first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -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 -->
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
# Enforce valid credential documentationUrl format (URL or lowercase alphanumeric slug) (`@n8n/community-nodes/credential-documentation-url`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Options
|
||||
|
||||
<!-- begin auto-generated rule options list -->
|
||||
|
||||
| Name | Description | Type |
|
||||
| :----------- | :----------------------------------------------------- | :------ |
|
||||
| `allowSlugs` | Whether to allow lowercase alphanumeric slugs with slashes | Boolean |
|
||||
| `allowUrls` | Whether to allow valid URLs | Boolean |
|
||||
|
||||
<!-- end auto-generated rule options list -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Ensures that credential `documentationUrl` values are in a valid format. For community packages, this should always be a complete URL to your documentation.
|
||||
|
||||
The lowercase alphanumeric slug option (`allowSlugs`) is only intended for internal n8n use when referring to slugs on docs.n8n.io, and should not be used in community packages. When enabled, uppercase letters in slugs will be automatically converted to lowercase.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApi';
|
||||
displayName = 'My API';
|
||||
documentationUrl = 'invalid-url-format'; // Not a valid URL
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApi';
|
||||
displayName = 'My API';
|
||||
documentationUrl = 'MyApi'; // Invalid: uppercase letters (will be autofixed to 'myapi')
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApi';
|
||||
displayName = 'My API';
|
||||
documentationUrl = 'my-api'; // Invalid: special characters not allowed
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApi';
|
||||
displayName = 'My API';
|
||||
documentationUrl = 'https://docs.myservice.com/api-setup'; // Complete URL to documentation
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApi';
|
||||
displayName = 'My API';
|
||||
documentationUrl = 'https://github.com/myuser/n8n-nodes-myapi#credentials'; // GitHub README section
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
By default, only URLs are allowed, which is the recommended setting for community packages.
|
||||
|
||||
The `allowSlugs` option is available for internal n8n development:
|
||||
|
||||
```json
|
||||
{
|
||||
"rules": {
|
||||
"@n8n/community-nodes/credential-documentation-url": [
|
||||
"error",
|
||||
{
|
||||
"allowSlugs": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Community package developers should keep the default settings and always use complete URLs for their documentation.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Ensure credential fields with sensitive names have typeOptions.password = true (`@n8n/community-nodes/credential-password-field`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Ensures that credential fields with names like "password", "secret", "token", or "key" are properly masked in the UI by having `typeOptions.password = true`.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
default: '',
|
||||
// Missing typeOptions.password
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,58 @@
|
||||
# Ensure credentials have a credential test (`@n8n/community-nodes/credential-test-required`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Ensures that your credentials include a `test` method to validate user credentials. This helps users verify their credentials are working correctly.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApi';
|
||||
displayName = 'My API';
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
// Missing test method
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApi';
|
||||
displayName = 'My API';
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
baseURL: 'https://api.myservice.com',
|
||||
url: '/user',
|
||||
method: 'GET',
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,67 @@
|
||||
# Validate node and credential icon files exist, are SVG format, and light/dark icons are different (`@n8n/community-nodes/icon-validation`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Validates that your node and credential icon files exist, are in SVG format, and use the correct `file:` protocol. Icons must be different files when providing light/dark theme variants.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Node',
|
||||
name: 'myNode',
|
||||
icon: 'icons/my-icon.png', // Missing 'file:' prefix, wrong format
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Node',
|
||||
name: 'myNode',
|
||||
icon: {
|
||||
light: 'file:icons/my-icon.svg',
|
||||
dark: 'file:icons/my-icon.svg', // Same file for both themes
|
||||
},
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Node',
|
||||
name: 'myNode',
|
||||
icon: 'file:icons/my-service.svg', // Correct format
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Node',
|
||||
name: 'myNode',
|
||||
icon: {
|
||||
light: 'file:icons/my-service-light.svg',
|
||||
dark: 'file:icons/my-service-dark.svg', // Different files
|
||||
},
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,82 @@
|
||||
# Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package (`@n8n/community-nodes/no-credential-reuse`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Ensures your nodes only reference credentials by their `name` property that match credential classes declared in your package's `package.json` file. This prevents security issues where nodes could access credentials from other packages.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
// MyApiCredential.credentials.ts
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApiCredential';
|
||||
displayName = 'My API';
|
||||
// ...
|
||||
}
|
||||
|
||||
// package.json: "n8n": { "credentials": ["dist/credentials/MyApiCredential.credentials.js"] }
|
||||
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Node',
|
||||
name: 'myNode',
|
||||
credentials: [
|
||||
{
|
||||
name: 'someOtherCredential', // No credential class with this name in package
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
// MyApiCredential.credentials.ts
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
name = 'myApiCredential'; // This name must match what's used in nodes
|
||||
displayName = 'My API';
|
||||
// ...
|
||||
}
|
||||
|
||||
// package.json: "n8n": { "credentials": ["dist/credentials/MyApiCredential.credentials.js"] }
|
||||
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Node',
|
||||
name: 'myNode',
|
||||
credentials: [
|
||||
{
|
||||
name: 'myApiCredential', // Matches credential class name property
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
Declare your credential files in `package.json` and ensure the credential name in nodes matches the `name` property in your credential classes:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "n8n-nodes-my-service",
|
||||
"n8n": {
|
||||
"credentials": [
|
||||
"dist/credentials/MyApiCredential.credentials.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# Disallow usage of deprecated functions and types from n8n-workflow package (`@n8n/community-nodes/no-deprecated-workflow-functions`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Prevents usage of deprecated functions from n8n-workflow package and suggests modern alternatives.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
import { IRequestOptions } from 'n8n-workflow';
|
||||
|
||||
export class MyNode implements INodeType {
|
||||
async execute(this: IExecuteFunctions) {
|
||||
// Using deprecated request helper function
|
||||
const response = await this.helpers.request({
|
||||
method: 'GET',
|
||||
url: 'https://api.example.com/data',
|
||||
});
|
||||
|
||||
// Using deprecated type
|
||||
const options: IRequestOptions = {
|
||||
method: 'POST',
|
||||
url: 'https://api.example.com/data',
|
||||
};
|
||||
|
||||
return [this.helpers.returnJsonArray([response])];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
import { IHttpRequestOptions } from 'n8n-workflow';
|
||||
|
||||
export class MyNode implements INodeType {
|
||||
async execute(this: IExecuteFunctions) {
|
||||
// Using modern httpRequest helper function
|
||||
const response = await this.helpers.httpRequest({
|
||||
method: 'GET',
|
||||
url: 'https://api.example.com/data',
|
||||
});
|
||||
|
||||
// Using modern type
|
||||
const options: IHttpRequestOptions = {
|
||||
method: 'POST',
|
||||
url: 'https://api.example.com/data',
|
||||
};
|
||||
|
||||
return [this.helpers.returnJsonArray([response])];
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
# Disallow usage of restricted global variables in community nodes (`@n8n/community-nodes/no-restricted-globals`)
|
||||
|
||||
💼 This rule is enabled in the ✅ `recommended` config.
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Prevents the use of Node.js global variables that are not allowed in n8n Cloud. While these globals may be available in self-hosted environments, they are restricted on n8n Cloud for security and stability reasons.
|
||||
|
||||
Restricted globals include: `clearInterval`, `clearTimeout`, `global`, `globalThis`, `process`, `setInterval`, `setTimeout`, `setImmediate`, `clearImmediate`, `__dirname`, `__filename`.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
async execute(this: IExecuteFunctions) {
|
||||
// These globals are not allowed on n8n Cloud
|
||||
const pid = process.pid;
|
||||
const dir = __dirname;
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('This will not work on n8n Cloud');
|
||||
}, 1000);
|
||||
|
||||
return this.prepareOutputData([]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
async execute(this: IExecuteFunctions) {
|
||||
// Use n8n context methods instead
|
||||
const timezone = this.getTimezone();
|
||||
|
||||
return this.prepareOutputData([]);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,47 @@
|
||||
# Disallow usage of restricted imports in community nodes (`@n8n/community-nodes/no-restricted-imports`)
|
||||
|
||||
💼 This rule is enabled in the ✅ `recommended` config.
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Prevents importing external dependencies that are not allowed on n8n Cloud. Community nodes running on n8n Cloud are restricted to a specific set of allowed modules for security and performance reasons.
|
||||
|
||||
**Allowed modules:** `n8n-workflow`, `lodash`, `moment`, `p-limit`, `luxon`, `zod`, `crypto`, `node:crypto`
|
||||
|
||||
Relative imports (starting with `./` or `../`) are always allowed.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
import axios from 'axios'; // External dependency not allowed
|
||||
import { readFile } from 'fs'; // Node.js modules not in allowlist
|
||||
const request = require('request'); // Same applies to require()
|
||||
|
||||
// Dynamic imports are also restricted
|
||||
const module = await import('some-package');
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
import { IExecuteFunctions, INodeType } from 'n8n-workflow'; // Allowed
|
||||
import { get } from 'lodash'; // Allowed
|
||||
import moment from 'moment'; // Allowed
|
||||
import { DateTime } from 'luxon'; // Allowed
|
||||
import { createHash } from 'crypto'; // Allowed
|
||||
|
||||
import { MyHelper } from './helpers/MyHelper'; // Relative imports allowed
|
||||
import config from '../config'; // Relative imports allowed
|
||||
|
||||
export class MyNode implements INodeType {
|
||||
// ... implementation
|
||||
}
|
||||
```
|
||||
|
||||
## When This Rule Doesn't Apply
|
||||
|
||||
This rule only applies to community nodes intended for n8n Cloud. If you're building nodes exclusively for self-hosted environments, you may disable this rule, but be aware that your package will not be compatible with n8n Cloud.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Ensure node classes have usableAsTool property (`@n8n/community-nodes/node-usable-as-tool`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Ensures your nodes declare whether they can be used as tools in AI workflows. This property helps n8n determine if your node is suitable for AI-assisted automation.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Node',
|
||||
name: 'myNode',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
// Missing usableAsTool property
|
||||
properties: [],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Node',
|
||||
name: 'myNode',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
usableAsTool: true,
|
||||
properties: [],
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
# Enforce correct package naming convention for n8n community nodes (`@n8n/community-nodes/package-name-convention`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Validates that your package name follows the correct n8n community node naming convention. Package names must start with `n8n-nodes-` and can optionally be scoped.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-service-integration"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "nodes-my-service"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@company/my-service"
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "n8n-nodes-my-service"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@company/n8n-nodes-my-service"
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use descriptive service names: `n8n-nodes-github` rather than `n8n-nodes-api`
|
||||
- For company packages, use your organization scope: `@mycompany/n8n-nodes-internal-tool`
|
||||
@@ -0,0 +1,84 @@
|
||||
# Enforce proper resource/operation pattern for better UX in n8n nodes (`@n8n/community-nodes/resource-operation-pattern`)
|
||||
|
||||
⚠️ This rule _warns_ in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Warns when a node has more than 5 operations without organizing them into resources. The resource/operation pattern improves user experience by grouping related operations together, making complex nodes easier to navigate.
|
||||
|
||||
When you have many operations, users benefit from having them organized into logical resource groups (e.g., "User", "Project", "File") rather than seeing a long flat list of operations.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Service',
|
||||
name: 'myService',
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Get User', value: 'getUser' },
|
||||
{ name: 'Create User', value: 'createUser' },
|
||||
{ name: 'Update User', value: 'updateUser' },
|
||||
{ name: 'Delete User', value: 'deleteUser' },
|
||||
{ name: 'Get Project', value: 'getProject' },
|
||||
{ name: 'Create Project', value: 'createProject' },
|
||||
{ name: 'List Files', value: 'listFiles' },
|
||||
// 7+ operations without resources - hard to navigate!
|
||||
],
|
||||
},
|
||||
// ... other properties
|
||||
],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
export class MyNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'My Service',
|
||||
name: 'myService',
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'User', value: 'user' },
|
||||
{ name: 'Project', value: 'project' },
|
||||
{ name: 'File', value: 'file' },
|
||||
],
|
||||
default: 'user',
|
||||
},
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['user'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{ name: 'Get', value: 'get' },
|
||||
{ name: 'Create', value: 'create' },
|
||||
{ name: 'Update', value: 'update' },
|
||||
{ name: 'Delete', value: 'delete' },
|
||||
],
|
||||
default: 'get',
|
||||
},
|
||||
// ... similar operation blocks for 'project' and 'file' resources
|
||||
],
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import { nodeConfig } from '@n8n/eslint-config/node';
|
||||
import eslintPlugin from 'eslint-plugin-eslint-plugin';
|
||||
|
||||
export default defineConfig([
|
||||
nodeConfig,
|
||||
eslintPlugin.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
allowDefaultProject: true,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// We use RuleCreator which adds this automatically
|
||||
'eslint-plugin/require-meta-docs-url': 'off',
|
||||
// typescript-eslint uses different pattern
|
||||
'eslint-plugin/require-meta-default-options': 'off',
|
||||
// Disable naming convention for plugin configs (ESLint rule names use kebab-case)
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
// Allow default exports for ESLint plugin
|
||||
'import-x/no-default-export': 'off',
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@n8n/eslint-plugin-community-nodes",
|
||||
"type": "module",
|
||||
"version": "0.8.0",
|
||||
"main": "./dist/plugin.js",
|
||||
"types": "./dist/plugin.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/plugin.d.ts",
|
||||
"default": "./dist/plugin.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"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:unit": "vitest run",
|
||||
"test:dev": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"watch": "tsc --watch --project tsconfig.build.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"☑️"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ESLint, Linter } from 'eslint';
|
||||
|
||||
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/community-nodes',
|
||||
},
|
||||
// @ts-expect-error Rules type does not match for typescript-eslint and eslint
|
||||
rules: rules as ESLint.Plugin['rules'],
|
||||
} satisfies ESLint.Plugin;
|
||||
|
||||
const configs = {
|
||||
recommended: {
|
||||
ignores: ['eslint.config.{js,mjs,ts,mts}'],
|
||||
plugins: {
|
||||
'@n8n/community-nodes': plugin,
|
||||
},
|
||||
rules: {
|
||||
'@n8n/community-nodes/ai-node-package-json': 'error',
|
||||
'@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/community-nodes': plugin,
|
||||
},
|
||||
rules: {
|
||||
'@n8n/community-nodes/ai-node-package-json': '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/credential-documentation-url': 'error',
|
||||
'@n8n/community-nodes/resource-operation-pattern': 'warn',
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, Linter.Config>;
|
||||
|
||||
const pluginWithConfigs = { ...plugin, configs } satisfies ESLint.Plugin;
|
||||
|
||||
const n8nCommunityNodesPlugin = pluginWithConfigs;
|
||||
export default pluginWithConfigs;
|
||||
export { rules, configs, n8nCommunityNodesPlugin };
|
||||
@@ -0,0 +1,111 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { AiNodePackageJsonRule } from './ai-node-package-json.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run('ai-node-package-json', AiNodePackageJsonRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'both n8n.aiNodeSdkVersion and ai-node-sdk peer dependency present',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 1 }, "peerDependencies": { "n8n-workflow": "*", "ai-node-sdk": "*" } }',
|
||||
},
|
||||
{
|
||||
name: 'neither n8n.aiNodeSdkVersion nor ai-node-sdk present (non-AI package)',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
|
||||
},
|
||||
{
|
||||
name: 'n8n section without aiNodeSdkVersion and no ai-node-sdk peer dep',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "nodes": [] } }',
|
||||
},
|
||||
{
|
||||
name: 'non-package.json file is ignored',
|
||||
filename: 'some-config.json',
|
||||
code: '{ "n8n": { "aiNodeSdkVersion": 1 } }',
|
||||
},
|
||||
{
|
||||
name: 'peerDependencies without ai-node-sdk and no aiNodeSdkVersion',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "nodes": [] }, "peerDependencies": { "n8n-workflow": "*" } }',
|
||||
},
|
||||
{
|
||||
name: 'aiNodeSdkVersion as a larger positive integer with multiple peer deps',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 42 }, "peerDependencies": { "n8n-workflow": "^1.0.0", "ai-node-sdk": "^1.0.0" } }',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'n8n.aiNodeSdkVersion present but ai-node-sdk missing from peerDependencies',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 1 } }',
|
||||
errors: [{ messageId: 'missingPeerDep' }],
|
||||
},
|
||||
{
|
||||
name: 'n8n.aiNodeSdkVersion present but peerDependencies has other deps only',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 1 }, "peerDependencies": { "n8n-workflow": "*" } }',
|
||||
errors: [{ messageId: 'missingPeerDep' }],
|
||||
},
|
||||
{
|
||||
name: 'ai-node-sdk in peerDependencies but n8n.aiNodeSdkVersion missing',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "nodes": [] }, "peerDependencies": { "n8n-workflow": "*", "ai-node-sdk": "*" } }',
|
||||
errors: [{ messageId: 'missingSdkVersion' }],
|
||||
},
|
||||
{
|
||||
name: 'ai-node-sdk in peerDependencies but no n8n section at all',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*", "ai-node-sdk": "*" } }',
|
||||
errors: [{ messageId: 'missingSdkVersion' }],
|
||||
},
|
||||
{
|
||||
name: 'n8n.aiNodeSdkVersion is a string instead of integer',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": "1" }, "peerDependencies": { "ai-node-sdk": "*" } }',
|
||||
errors: [{ messageId: 'invalidSdkVersion', data: { value: '1' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8n.aiNodeSdkVersion is zero',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 0 }, "peerDependencies": { "ai-node-sdk": "*" } }',
|
||||
errors: [{ messageId: 'invalidSdkVersion', data: { value: '0' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8n.aiNodeSdkVersion is negative (parsed as UnaryExpression, not Literal)',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": -1 }, "peerDependencies": { "ai-node-sdk": "*" } }',
|
||||
errors: [{ messageId: 'invalidSdkVersion', data: { value: 'non-literal' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8n.aiNodeSdkVersion is a float',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 1.5 }, "peerDependencies": { "ai-node-sdk": "*" } }',
|
||||
errors: [{ messageId: 'invalidSdkVersion', data: { value: '1.5' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8n.aiNodeSdkVersion is invalid and ai-node-sdk peer dep is missing (two errors)',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": "bad" } }',
|
||||
errors: [
|
||||
{ messageId: 'invalidSdkVersion', data: { value: 'bad' } },
|
||||
{ messageId: 'missingPeerDep' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'aiNodeSdkVersion at root level instead of inside n8n section',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "aiNodeSdkVersion": 1, "peerDependencies": { "ai-node-sdk": "*" } }',
|
||||
errors: [{ messageId: 'wrongLocation' }, { messageId: 'missingSdkVersion' }],
|
||||
},
|
||||
{
|
||||
name: 'aiNodeSdkVersion at root level without peer dep',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "aiNodeSdkVersion": 1 }',
|
||||
errors: [{ messageId: 'wrongLocation' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { TSESTree } from '@typescript-eslint/utils';
|
||||
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
|
||||
import { createRule, findJsonProperty } from '../utils/index.js';
|
||||
|
||||
export const AiNodePackageJsonRule = createRule({
|
||||
name: 'ai-node-package-json',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Enforce consistency between n8n.aiNodeSdkVersion and ai-node-sdk peer dependency in community node packages',
|
||||
},
|
||||
messages: {
|
||||
missingPeerDep:
|
||||
'Package declares "n8n.aiNodeSdkVersion" but is missing "ai-node-sdk" in peerDependencies. Add "ai-node-sdk": "*" to peerDependencies.',
|
||||
missingSdkVersion:
|
||||
'Package has "ai-node-sdk" in peerDependencies but is missing "aiNodeSdkVersion" in the "n8n" section of package.json.',
|
||||
invalidSdkVersion: '"n8n.aiNodeSdkVersion" must be a positive integer, got {{ value }}.',
|
||||
wrongLocation:
|
||||
'"aiNodeSdkVersion" must be inside the "n8n" section, not at the root level of package.json.',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
if (!context.filename.endsWith('package.json')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
ObjectExpression(node: TSESTree.ObjectExpression) {
|
||||
// Only process the root object, not nested ones
|
||||
if (node.parent?.type === AST_NODE_TYPES.Property) {
|
||||
return;
|
||||
}
|
||||
|
||||
const n8nProp = findJsonProperty(node, 'n8n');
|
||||
const n8nObject =
|
||||
n8nProp?.value.type === AST_NODE_TYPES.ObjectExpression ? n8nProp.value : null;
|
||||
|
||||
const aiNodeSdkVersionProp = n8nObject
|
||||
? findJsonProperty(n8nObject, 'aiNodeSdkVersion')
|
||||
: null;
|
||||
|
||||
const rootAiNodeSdkVersionProp = findJsonProperty(node, 'aiNodeSdkVersion');
|
||||
const peerDependenciesProp = findJsonProperty(node, 'peerDependencies');
|
||||
|
||||
const hasAiNodeSdkVersion = aiNodeSdkVersionProp !== null;
|
||||
const hasAiNodeSdkPeerDep =
|
||||
peerDependenciesProp?.value.type === AST_NODE_TYPES.ObjectExpression &&
|
||||
findJsonProperty(peerDependenciesProp.value, 'ai-node-sdk') !== null;
|
||||
|
||||
// Catch aiNodeSdkVersion placed at root level instead of inside n8n
|
||||
if (rootAiNodeSdkVersionProp) {
|
||||
context.report({
|
||||
node: rootAiNodeSdkVersionProp,
|
||||
messageId: 'wrongLocation',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate aiNodeSdkVersion is a positive integer when present
|
||||
if (hasAiNodeSdkVersion) {
|
||||
const valueNode = aiNodeSdkVersionProp.value;
|
||||
if (valueNode.type !== AST_NODE_TYPES.Literal || !isPositiveInteger(valueNode.value)) {
|
||||
context.report({
|
||||
node: aiNodeSdkVersionProp,
|
||||
messageId: 'invalidSdkVersion',
|
||||
data: {
|
||||
value: String(
|
||||
valueNode.type === AST_NODE_TYPES.Literal ? valueNode.value : 'non-literal',
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If aiNodeSdkVersion is declared, ai-node-sdk must be in peerDependencies
|
||||
if (hasAiNodeSdkVersion && !hasAiNodeSdkPeerDep) {
|
||||
context.report({
|
||||
node: aiNodeSdkVersionProp,
|
||||
messageId: 'missingPeerDep',
|
||||
});
|
||||
}
|
||||
|
||||
// If ai-node-sdk is in peerDependencies, aiNodeSdkVersion must be declared
|
||||
if (hasAiNodeSdkPeerDep && !hasAiNodeSdkVersion) {
|
||||
context.report({
|
||||
node: peerDependenciesProp,
|
||||
messageId: 'missingSdkVersion',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function isPositiveInteger(value: unknown): boolean {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0;
|
||||
}
|
||||
+306
@@ -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)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
+129
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { CredentialPasswordFieldRule } from './credential-password-field.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
function createCredentialCode(properties: string[]): string {
|
||||
return `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class TestCredential implements ICredentialType {
|
||||
name = 'testApi';
|
||||
displayName = 'Test API';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
${properties.map((prop) => `\t\t${prop}`).join(',\n')},
|
||||
];
|
||||
}`;
|
||||
}
|
||||
|
||||
function createProperty(
|
||||
displayName: string,
|
||||
name: string,
|
||||
options: { password?: boolean; emptyTypeOptions?: boolean } = {},
|
||||
): string {
|
||||
let typeOptionsStr = '';
|
||||
if (options.emptyTypeOptions) {
|
||||
typeOptionsStr = '\n\t\t\ttypeOptions: {},';
|
||||
} else if (options.password !== undefined) {
|
||||
typeOptionsStr = `\n\t\t\ttypeOptions: { password: ${options.password} },`;
|
||||
}
|
||||
|
||||
return `{
|
||||
displayName: '${displayName}',
|
||||
name: '${name}',
|
||||
type: 'string',
|
||||
default: '',${typeOptionsStr}
|
||||
}`;
|
||||
}
|
||||
|
||||
function createOAuth2CredentialCode(hasPasswordProtection: boolean = true): string {
|
||||
const passwordOptions = hasPasswordProtection ? '\n\t\t\ttypeOptions: { password: true },' : '';
|
||||
|
||||
return `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class GithubOAuth2Api implements ICredentialType {
|
||||
name = 'githubOAuth2Api';
|
||||
extends = ['oAuth2Api'];
|
||||
displayName = 'GitHub OAuth2 API';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Access Token URL',
|
||||
name: 'accessTokenUrl',
|
||||
type: 'hidden',
|
||||
default: 'https://github.com/login/oauth/access_token',
|
||||
},
|
||||
{
|
||||
displayName: 'Client Secret',
|
||||
name: 'clientSecret',
|
||||
type: 'string',
|
||||
default: '',${passwordOptions}
|
||||
},
|
||||
];
|
||||
}`;
|
||||
}
|
||||
|
||||
// Helper function to create a regular (non-credential) class
|
||||
function createRegularClass(): string {
|
||||
return `
|
||||
export class RegularClass {
|
||||
properties = [
|
||||
{
|
||||
name: 'password',
|
||||
type: 'string',
|
||||
},
|
||||
];
|
||||
}`;
|
||||
}
|
||||
|
||||
ruleTester.run('credential-password-field', CredentialPasswordFieldRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'correct usage with password field having typeOptions.password = true',
|
||||
code: createCredentialCode([createProperty('API Key', 'apiKey', { password: true })]),
|
||||
},
|
||||
{
|
||||
name: 'field name is not sensitive',
|
||||
code: createCredentialCode([createProperty('Base URL', 'baseUrl')]),
|
||||
},
|
||||
{
|
||||
name: 'multiple sensitive fields with correct typeOptions',
|
||||
code: createCredentialCode([
|
||||
createProperty('Password', 'password', { password: true }),
|
||||
createProperty('Secret Token', 'secretToken', { password: true }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'class does not implement ICredentialType',
|
||||
code: createRegularClass(),
|
||||
},
|
||||
{
|
||||
name: 'OAuth2 credential with URL fields and proper client secret protection',
|
||||
code: createOAuth2CredentialCode(true),
|
||||
},
|
||||
{
|
||||
name: 'public key fields should not be flagged as sensitive',
|
||||
code: createCredentialCode([
|
||||
createProperty('Public Key', 'publicKey'),
|
||||
createProperty('Client ID', 'clientId'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'certificates should be flagged as sensitive (when properly configured)',
|
||||
code: createCredentialCode([
|
||||
createProperty('Certificate', 'certificate', { password: true }),
|
||||
createProperty('CA Certificate', 'caCert', { password: true }),
|
||||
createProperty('Client Certificate', 'clientCert', { password: true }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'URL fields containing sensitive keywords should not be flagged',
|
||||
code: createCredentialCode([
|
||||
createProperty('Token URL', 'tokenUrl'),
|
||||
createProperty('Authorization URL', 'authorizationUrl'),
|
||||
createProperty('Access Token URL', 'accessTokenUrl'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'ID fields containing sensitive keywords should not be flagged',
|
||||
code: createCredentialCode([
|
||||
createProperty('Access Key ID', 'accessKeyId'),
|
||||
createProperty('Key ID', 'keyId'),
|
||||
createProperty('User ID', 'userId'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'file path and name fields should not be flagged',
|
||||
code: createCredentialCode([
|
||||
createProperty('Key File Path', 'keyPath'),
|
||||
createProperty('Key File Name', 'keyFile'),
|
||||
createProperty('Certificate Path', 'keyName'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'should flag actual sensitive fields while ignoring false positives',
|
||||
code: createCredentialCode([
|
||||
createProperty('Private Key', 'privateKey', { password: true }),
|
||||
createProperty('Public Key', 'publicKey'), // Should not be flagged
|
||||
createProperty('Secret Token', 'secretToken', { password: true }),
|
||||
createProperty('Client ID', 'clientId'), // Should not be flagged
|
||||
]),
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'password field missing typeOptions.password = true',
|
||||
code: createCredentialCode([createProperty('Password', 'password')]),
|
||||
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'password' } }],
|
||||
output: createCredentialCode([createProperty('Password', 'password', { password: true })]),
|
||||
},
|
||||
{
|
||||
name: 'API key field missing typeOptions.password = true',
|
||||
code: createCredentialCode([createProperty('API Key', 'apiKey')]),
|
||||
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'apiKey' } }],
|
||||
output: createCredentialCode([createProperty('API Key', 'apiKey', { password: true })]),
|
||||
},
|
||||
{
|
||||
name: 'secret field missing typeOptions.password = true',
|
||||
code: createCredentialCode([createProperty('Client Secret', 'clientSecret')]),
|
||||
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'clientSecret' } }],
|
||||
output: createCredentialCode([
|
||||
createProperty('Client Secret', 'clientSecret', { password: true }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'multiple invalid fields',
|
||||
code: createCredentialCode([
|
||||
createProperty('Password', 'password'),
|
||||
createProperty('Username', 'username'),
|
||||
createProperty('Access Token', 'accessToken'),
|
||||
]),
|
||||
errors: [
|
||||
{ messageId: 'missingPasswordOption', data: { fieldName: 'password' } },
|
||||
{ messageId: 'missingPasswordOption', data: { fieldName: 'accessToken' } },
|
||||
],
|
||||
output: createCredentialCode([
|
||||
createProperty('Password', 'password', { password: true }),
|
||||
createProperty('Username', 'username'),
|
||||
createProperty('Access Token', 'accessToken', { password: true }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'field has typeOptions but password is false',
|
||||
code: createCredentialCode([createProperty('API Key', 'apiKey', { password: false })]),
|
||||
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'apiKey' } }],
|
||||
output: createCredentialCode([createProperty('API Key', 'apiKey', { password: true })]),
|
||||
},
|
||||
{
|
||||
name: 'OAuth2 credential with missing password protection for clientSecret',
|
||||
code: createOAuth2CredentialCode(false),
|
||||
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'clientSecret' } }],
|
||||
output: createOAuth2CredentialCode(true),
|
||||
},
|
||||
{
|
||||
name: 'field has empty typeOptions object',
|
||||
code: createCredentialCode([
|
||||
createProperty('Access Token', 'accessToken', { emptyTypeOptions: true }),
|
||||
]),
|
||||
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'accessToken' } }],
|
||||
output: createCredentialCode([
|
||||
createProperty('Access Token', 'accessToken', { password: true }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'certificate fields should require password protection',
|
||||
code: createCredentialCode([
|
||||
createProperty('Certificate', 'certificate'),
|
||||
createProperty('Client Certificate', 'clientCert'),
|
||||
]),
|
||||
errors: [
|
||||
{ messageId: 'missingPasswordOption', data: { fieldName: 'certificate' } },
|
||||
{ messageId: 'missingPasswordOption', data: { fieldName: 'clientCert' } },
|
||||
],
|
||||
output: createCredentialCode([
|
||||
createProperty('Certificate', 'certificate', { password: true }),
|
||||
createProperty('Client Certificate', 'clientCert', { password: true }),
|
||||
]),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
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 = [
|
||||
'password',
|
||||
'secret',
|
||||
'token',
|
||||
'cert',
|
||||
'passphrase',
|
||||
'apikey',
|
||||
'secretkey',
|
||||
'privatekey',
|
||||
'authkey',
|
||||
];
|
||||
|
||||
const NON_SENSITIVE_PATTERNS = ['url', 'pub', 'id'];
|
||||
|
||||
function isSensitiveFieldName(name: string): boolean {
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
if (NON_SENSITIVE_PATTERNS.some((pattern) => lowerName.includes(pattern))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SENSITIVE_PATTERNS.some((pattern) => lowerName.includes(pattern));
|
||||
}
|
||||
|
||||
function hasPasswordTypeOption(element: TSESTree.ObjectExpression): boolean {
|
||||
const typeOptionsProperty = findObjectProperty(element, 'typeOptions');
|
||||
|
||||
if (typeOptionsProperty?.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const passwordProperty = findObjectProperty(typeOptionsProperty.value, 'password');
|
||||
const passwordValue = passwordProperty ? getBooleanLiteralValue(passwordProperty.value) : null;
|
||||
|
||||
return passwordValue === true;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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 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];
|
||||
if (lastProperty) {
|
||||
return fixer.insertTextAfter(lastProperty, ',\n\t\t\ttypeOptions: { password: true }');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
export const CredentialPasswordFieldRule = createRule({
|
||||
name: 'credential-password-field',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ensure credential fields with sensitive names have typeOptions.password = true',
|
||||
},
|
||||
messages: {
|
||||
missingPasswordOption:
|
||||
"Field '{{ fieldName }}' appears to be a sensitive field but is missing 'typeOptions: { password: true }'",
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
if (!isCredentialTypeClass(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const propertiesProperty = findClassProperty(node, 'properties');
|
||||
if (
|
||||
!propertiesProperty?.value ||
|
||||
propertiesProperty.value.type !== TSESTree.AST_NODE_TYPES.ArrayExpression
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const element of propertiesProperty.value.elements) {
|
||||
if (element?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nameProperty = findObjectProperty(element, 'name');
|
||||
const fieldName = nameProperty ? getStringLiteralValue(nameProperty.value) : null;
|
||||
|
||||
if (!fieldName || !isSensitiveFieldName(fieldName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasPasswordTypeOption(element)) {
|
||||
const typeOptionsProperty = findObjectProperty(element, 'typeOptions');
|
||||
|
||||
context.report({
|
||||
node: element,
|
||||
messageId: 'missingPasswordOption',
|
||||
data: { fieldName },
|
||||
fix: createPasswordFix(element, typeOptionsProperty),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { CredentialTestRequiredRule } from './credential-test-required.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
// Helper function to create credential class code
|
||||
function createCredentialCode(options: {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
hasTest?: boolean;
|
||||
extends?: string[];
|
||||
extraProperties?: string;
|
||||
}): string {
|
||||
const {
|
||||
name = 'myApi',
|
||||
displayName = 'My API',
|
||||
hasTest = false,
|
||||
extends: extendsArray,
|
||||
extraProperties = '',
|
||||
} = options;
|
||||
|
||||
const imports = hasTest
|
||||
? "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};"
|
||||
: '';
|
||||
|
||||
return `
|
||||
${imports}
|
||||
|
||||
export class ${name.charAt(0).toUpperCase() + name.slice(1)} implements ICredentialType {
|
||||
name = '${name}';${extendsStr}
|
||||
displayName = '${displayName}';
|
||||
properties: INodeProperties[] = [];${testProperty}${extraProperties}
|
||||
}`;
|
||||
}
|
||||
|
||||
function createNonCredentialClass(className: string = 'SomeOtherClass'): string {
|
||||
return `
|
||||
export class ${className} {
|
||||
name = 'notACredential';
|
||||
}`;
|
||||
}
|
||||
|
||||
ruleTester.run('credential-test-required', CredentialTestRequiredRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'credential class with test property',
|
||||
filename: 'MyApi.credentials.ts',
|
||||
code: createCredentialCode({ hasTest: true }),
|
||||
},
|
||||
{
|
||||
name: 'credential class extending oAuth2Api (exempt)',
|
||||
filename: 'MyOAuth2Api.credentials.ts',
|
||||
code: createCredentialCode({
|
||||
name: 'myOAuth2Api',
|
||||
displayName: 'My OAuth2 API',
|
||||
extends: ['oAuth2Api'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'non-credential class ignored',
|
||||
filename: 'MyApi.credentials.ts',
|
||||
code: createNonCredentialClass(),
|
||||
},
|
||||
{
|
||||
name: 'non-credential file ignored',
|
||||
filename: 'regular-file.ts',
|
||||
code: createCredentialCode({}),
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'credential class missing test property and no testedBy in package',
|
||||
filename: 'MyApi.credentials.ts',
|
||||
code: createCredentialCode({}),
|
||||
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' },
|
||||
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' },
|
||||
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
|
||||
},
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
import {
|
||||
isCredentialTypeClass,
|
||||
findClassProperty,
|
||||
hasArrayLiteralValue,
|
||||
isFileType,
|
||||
getStringLiteralValue,
|
||||
findPackageJson,
|
||||
areAllCredentialUsagesTestedByNodes,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
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) {
|
||||
if (!isFileType(context.filename, '.credentials.ts')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let packageDir: string | null = null;
|
||||
|
||||
const getPackageDir = (): string | null => {
|
||||
if (packageDir !== null) {
|
||||
return packageDir;
|
||||
}
|
||||
|
||||
const packageJsonPath = findPackageJson(context.filename);
|
||||
if (!packageJsonPath) {
|
||||
packageDir = '';
|
||||
return packageDir;
|
||||
}
|
||||
|
||||
packageDir = dirname(packageJsonPath);
|
||||
return packageDir;
|
||||
};
|
||||
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
if (!isCredentialTypeClass(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const extendsProperty = findClassProperty(node, 'extends');
|
||||
if (extendsProperty && hasArrayLiteralValue(extendsProperty, 'oAuth2Api')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const testProperty = findClassProperty(node, 'test');
|
||||
if (testProperty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nameProperty = findClassProperty(node, 'name');
|
||||
if (!nameProperty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialName = getStringLiteralValue(nameProperty.value);
|
||||
if (!credentialName) {
|
||||
return;
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
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};`;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
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();
|
||||
|
||||
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 [];
|
||||
});
|
||||
}
|
||||
|
||||
setupMockFileSystem();
|
||||
|
||||
const nodeFilePath = '/tmp/TestNode.node.ts';
|
||||
const credentialFilePath = '/tmp/TestCredential.credentials.ts';
|
||||
|
||||
function createNodeCode(
|
||||
icon?: string | { light: string; dark: string },
|
||||
includeTypeImport: boolean = false,
|
||||
): string {
|
||||
const typeImport = includeTypeImport
|
||||
? "import type { INodeType, INodeTypeDescription } from 'n8n-workflow';"
|
||||
: "import type { INodeType } from 'n8n-workflow';";
|
||||
|
||||
const typeAnnotation = includeTypeImport ? ': INodeTypeDescription' : '';
|
||||
|
||||
let iconProperty = '';
|
||||
if (icon) {
|
||||
if (typeof icon === 'string') {
|
||||
iconProperty = `icon: '${icon}',`;
|
||||
} else {
|
||||
iconProperty = `icon: {
|
||||
light: '${icon.light}',
|
||||
dark: '${icon.dark}'
|
||||
},`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
${typeImport}
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description${typeAnnotation} = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
${iconProperty}
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
description: 'A test node',
|
||||
defaults: {
|
||||
name: 'Test Node',
|
||||
},
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
function createCredentialCode(icon?: string | { light: string; dark: string }): string {
|
||||
let iconProperty = '';
|
||||
if (icon) {
|
||||
if (typeof icon === 'string') {
|
||||
iconProperty = `icon = '${icon}';`;
|
||||
} else {
|
||||
iconProperty = `icon = {
|
||||
light: '${icon.light}',
|
||||
dark: '${icon.dark}'
|
||||
};`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class TestCredential implements ICredentialType {
|
||||
name = 'testApi';
|
||||
displayName = 'Test API';
|
||||
${iconProperty}
|
||||
properties: INodeProperties[] = [];
|
||||
}`;
|
||||
}
|
||||
|
||||
// Helper function to create non-node class
|
||||
function createNonNodeClass(icon: string): string {
|
||||
return `
|
||||
export class NotANode {
|
||||
icon = '${icon}';
|
||||
}`;
|
||||
}
|
||||
|
||||
ruleTester.run('icon-validation', IconValidationRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'non-node class ignored',
|
||||
filename: nodeFilePath,
|
||||
code: createNonNodeClass('file:nonexistent.png'),
|
||||
},
|
||||
{
|
||||
name: 'non-node file ignored',
|
||||
filename: '/tmp/regular-file.ts',
|
||||
code: createNodeCode('file:nonexistent.svg'),
|
||||
},
|
||||
{
|
||||
name: 'node with valid string icon in description',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode('file:icons/TestNode.svg', true),
|
||||
},
|
||||
{
|
||||
name: 'node with valid light/dark icons in description',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode(
|
||||
{
|
||||
light: 'file:icons/ValidIcon.svg',
|
||||
dark: 'file:icons/ValidIcon.dark.svg',
|
||||
},
|
||||
true,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'credential with valid string icon',
|
||||
filename: credentialFilePath,
|
||||
code: createCredentialCode('file:icons/TestNode.svg'),
|
||||
},
|
||||
{
|
||||
name: 'credential with valid light/dark icons',
|
||||
filename: credentialFilePath,
|
||||
code: createCredentialCode({
|
||||
light: 'file:icons/ValidIcon.svg',
|
||||
dark: 'file:icons/ValidIcon.dark.svg',
|
||||
}),
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'node missing icon property in description',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode(undefined, true),
|
||||
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',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode('file:icons/NonExistent.svg', true),
|
||||
errors: [{ messageId: 'iconFileNotFound', data: { iconPath: 'icons/NonExistent.svg' } }],
|
||||
},
|
||||
{
|
||||
name: 'light and dark icons are the same file in description',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode(
|
||||
{
|
||||
light: 'file:icons/SameIcon.svg',
|
||||
dark: 'file:icons/SameIcon.svg',
|
||||
},
|
||||
true,
|
||||
),
|
||||
errors: [{ messageId: 'lightDarkSame', data: { iconPath: 'icons/SameIcon.svg' } }],
|
||||
},
|
||||
{
|
||||
name: 'credential missing icon property',
|
||||
filename: credentialFilePath,
|
||||
code: createCredentialCode(),
|
||||
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',
|
||||
filename: credentialFilePath,
|
||||
code: createCredentialCode('file:icons/NonExistent.svg'),
|
||||
errors: [{ messageId: 'iconFileNotFound', data: { iconPath: 'icons/NonExistent.svg' } }],
|
||||
},
|
||||
{
|
||||
name: 'credential light and dark icons are the same file',
|
||||
filename: credentialFilePath,
|
||||
code: createCredentialCode({
|
||||
light: 'file:icons/SameIcon.svg',
|
||||
dark: 'file:icons/SameIcon.svg',
|
||||
}),
|
||||
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: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { TSESTree } from '@typescript-eslint/utils';
|
||||
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
import {
|
||||
isNodeTypeClass,
|
||||
isCredentialTypeClass,
|
||||
findClassProperty,
|
||||
findObjectProperty,
|
||||
getStringLiteralValue,
|
||||
validateIconPath,
|
||||
findSimilarSvgFiles,
|
||||
isFileType,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
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,
|
||||
schema: [],
|
||||
hasSuggestions: true,
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
if (
|
||||
!isFileType(context.filename, '.node.ts') &&
|
||||
!isFileType(context.filename, '.credentials.ts')
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const validateIcon = (iconPath: string | null, node: TSESTree.Node): boolean => {
|
||||
if (!iconPath) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'invalidIconPath',
|
||||
data: { iconPath: iconPath ?? '' },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentDir = dirname(context.filename);
|
||||
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;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateIconValue = (iconValue: TSESTree.Node) => {
|
||||
if (iconValue.type === TSESTree.AST_NODE_TYPES.Literal) {
|
||||
const iconPath = getStringLiteralValue(iconValue);
|
||||
validateIcon(iconPath, iconValue);
|
||||
} else if (iconValue.type === TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
const lightProperty = findObjectProperty(iconValue, 'light');
|
||||
const darkProperty = findObjectProperty(iconValue, 'dark');
|
||||
|
||||
const lightPath = lightProperty ? getStringLiteralValue(lightProperty.value) : null;
|
||||
const darkPath = darkProperty ? getStringLiteralValue(darkProperty.value) : null;
|
||||
|
||||
if (lightProperty) {
|
||||
validateIcon(lightPath, lightProperty.value);
|
||||
}
|
||||
if (darkProperty) {
|
||||
validateIcon(darkPath, darkProperty.value);
|
||||
}
|
||||
|
||||
if (lightPath && darkPath && lightPath === darkPath && lightProperty) {
|
||||
context.report({
|
||||
node: lightProperty.value,
|
||||
messageId: 'lightDarkSame',
|
||||
data: { iconPath: lightPath.replace(/^file:/, '') },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
const isNodeClass = isNodeTypeClass(node);
|
||||
const isCredentialClass = isCredentialTypeClass(node);
|
||||
|
||||
if (!isNodeClass && !isCredentialClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNodeClass) {
|
||||
const descriptionProperty = findClassProperty(node, 'description');
|
||||
if (
|
||||
!descriptionProperty?.value ||
|
||||
descriptionProperty.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'missingIcon',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
validateIconValue(iconProperty.value);
|
||||
} 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;
|
||||
}
|
||||
|
||||
validateIconValue(iconProperty.value);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { AnyRuleModule } from '@typescript-eslint/utils/ts-eslint';
|
||||
|
||||
import { AiNodePackageJsonRule } from './ai-node-package-json.js';
|
||||
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 { NodeUsableAsToolRule } from './node-usable-as-tool.js';
|
||||
import { PackageNameConventionRule } from './package-name-convention.js';
|
||||
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
|
||||
|
||||
export const rules = {
|
||||
'ai-node-package-json': AiNodePackageJsonRule,
|
||||
'no-restricted-globals': NoRestrictedGlobalsRule,
|
||||
'no-restricted-imports': NoRestrictedImportsRule,
|
||||
'credential-password-field': CredentialPasswordFieldRule,
|
||||
'no-deprecated-workflow-functions': NoDeprecatedWorkflowFunctionsRule,
|
||||
'node-usable-as-tool': NodeUsableAsToolRule,
|
||||
'package-name-convention': PackageNameConventionRule,
|
||||
'credential-test-required': CredentialTestRequiredRule,
|
||||
'no-credential-reuse': NoCredentialReuseRule,
|
||||
'icon-validation': IconValidationRule,
|
||||
'resource-operation-pattern': ResourceOperationPatternRule,
|
||||
'credential-documentation-url': CredentialDocumentationUrlRule,
|
||||
} satisfies Record<string, AnyRuleModule>;
|
||||
@@ -0,0 +1,474 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
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();
|
||||
|
||||
const nodeFilePath = '/tmp/TestNode.node.ts';
|
||||
|
||||
function createNodeCode(
|
||||
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 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 `
|
||||
export class RegularClass {
|
||||
credentials = [
|
||||
{ name: 'ExternalApi', required: true }
|
||||
];
|
||||
}`;
|
||||
}
|
||||
|
||||
// Helper function to create non-INodeType class
|
||||
function createNonINodeTypeClass(): string {
|
||||
return `
|
||||
export class NotANode {
|
||||
description = {
|
||||
displayName: 'Not A Node',
|
||||
credentials: [
|
||||
{ name: 'ExternalApi', required: true }
|
||||
]
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
function setupMockFileSystem() {
|
||||
mockFindPackageJson.mockReturnValue('/tmp/package.json');
|
||||
mockReadPackageJsonCredentials.mockReturnValue(
|
||||
new Set(['myApiCredential', 'anotherApiCredential']),
|
||||
);
|
||||
}
|
||||
|
||||
setupMockFileSystem();
|
||||
|
||||
ruleTester.run('no-credential-reuse', NoCredentialReuseRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'node using allowed credential (object form) from same package',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode([{ name: 'myApiCredential', required: true }]),
|
||||
},
|
||||
{
|
||||
name: 'node using allowed credential (string form) from same package',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode(['myApiCredential']),
|
||||
},
|
||||
{
|
||||
name: 'node using multiple allowed credentials (mixed forms)',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode(['myApiCredential', { name: 'anotherApiCredential', required: false }]),
|
||||
},
|
||||
{
|
||||
name: 'node without credentials array',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode(),
|
||||
},
|
||||
{
|
||||
name: 'non-node file ignored',
|
||||
filename: '/tmp/regular-file.ts',
|
||||
code: createNonNodeClass(),
|
||||
},
|
||||
{
|
||||
name: 'non-INodeType class ignored',
|
||||
filename: nodeFilePath,
|
||||
code: createNonINodeTypeClass(),
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'SECURITY: node using credential not in package (object form)',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode([{ name: 'ExternalApi', required: true }]),
|
||||
errors: [
|
||||
{
|
||||
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 }]),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SECURITY: node using credential not in package (string form)',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode(['ExternalApi']),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'credentialNotInPackage',
|
||||
data: { credentialName: 'ExternalApi' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'myApiCredential' },
|
||||
output: createExpectedNodeCode(['myApiCredential']),
|
||||
},
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'anotherApiCredential' },
|
||||
output: createExpectedNodeCode(['anotherApiCredential']),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SECURITY: node using mix of allowed and disallowed credentials (mixed forms)',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode([
|
||||
'myApiCredential',
|
||||
{ name: 'ExternalApi', required: true },
|
||||
'AnotherExternalApi',
|
||||
]),
|
||||
errors: [
|
||||
{
|
||||
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: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'node using multiple disallowed credentials',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode([
|
||||
{ name: 'ExternalApi1', required: true },
|
||||
{ name: 'ExternalApi2', required: false },
|
||||
]),
|
||||
errors: [
|
||||
{
|
||||
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: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { TSESTree } from '@typescript-eslint/types';
|
||||
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
|
||||
|
||||
import {
|
||||
isNodeTypeClass,
|
||||
findClassProperty,
|
||||
findArrayLiteralProperty,
|
||||
extractCredentialNameFromArray,
|
||||
findPackageJson,
|
||||
readPackageJsonCredentials,
|
||||
isFileType,
|
||||
findSimilarStrings,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const NoCredentialReuseRule = createRule({
|
||||
name: 'no-credential-reuse',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'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) {
|
||||
if (!isFileType(context.filename, '.node.ts')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let packageCredentials: Set<string> | null = null;
|
||||
|
||||
const loadPackageCredentials = (): Set<string> => {
|
||||
if (packageCredentials !== null) {
|
||||
return packageCredentials;
|
||||
}
|
||||
|
||||
const packageJsonPath = findPackageJson(context.filename);
|
||||
if (!packageJsonPath) {
|
||||
packageCredentials = new Set();
|
||||
return packageCredentials;
|
||||
}
|
||||
|
||||
packageCredentials = readPackageJsonCredentials(packageJsonPath);
|
||||
return packageCredentials;
|
||||
};
|
||||
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
if (!isNodeTypeClass(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptionProperty = findClassProperty(node, 'description');
|
||||
if (
|
||||
!descriptionProperty?.value ||
|
||||
descriptionProperty.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialsArray = findArrayLiteralProperty(descriptionProperty.value, 'credentials');
|
||||
if (!credentialsArray) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedCredentials = loadPackageCredentials();
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run('no-deprecated-workflow-functions', NoDeprecatedWorkflowFunctionsRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'using recommended functions and types',
|
||||
code: `
|
||||
import { IHttpRequestOptions } from 'n8n-workflow';
|
||||
|
||||
const requestOptions: IHttpRequestOptions = {
|
||||
method: 'GET',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
const response1 = await this.helpers.httpRequest(requestOptions);
|
||||
const response2 = await this.helpers.httpRequestWithAuthentication.call(this, 'oAuth2Api', {
|
||||
method: 'POST',
|
||||
url: 'https://api.example.com/data',
|
||||
});`,
|
||||
},
|
||||
{
|
||||
name: 'functions with similar names should not trigger',
|
||||
code: `
|
||||
import { request } from 'axios';
|
||||
|
||||
const result = await this.helpers.requestSomething();
|
||||
const response = await request('https://api.example.com');
|
||||
const config = { request: 'some value' };
|
||||
|
||||
// Other objects with helpers property should not trigger
|
||||
const otherObject = {
|
||||
helpers: {
|
||||
request: () => 'not n8n',
|
||||
requestWithAuthentication: () => 'not n8n'
|
||||
}
|
||||
};
|
||||
const result2 = otherObject.helpers.request();`,
|
||||
},
|
||||
{
|
||||
name: 'types with same name from other modules should not trigger',
|
||||
code: `
|
||||
import { IRequestOptions } from 'some-other-package';
|
||||
|
||||
function test(options: IRequestOptions) {
|
||||
return options.url;
|
||||
}`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'deprecated request functions',
|
||||
code: `
|
||||
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.requestOAuth2.call(this, 'google', options);`,
|
||||
errors: [
|
||||
{
|
||||
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',
|
||||
data: {
|
||||
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);`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'deprecated types',
|
||||
code: `
|
||||
import { IRequestOptions } from 'n8n-workflow';
|
||||
|
||||
function makeRequest(options: IRequestOptions): Promise<any> {
|
||||
return this.helpers.request(options);
|
||||
}`,
|
||||
errors: [
|
||||
{
|
||||
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);
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'functions without replacement',
|
||||
code: `
|
||||
const result = await this.helpers.copyBinaryFile();
|
||||
return this.helpers.prepareOutputData([{ json: response }]);`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'deprecatedWithoutReplacement',
|
||||
data: { functionName: 'copyBinaryFile' },
|
||||
},
|
||||
{
|
||||
messageId: 'deprecatedWithoutReplacement',
|
||||
data: { functionName: 'prepareOutputData' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
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',
|
||||
requestWithAuthentication: 'httpRequestWithAuthentication',
|
||||
requestOAuth1: 'httpRequestWithAuthentication',
|
||||
requestOAuth2: 'httpRequestWithAuthentication',
|
||||
copyBinaryFile: null,
|
||||
prepareOutputData: null,
|
||||
} as const;
|
||||
|
||||
const DEPRECATED_TYPES = {
|
||||
IRequestOptions: 'IHttpRequestOptions',
|
||||
} as const;
|
||||
|
||||
function isDeprecatedFunctionName(name: string): name is keyof typeof DEPRECATED_FUNCTIONS {
|
||||
return name in DEPRECATED_FUNCTIONS;
|
||||
}
|
||||
|
||||
function isDeprecatedTypeName(name: string): name is keyof typeof DEPRECATED_TYPES {
|
||||
return name in DEPRECATED_TYPES;
|
||||
}
|
||||
|
||||
export const NoDeprecatedWorkflowFunctionsRule = createRule({
|
||||
name: 'no-deprecated-workflow-functions',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow usage of deprecated functions and types from n8n-workflow package',
|
||||
},
|
||||
messages: {
|
||||
deprecatedRequestFunction:
|
||||
"'{{ functionName }}' is deprecated. Use '{{ replacement }}' instead for better authentication support and consistency.",
|
||||
deprecatedFunction: "'{{ functionName }}' is deprecated and should be avoided. {{ message }}",
|
||||
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) {
|
||||
const n8nWorkflowTypes = new Set<string>();
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value === 'n8n-workflow') {
|
||||
node.specifiers.forEach((specifier) => {
|
||||
if (
|
||||
specifier.type === AST_NODE_TYPES.ImportSpecifier &&
|
||||
specifier.imported.type === AST_NODE_TYPES.Identifier
|
||||
) {
|
||||
n8nWorkflowTypes.add(specifier.local.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
MemberExpression(node) {
|
||||
if (
|
||||
node.property.type === AST_NODE_TYPES.Identifier &&
|
||||
isDeprecatedFunctionName(node.property.name)
|
||||
) {
|
||||
if (!isThisHelpersAccess(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const functionName = node.property.name;
|
||||
const replacement = DEPRECATED_FUNCTIONS[functionName];
|
||||
|
||||
if (replacement) {
|
||||
const messageId = functionName.includes('request')
|
||||
? 'deprecatedRequestFunction'
|
||||
: 'deprecatedFunction';
|
||||
|
||||
context.report({
|
||||
node: node.property,
|
||||
messageId,
|
||||
data: {
|
||||
functionName,
|
||||
replacement,
|
||||
message: getDeprecationMessage(functionName),
|
||||
},
|
||||
suggest: [
|
||||
{
|
||||
messageId: 'suggestReplaceFunction',
|
||||
data: { functionName, replacement },
|
||||
fix: (fixer) => fixer.replaceText(node.property, replacement),
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
context.report({
|
||||
node: node.property,
|
||||
messageId: 'deprecatedWithoutReplacement',
|
||||
data: {
|
||||
functionName,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
TSTypeReference(node) {
|
||||
if (
|
||||
node.typeName.type === AST_NODE_TYPES.Identifier &&
|
||||
isDeprecatedTypeName(node.typeName.name) &&
|
||||
n8nWorkflowTypes.has(node.typeName.name)
|
||||
) {
|
||||
const typeName = node.typeName.name;
|
||||
const replacement = DEPRECATED_TYPES[typeName];
|
||||
|
||||
context.report({
|
||||
node: node.typeName,
|
||||
messageId: 'deprecatedType',
|
||||
data: {
|
||||
typeName,
|
||||
replacement,
|
||||
},
|
||||
suggest: [
|
||||
{
|
||||
messageId: 'suggestReplaceType',
|
||||
data: { typeName, replacement },
|
||||
fix: (fixer) => fixer.replaceText(node.typeName, replacement),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
ImportSpecifier(node) {
|
||||
// Check if this import is from n8n-workflow by looking at the parent ImportDeclaration
|
||||
const importDeclaration = node.parent;
|
||||
if (
|
||||
importDeclaration?.type === AST_NODE_TYPES.ImportDeclaration &&
|
||||
importDeclaration.source.value === 'n8n-workflow' &&
|
||||
node.imported.type === AST_NODE_TYPES.Identifier &&
|
||||
isDeprecatedTypeName(node.imported.name)
|
||||
) {
|
||||
const typeName = node.imported.name;
|
||||
const replacement = DEPRECATED_TYPES[typeName];
|
||||
|
||||
context.report({
|
||||
node: node.imported,
|
||||
messageId: 'deprecatedType',
|
||||
data: {
|
||||
typeName,
|
||||
replacement,
|
||||
},
|
||||
suggest: [
|
||||
{
|
||||
messageId: 'suggestReplaceType',
|
||||
data: { typeName, replacement },
|
||||
fix: (fixer) => fixer.replaceText(node.imported, replacement),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if the MemberExpression follows the this.helpers.* pattern
|
||||
*/
|
||||
function isThisHelpersAccess(node: TSESTree.MemberExpression): boolean {
|
||||
if (node.object?.type === AST_NODE_TYPES.MemberExpression) {
|
||||
const outerObject = node.object;
|
||||
return (
|
||||
outerObject.object?.type === AST_NODE_TYPES.ThisExpression &&
|
||||
outerObject.property?.type === AST_NODE_TYPES.Identifier &&
|
||||
outerObject.property.name === 'helpers'
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getDeprecationMessage(functionName: string): string {
|
||||
switch (functionName) {
|
||||
case 'request':
|
||||
return 'Use httpRequest for better type safety and consistency.';
|
||||
case 'requestWithAuthentication':
|
||||
case 'requestOAuth1':
|
||||
case 'requestOAuth2':
|
||||
return 'Use httpRequestWithAuthentication which provides unified authentication handling.';
|
||||
case 'copyBinaryFile':
|
||||
return 'This function has been removed. Handle binary data directly.';
|
||||
case 'prepareOutputData':
|
||||
return 'This function is deprecated. Return data directly from execute method.';
|
||||
default:
|
||||
return 'This function is deprecated and should be avoided.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { NoRestrictedGlobalsRule } from './no-restricted-globals.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run('no-restricted-globals', NoRestrictedGlobalsRule, {
|
||||
valid: [
|
||||
{
|
||||
code: 'const result = someFunction();',
|
||||
},
|
||||
{
|
||||
code: 'window.setTimeout(() => {}, 1000);',
|
||||
},
|
||||
{
|
||||
code: 'const obj = { global: "allowed" };',
|
||||
},
|
||||
{
|
||||
code: 'function process() { return "allowed"; }',
|
||||
},
|
||||
{
|
||||
code: 'console.clearInterval;',
|
||||
},
|
||||
{
|
||||
code: 'const obj = { __dirname: "allowed", Buffer: "allowed", require: "allowed" };',
|
||||
},
|
||||
{
|
||||
code: 'function globalThis() { return "allowed"; }',
|
||||
},
|
||||
{
|
||||
code: 'const helper = require("./helper");',
|
||||
},
|
||||
{
|
||||
name: 'variable declarations should be allowed',
|
||||
code: 'const process = "my-process"; let global = "my-global";',
|
||||
},
|
||||
{
|
||||
name: 'function parameters should be allowed',
|
||||
code: 'function test(process, global, setTimeout) { return process; }',
|
||||
},
|
||||
{
|
||||
name: 'arrow function parameters should be allowed',
|
||||
code: 'const fn = (process, global) => process + global;',
|
||||
},
|
||||
{
|
||||
name: 'destructuring should be allowed',
|
||||
code: 'const { process, global } = someObject; const [setTimeout] = someArray;',
|
||||
},
|
||||
{
|
||||
name: 'class methods should be allowed',
|
||||
code: 'class MyClass { process() {} global = "value"; }',
|
||||
},
|
||||
{
|
||||
name: 'import should be allowed',
|
||||
code: 'import { process } from "./utils";',
|
||||
},
|
||||
{
|
||||
name: 'function expressions should be allowed',
|
||||
code: 'const fn = function process() {}; const fn2 = function global() {};',
|
||||
},
|
||||
{
|
||||
name: 'locally declared variables should not trigger false positives',
|
||||
code: `
|
||||
const process = require('process');
|
||||
const global = {};
|
||||
const setTimeout = () => {};
|
||||
function clearInterval() {}
|
||||
let setInterval;
|
||||
var __dirname = '/path';
|
||||
const __filename = 'file.js';
|
||||
`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: 'const pid = process.pid;',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'process' } }],
|
||||
},
|
||||
{
|
||||
code: 'global.myVar = "test";',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'global' } }],
|
||||
},
|
||||
{
|
||||
code: 'setTimeout(() => {}, 1000);',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'setTimeout' } }],
|
||||
},
|
||||
{
|
||||
code: 'clearInterval(timer);',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'clearInterval' } }],
|
||||
},
|
||||
{
|
||||
code: 'clearTimeout(timer);',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'clearTimeout' } }],
|
||||
},
|
||||
{
|
||||
code: 'setInterval(() => {}, 1000);',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'setInterval' } }],
|
||||
},
|
||||
{
|
||||
code: `
|
||||
const fn = () => {
|
||||
process.exit(0);
|
||||
global.something = true;
|
||||
};`,
|
||||
errors: [
|
||||
{ messageId: 'restrictedGlobal', data: { name: 'process' } },
|
||||
{ messageId: 'restrictedGlobal', data: { name: 'global' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SECURITY: __dirname usage',
|
||||
code: 'const currentDir = __dirname;',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: '__dirname' } }],
|
||||
},
|
||||
{
|
||||
name: 'SECURITY: __filename usage',
|
||||
code: 'console.log(__filename);',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: '__filename' } }],
|
||||
},
|
||||
{
|
||||
name: 'SECURITY: globalThis usage',
|
||||
code: 'globalThis.myVar = "test";',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'globalThis' } }],
|
||||
},
|
||||
{
|
||||
name: 'SECURITY: setImmediate usage',
|
||||
code: 'setImmediate(() => {});',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'setImmediate' } }],
|
||||
},
|
||||
{
|
||||
name: 'SECURITY: clearImmediate usage',
|
||||
code: 'clearImmediate(immediate);',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'clearImmediate' } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { TSESTree } from '@typescript-eslint/types';
|
||||
import type { TSESLint } from '@typescript-eslint/utils';
|
||||
|
||||
import { createRule } from '../utils/index.js';
|
||||
|
||||
const restrictedGlobals = [
|
||||
'clearInterval',
|
||||
'clearTimeout',
|
||||
'global',
|
||||
'globalThis',
|
||||
'process',
|
||||
'setInterval',
|
||||
'setTimeout',
|
||||
'setImmediate',
|
||||
'clearImmediate',
|
||||
'__dirname',
|
||||
'__filename',
|
||||
];
|
||||
|
||||
export const NoRestrictedGlobalsRule = createRule({
|
||||
name: 'no-restricted-globals',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow usage of restricted global variables in community nodes.',
|
||||
},
|
||||
messages: {
|
||||
restrictedGlobal: "Use of restricted global '{{ name }}' is not allowed",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
function checkReference(ref: TSESLint.Scope.Reference, name: string) {
|
||||
const { parent } = ref.identifier;
|
||||
|
||||
// Skip property access (like console.process - we want process.exit but not obj.process)
|
||||
if (
|
||||
parent?.type === TSESTree.AST_NODE_TYPES.MemberExpression &&
|
||||
parent.property === ref.identifier &&
|
||||
!parent.computed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.report({
|
||||
node: ref.identifier,
|
||||
messageId: 'restrictedGlobal',
|
||||
data: { name },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
Program() {
|
||||
const globalScope = context.sourceCode.getScope(context.sourceCode.ast);
|
||||
|
||||
const allReferences = [
|
||||
...globalScope.variables
|
||||
.filter(
|
||||
(variable) => restrictedGlobals.includes(variable.name) && variable.defs.length === 0, // No definitions means it's a global
|
||||
)
|
||||
.flatMap((variable) =>
|
||||
variable.references.map((ref) => ({ ref, name: variable.name })),
|
||||
),
|
||||
...globalScope.through
|
||||
.filter((ref) => restrictedGlobals.includes(ref.identifier.name))
|
||||
.map((ref) => ({ ref, name: ref.identifier.name })),
|
||||
];
|
||||
|
||||
allReferences.forEach(({ ref, name }) => checkReference(ref, name));
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { NoRestrictedImportsRule } from './no-restricted-imports.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run('no-restricted-imports', NoRestrictedImportsRule, {
|
||||
valid: [
|
||||
{
|
||||
code: 'import { WorkflowExecuteMode } from "n8n-workflow";',
|
||||
},
|
||||
{
|
||||
code: 'import _ from "lodash";',
|
||||
},
|
||||
{
|
||||
code: 'import moment from "moment";',
|
||||
},
|
||||
{
|
||||
code: 'import pLimit from "p-limit";',
|
||||
},
|
||||
{
|
||||
code: 'import { DateTime } from "luxon";',
|
||||
},
|
||||
{
|
||||
code: 'import { z } from "zod";',
|
||||
},
|
||||
{
|
||||
code: 'import crypto from "crypto";',
|
||||
},
|
||||
{
|
||||
code: 'import crypto from "node:crypto";',
|
||||
},
|
||||
{
|
||||
code: 'import { helper } from "./helper";',
|
||||
},
|
||||
{
|
||||
code: 'import { utils } from "../utils";',
|
||||
},
|
||||
{
|
||||
code: 'const helper = require("./helper");',
|
||||
},
|
||||
{
|
||||
code: 'const utils = require("../utils");',
|
||||
},
|
||||
{
|
||||
code: 'const _ = require("lodash");',
|
||||
},
|
||||
{
|
||||
code: 'require.resolve("lodash");',
|
||||
},
|
||||
{
|
||||
code: 'require.resolve("./helper");',
|
||||
},
|
||||
{
|
||||
code: 'require.resolve("../utils");',
|
||||
},
|
||||
{
|
||||
code: 'const workflow = await import("n8n-workflow");',
|
||||
},
|
||||
{
|
||||
code: 'import("lodash").then((_) => {});',
|
||||
},
|
||||
{
|
||||
code: 'const helper = await import("./helper");',
|
||||
},
|
||||
{
|
||||
code: 'import("../utils").then((utils) => {});',
|
||||
},
|
||||
{
|
||||
code: 'import(`lodash`).then((_) => {});',
|
||||
},
|
||||
{
|
||||
code: 'require(`./helper`);',
|
||||
},
|
||||
{
|
||||
code: 'require.resolve(`n8n-workflow`);',
|
||||
},
|
||||
{
|
||||
code: 'const workflow = await import(`n8n-workflow`);',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: 'import fs from "fs";',
|
||||
errors: [{ messageId: 'restrictedImport', data: { modulePath: 'fs' } }],
|
||||
},
|
||||
{
|
||||
code: 'import path from "path";',
|
||||
errors: [{ messageId: 'restrictedImport', data: { modulePath: 'path' } }],
|
||||
},
|
||||
{
|
||||
code: 'import express from "express";',
|
||||
errors: [{ messageId: 'restrictedImport', data: { modulePath: 'express' } }],
|
||||
},
|
||||
{
|
||||
code: 'import axios from "axios";',
|
||||
errors: [{ messageId: 'restrictedImport', data: { modulePath: 'axios' } }],
|
||||
},
|
||||
{
|
||||
code: 'import { Client } from "@elastic/elasticsearch";',
|
||||
errors: [{ messageId: 'restrictedImport', data: { modulePath: '@elastic/elasticsearch' } }],
|
||||
},
|
||||
{
|
||||
code: 'const fs = require("fs");',
|
||||
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'fs' } }],
|
||||
},
|
||||
{
|
||||
code: 'const path = require("path");',
|
||||
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'path' } }],
|
||||
},
|
||||
{
|
||||
code: 'const express = require("express");',
|
||||
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'express' } }],
|
||||
},
|
||||
{
|
||||
code: 'require.resolve("fs");',
|
||||
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'fs' } }],
|
||||
},
|
||||
{
|
||||
code: 'require.resolve("express");',
|
||||
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'express' } }],
|
||||
},
|
||||
{
|
||||
code: 'const resolved = require.resolve("axios");',
|
||||
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'axios' } }],
|
||||
},
|
||||
{
|
||||
code: `
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { WorkflowExecuteMode } from "n8n-workflow";
|
||||
import { supplyModel } from "@n8n/ai-node-sdk";`,
|
||||
errors: [
|
||||
{ messageId: 'restrictedImport', data: { modulePath: 'fs' } },
|
||||
{ messageId: 'restrictedImport', data: { modulePath: 'path' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: `
|
||||
const fs = require("fs");
|
||||
const express = require("express");
|
||||
const lodash = require("lodash");`,
|
||||
errors: [
|
||||
{ messageId: 'restrictedRequire', data: { modulePath: 'fs' } },
|
||||
{ messageId: 'restrictedRequire', data: { modulePath: 'express' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const fs = await import("fs");',
|
||||
errors: [{ messageId: 'restrictedDynamicImport', data: { modulePath: 'fs' } }],
|
||||
},
|
||||
{
|
||||
code: 'import("path").then((path) => {});',
|
||||
errors: [{ messageId: 'restrictedDynamicImport', data: { modulePath: 'path' } }],
|
||||
},
|
||||
{
|
||||
code: 'const express = await import("express");',
|
||||
errors: [{ messageId: 'restrictedDynamicImport', data: { modulePath: 'express' } }],
|
||||
},
|
||||
{
|
||||
code: 'const path = require(`path`);',
|
||||
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'path' } }],
|
||||
},
|
||||
{
|
||||
code: 'require.resolve(`express`);',
|
||||
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'express' } }],
|
||||
},
|
||||
{
|
||||
code: 'const axios = await import(`axios`);',
|
||||
errors: [{ messageId: 'restrictedDynamicImport', data: { modulePath: 'axios' } }],
|
||||
},
|
||||
{
|
||||
code: `
|
||||
const fs = await import("fs");
|
||||
import("axios").then((axios) => {});
|
||||
const workflow = await import("n8n-workflow");`,
|
||||
errors: [
|
||||
{ messageId: 'restrictedDynamicImport', data: { modulePath: 'fs' } },
|
||||
{ messageId: 'restrictedDynamicImport', data: { modulePath: 'axios' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
getModulePath,
|
||||
isDirectRequireCall,
|
||||
isRequireMemberCall,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
const allowedModules = [
|
||||
'n8n-workflow',
|
||||
'ai-node-sdk',
|
||||
'lodash',
|
||||
'moment',
|
||||
'p-limit',
|
||||
'luxon',
|
||||
'zod',
|
||||
'crypto',
|
||||
'node:crypto',
|
||||
'@n8n/ai-node-sdk',
|
||||
];
|
||||
|
||||
const isModuleAllowed = (modulePath: string): boolean => {
|
||||
if (modulePath.startsWith('./') || modulePath.startsWith('../')) return true;
|
||||
|
||||
const moduleName = modulePath.startsWith('@')
|
||||
? modulePath.split('/').slice(0, 2).join('/')
|
||||
: modulePath.split('/')[0];
|
||||
if (!moduleName) return true;
|
||||
return allowedModules.includes(moduleName);
|
||||
};
|
||||
|
||||
export const NoRestrictedImportsRule = createRule({
|
||||
name: 'no-restricted-imports',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow usage of restricted imports in community nodes.',
|
||||
},
|
||||
messages: {
|
||||
restrictedImport:
|
||||
"Import of '{{ modulePath }}' is not allowed. n8n Cloud does not allow community nodes with dependencies.",
|
||||
restrictedRequire:
|
||||
"Require of '{{ modulePath }}' is not allowed. n8n Cloud does not allow community nodes with dependencies.",
|
||||
restrictedDynamicImport:
|
||||
"Dynamic import of '{{ modulePath }}' is not allowed. n8n Cloud does not allow community nodes with dependencies.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
const modulePath = getModulePath(node.source);
|
||||
if (modulePath && !isModuleAllowed(modulePath)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'restrictedImport',
|
||||
data: {
|
||||
modulePath,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
ImportExpression(node) {
|
||||
const modulePath = getModulePath(node.source);
|
||||
if (modulePath && !isModuleAllowed(modulePath)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'restrictedDynamicImport',
|
||||
data: {
|
||||
modulePath,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
CallExpression(node) {
|
||||
if (isDirectRequireCall(node) || isRequireMemberCall(node)) {
|
||||
const modulePath = getModulePath(node.arguments[0] ?? null);
|
||||
if (modulePath && !isModuleAllowed(modulePath)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'restrictedRequire',
|
||||
data: {
|
||||
modulePath,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
function createNodeCode(
|
||||
usableAsTool?: boolean | 'missing',
|
||||
hasDescription: boolean = true,
|
||||
): string {
|
||||
let usableAsToolProperty = '';
|
||||
if (usableAsTool === true) {
|
||||
usableAsToolProperty = ',\n\t\tusableAsTool: true';
|
||||
} else if (usableAsTool === false) {
|
||||
usableAsToolProperty = ',\n\t\tusableAsTool: false';
|
||||
}
|
||||
|
||||
if (!hasDescription) {
|
||||
return `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
displayName = 'Test Node';
|
||||
}`;
|
||||
}
|
||||
|
||||
return `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
description: 'A test node',
|
||||
defaults: {
|
||||
name: 'Test Node',
|
||||
},
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: []${usableAsToolProperty},
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
function createNonNodeClass(): string {
|
||||
return `
|
||||
export class RegularClass {
|
||||
someProperty = 'value';
|
||||
}`;
|
||||
}
|
||||
|
||||
function createNodeCodeWithOutputsInputs(
|
||||
outputs: string,
|
||||
inputs: string,
|
||||
includeUsableAsTool = false,
|
||||
): string {
|
||||
const usableAsToolLine = includeUsableAsTool ? '\n\t\tusableAsTool: true,' : '';
|
||||
return `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
description: 'A test node',
|
||||
defaults: {
|
||||
name: 'Test Node',
|
||||
},
|
||||
inputs: ${inputs},
|
||||
outputs: ${outputs},
|
||||
properties: [],${usableAsToolLine}
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
ruleTester.run('node-usable-as-tool', NodeUsableAsToolRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'node with usableAsTool set to true',
|
||||
code: createNodeCode(true),
|
||||
},
|
||||
{
|
||||
name: 'class that does not implement INodeType',
|
||||
code: createNonNodeClass(),
|
||||
},
|
||||
{
|
||||
name: 'node with usableAsTool set to false',
|
||||
code: createNodeCode(false),
|
||||
},
|
||||
{
|
||||
name: 'node without description property',
|
||||
code: createNodeCode(undefined, false),
|
||||
},
|
||||
{
|
||||
name: 'AI-only node: NodeConnectionTypes non-Main output and empty inputs skips usableAsTool check',
|
||||
code: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.AiAgent]', '[]'),
|
||||
},
|
||||
{
|
||||
name: 'AI-only node: multiple non-Main NodeConnectionTypes outputs and empty inputs skips usableAsTool check',
|
||||
code: createNodeCodeWithOutputsInputs(
|
||||
'[NodeConnectionTypes.AiAgent, NodeConnectionTypes.AiTool]',
|
||||
'[]',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'AI-only node: non-main string literal output and empty inputs skips usableAsTool check',
|
||||
code: createNodeCodeWithOutputsInputs("['ai_agent']", '[]'),
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'node missing usableAsTool property',
|
||||
code: createNodeCode('missing'),
|
||||
errors: [{ messageId: 'missingUsableAsTool' }],
|
||||
output: createNodeCode(true),
|
||||
},
|
||||
{
|
||||
name: 'NodeConnectionTypes.Main output with empty inputs does not skip check',
|
||||
code: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.Main]', '[]'),
|
||||
errors: [{ messageId: 'missingUsableAsTool' }],
|
||||
output: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.Main]', '[]', true),
|
||||
},
|
||||
{
|
||||
name: 'main string literal output with empty inputs does not skip check',
|
||||
code: createNodeCodeWithOutputsInputs("['main']", '[]'),
|
||||
errors: [{ messageId: 'missingUsableAsTool' }],
|
||||
output: createNodeCodeWithOutputsInputs("['main']", '[]', true),
|
||||
},
|
||||
{
|
||||
name: 'non-Main output with non-empty inputs does not skip check',
|
||||
code: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.AiAgent]', "['main']"),
|
||||
errors: [{ messageId: 'missingUsableAsTool' }],
|
||||
output: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.AiAgent]', "['main']", true),
|
||||
},
|
||||
{
|
||||
name: 'non-main string literal output with non-empty inputs does not skip check',
|
||||
code: createNodeCodeWithOutputsInputs("['ai_agent']", "['main']"),
|
||||
errors: [{ messageId: 'missingUsableAsTool' }],
|
||||
output: createNodeCodeWithOutputsInputs("['ai_agent']", "['main']", true),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { TSESTree } from '@typescript-eslint/types';
|
||||
|
||||
import {
|
||||
isNodeTypeClass,
|
||||
findClassProperty,
|
||||
findObjectProperty,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const NodeUsableAsToolRule = createRule({
|
||||
name: 'node-usable-as-tool',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ensure node classes have usableAsTool property',
|
||||
},
|
||||
messages: {
|
||||
missingUsableAsTool:
|
||||
'Node class should have usableAsTool property. When in doubt, set it to true.',
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
if (!isNodeTypeClass(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptionProperty = findClassProperty(node, 'description');
|
||||
if (!descriptionProperty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptionValue = descriptionProperty.value;
|
||||
if (descriptionValue?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usableAsToolProperty = findObjectProperty(descriptionValue, 'usableAsTool');
|
||||
const outputsProperty = findObjectProperty(descriptionValue, 'outputs');
|
||||
const inputsProperty = findObjectProperty(descriptionValue, 'inputs');
|
||||
if (
|
||||
outputsProperty?.value?.type === TSESTree.AST_NODE_TYPES.ArrayExpression &&
|
||||
inputsProperty?.value?.type === TSESTree.AST_NODE_TYPES.ArrayExpression
|
||||
) {
|
||||
const isAiOutput = outputsProperty?.value?.elements?.some((element) => {
|
||||
const isAiOutputEnum =
|
||||
element?.type === TSESTree.AST_NODE_TYPES.MemberExpression &&
|
||||
element?.object?.type === TSESTree.AST_NODE_TYPES.Identifier &&
|
||||
element?.object?.name === 'NodeConnectionTypes' &&
|
||||
element?.property?.type === TSESTree.AST_NODE_TYPES.Identifier &&
|
||||
element?.property?.name !== 'Main';
|
||||
const isAiOutputLiteral =
|
||||
element?.type === TSESTree.AST_NODE_TYPES.Literal && element?.value !== 'main';
|
||||
return isAiOutputEnum || isAiOutputLiteral;
|
||||
});
|
||||
const isEmptyInputs = inputsProperty?.value?.elements?.length === 0;
|
||||
if (isAiOutput && isEmptyInputs) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!usableAsToolProperty) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'missingUsableAsTool',
|
||||
fix(fixer) {
|
||||
if (descriptionValue?.type === TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
const properties = descriptionValue.properties;
|
||||
if (properties.length === 0) {
|
||||
const openBrace = descriptionValue.range[0] + 1;
|
||||
return fixer.insertTextAfterRange(
|
||||
[openBrace, openBrace],
|
||||
'\n\t\tusableAsTool: true,',
|
||||
);
|
||||
} else {
|
||||
const lastProperty = properties.at(-1);
|
||||
if (lastProperty) {
|
||||
return fixer.insertTextAfter(lastProperty, ',\n\t\tusableAsTool: true');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { PackageNameConventionRule } from './package-name-convention.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run('package-name-convention', PackageNameConventionRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'valid unscoped package name',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
|
||||
},
|
||||
{
|
||||
name: 'valid unscoped package name with dashes',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-my-service", "version": "1.0.0" }',
|
||||
},
|
||||
{
|
||||
name: 'valid scoped package name',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "@mycompany/n8n-nodes-example", "version": "1.0.0" }',
|
||||
},
|
||||
{
|
||||
name: 'valid scoped package name with dashes',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "@author/n8n-nodes-service", "version": "1.0.0" }',
|
||||
},
|
||||
{
|
||||
name: 'object without name property',
|
||||
filename: 'package.json',
|
||||
code: '{ "version": "1.0.0", "description": "test" }',
|
||||
},
|
||||
{
|
||||
name: 'non-package.json file ignored',
|
||||
filename: 'some-config.json',
|
||||
code: '{ "name": "my-config", "type": "config" }',
|
||||
},
|
||||
{
|
||||
name: 'nested name fields should be ignored - only top-level name matters',
|
||||
filename: 'package.json',
|
||||
code: `{
|
||||
"name": "n8n-nodes-example",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"name": "invalid-nested-name"
|
||||
},
|
||||
"scripts": {
|
||||
"name": "another-invalid-name"
|
||||
},
|
||||
"author": {
|
||||
"name": "John Doe"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: 'deeply nested name fields should be ignored',
|
||||
filename: 'package.json',
|
||||
code: `{
|
||||
"name": "@author/n8n-nodes-service",
|
||||
"version": "1.0.0",
|
||||
"config": {
|
||||
"nested": {
|
||||
"deeply": {
|
||||
"name": "very-invalid-name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/user/repo",
|
||||
"directory": {
|
||||
"name": "bad-name"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'invalid package name - generic',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "my-package", "version": "1.0.0" }',
|
||||
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' },
|
||||
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' },
|
||||
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' },
|
||||
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: '' },
|
||||
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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { TSESTree } from '@typescript-eslint/utils';
|
||||
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
|
||||
|
||||
import { createRule, findJsonProperty } 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) {
|
||||
if (!context.filename.endsWith('package.json')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
ObjectExpression(node: TSESTree.ObjectExpression) {
|
||||
if (node.parent?.type === AST_NODE_TYPES.Property) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nameProperty = findJsonProperty(node, 'name');
|
||||
|
||||
if (!nameProperty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nameProperty.value.type !== AST_NODE_TYPES.Literal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const packageName = nameProperty.value.value;
|
||||
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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function isValidPackageName(name: string): boolean {
|
||||
const unscoped = /^n8n-nodes-.+$/;
|
||||
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}`] : [];
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run('resource-operation-pattern', ResourceOperationPatternRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'node with resources and operations (good pattern)',
|
||||
filename: '/tmp/TestNode.node.ts',
|
||||
code: `
|
||||
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'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'User', value: 'user' },
|
||||
{ name: 'Project', value: 'project' }
|
||||
],
|
||||
default: 'user'
|
||||
},
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Get', value: 'get' },
|
||||
{ name: 'Create', value: 'create' },
|
||||
{ name: 'Update', value: 'update' },
|
||||
{ name: 'Delete', value: 'delete' }
|
||||
],
|
||||
default: 'get'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'node without operations property',
|
||||
filename: '/tmp/TestNode.node.ts',
|
||||
code: `
|
||||
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'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
default: ''
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'non-node class ignored',
|
||||
filename: '/tmp/TestNode.node.ts',
|
||||
code: `
|
||||
export class NotANode {
|
||||
description = {
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Get', value: 'get' },
|
||||
{ name: 'Create', value: 'create' }
|
||||
],
|
||||
default: 'get'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'node with exactly 5 operations without resources (allowed)',
|
||||
filename: '/tmp/TestNode.node.ts',
|
||||
code: `
|
||||
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'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Get', value: 'get' },
|
||||
{ name: 'Create', value: 'create' },
|
||||
{ name: 'Update', value: 'update' },
|
||||
{ name: 'Delete', value: 'delete' },
|
||||
{ name: 'List', value: 'list' }
|
||||
],
|
||||
default: 'get'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'node with exactly 6 operations without resources (error)',
|
||||
filename: '/tmp/TestNode.node.ts',
|
||||
code: `
|
||||
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'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Get', value: 'get' },
|
||||
{ name: 'Create', value: 'create' },
|
||||
{ name: 'Update', value: 'update' },
|
||||
{ name: 'Delete', value: 'delete' },
|
||||
{ name: 'List', value: 'list' },
|
||||
{ name: 'Search', value: 'search' }
|
||||
],
|
||||
default: 'get'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'tooManyOperationsWithoutResources',
|
||||
data: { operationCount: '6' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'node with many operations without resources (error)',
|
||||
filename: '/tmp/TestNode.node.ts',
|
||||
code: `
|
||||
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'],
|
||||
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: 'List Users', value: 'listUsers' },
|
||||
{ name: 'Get Project', value: 'getProject' },
|
||||
{ name: 'Create Project', value: 'createProject' },
|
||||
{ name: 'Update Project', value: 'updateProject' }
|
||||
],
|
||||
default: 'getUser'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'tooManyOperationsWithoutResources',
|
||||
data: { operationCount: '8' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
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 = createRule({
|
||||
name: 'resource-operation-pattern',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce proper resource/operation pattern for better UX in n8n nodes',
|
||||
},
|
||||
messages: {
|
||||
tooManyOperationsWithoutResources:
|
||||
'Node has {{ operationCount }} operations without resources. Use resources to organize operations when there are more than 5 operations.',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
if (!isFileType(context.filename, '.node.ts')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const analyzeNodeDescription = (descriptionValue: TSESTree.Expression | null): void => {
|
||||
if (!descriptionValue || descriptionValue.type !== AST_NODE_TYPES.ObjectExpression) {
|
||||
return;
|
||||
}
|
||||
|
||||
const propertiesProperty = findObjectProperty(descriptionValue, 'properties');
|
||||
if (
|
||||
!propertiesProperty?.value ||
|
||||
propertiesProperty.value.type !== AST_NODE_TYPES.ArrayExpression
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const propertiesArray = propertiesProperty.value;
|
||||
let hasResources = false;
|
||||
let operationCount = 0;
|
||||
let operationNode: TSESTree.Node | null = null;
|
||||
|
||||
for (const property of propertiesArray.elements) {
|
||||
if (!property || property.type !== AST_NODE_TYPES.ObjectExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nameProperty = findObjectProperty(property, 'name');
|
||||
const typeProperty = findObjectProperty(property, 'type');
|
||||
|
||||
const name = nameProperty ? getStringLiteralValue(nameProperty.value) : null;
|
||||
const type = typeProperty ? getStringLiteralValue(typeProperty.value) : null;
|
||||
|
||||
if (!name || !type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name === 'resource' && type === 'options') {
|
||||
hasResources = true;
|
||||
}
|
||||
|
||||
if (name === 'operation' && type === 'options') {
|
||||
operationNode = property;
|
||||
const optionsProperty = findObjectProperty(property, 'options');
|
||||
if (optionsProperty?.value?.type === AST_NODE_TYPES.ArrayExpression) {
|
||||
operationCount = optionsProperty.value.elements.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (operationCount > 5 && !hasResources && operationNode) {
|
||||
context.report({
|
||||
node: operationNode,
|
||||
messageId: 'tooManyOperationsWithoutResources',
|
||||
data: {
|
||||
operationCount: operationCount.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
if (!isNodeTypeClass(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptionProperty = findClassProperty(node, 'description');
|
||||
if (!descriptionProperty) {
|
||||
return;
|
||||
}
|
||||
|
||||
analyzeNodeDescription(descriptionProperty.value);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
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 === AST_NODE_TYPES.TSClassImplements &&
|
||||
impl.expression.type === AST_NODE_TYPES.Identifier &&
|
||||
impl.expression.name === interfaceName,
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
export function isNodeTypeClass(node: TSESTree.ClassDeclaration): boolean {
|
||||
if (implementsInterface(node, 'INodeType')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.superClass?.type === AST_NODE_TYPES.Identifier && node.superClass.name === 'Node') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isCredentialTypeClass(node: TSESTree.ClassDeclaration): boolean {
|
||||
return implementsInterface(node, 'ICredentialType');
|
||||
}
|
||||
|
||||
export function findClassProperty(
|
||||
node: TSESTree.ClassDeclaration,
|
||||
propertyName: string,
|
||||
): TSESTree.PropertyDefinition | null {
|
||||
const property = node.body.body.find(
|
||||
(member) =>
|
||||
member.type === AST_NODE_TYPES.PropertyDefinition &&
|
||||
member.key?.type === AST_NODE_TYPES.Identifier &&
|
||||
member.key.name === propertyName,
|
||||
);
|
||||
return property?.type === AST_NODE_TYPES.PropertyDefinition ? property : null;
|
||||
}
|
||||
|
||||
export function findObjectProperty(
|
||||
obj: TSESTree.ObjectExpression,
|
||||
propertyName: string,
|
||||
): TSESTree.Property | null {
|
||||
const property = obj.properties.find(
|
||||
(prop) =>
|
||||
prop.type === AST_NODE_TYPES.Property &&
|
||||
prop.key.type === AST_NODE_TYPES.Identifier &&
|
||||
prop.key.name === propertyName,
|
||||
);
|
||||
return property?.type === AST_NODE_TYPES.Property ? property : null;
|
||||
}
|
||||
|
||||
export function getLiteralValue(node: TSESTree.Node | null): string | boolean | number | null {
|
||||
if (node?.type === AST_NODE_TYPES.Literal) {
|
||||
return node.value as string | boolean | number | null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getStringLiteralValue(node: TSESTree.Node | null): string | null {
|
||||
const value = getLiteralValue(node);
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
export function getModulePath(node: TSESTree.Node | null): string | null {
|
||||
const stringValue = getStringLiteralValue(node);
|
||||
if (stringValue) {
|
||||
return stringValue;
|
||||
}
|
||||
|
||||
if (
|
||||
node?.type === AST_NODE_TYPES.TemplateLiteral &&
|
||||
node.expressions.length === 0 &&
|
||||
node.quasis.length === 1
|
||||
) {
|
||||
return node.quasis[0]?.value.cooked ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getBooleanLiteralValue(node: TSESTree.Node | null): boolean | null {
|
||||
const value = getLiteralValue(node);
|
||||
return typeof value === 'boolean' ? value : null;
|
||||
}
|
||||
|
||||
export function findJsonProperty(
|
||||
obj: TSESTree.ObjectExpression,
|
||||
propertyName: string,
|
||||
): TSESTree.Property | null {
|
||||
const property = obj.properties.find(
|
||||
(prop) =>
|
||||
prop.type === AST_NODE_TYPES.Property &&
|
||||
prop.key.type === AST_NODE_TYPES.Literal &&
|
||||
prop.key.value === propertyName,
|
||||
);
|
||||
return property?.type === AST_NODE_TYPES.Property ? property : null;
|
||||
}
|
||||
|
||||
export function findArrayLiteralProperty(
|
||||
obj: TSESTree.ObjectExpression,
|
||||
propertyName: string,
|
||||
): TSESTree.ArrayExpression | null {
|
||||
const property = findObjectProperty(obj, propertyName);
|
||||
if (property?.value.type === AST_NODE_TYPES.ArrayExpression) {
|
||||
return property.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasArrayLiteralValue(
|
||||
node: TSESTree.PropertyDefinition,
|
||||
searchValue: string,
|
||||
): boolean {
|
||||
if (node.value?.type !== AST_NODE_TYPES.ArrayExpression) return false;
|
||||
|
||||
return node.value.elements.some(
|
||||
(element) =>
|
||||
element?.type === AST_NODE_TYPES.Literal &&
|
||||
typeof element.value === 'string' &&
|
||||
element.value === searchValue,
|
||||
);
|
||||
}
|
||||
|
||||
export function getTopLevelObjectInJson(
|
||||
node: TSESTree.ObjectExpression,
|
||||
): TSESTree.ObjectExpression | null {
|
||||
if (node.parent?.type === AST_NODE_TYPES.Property) {
|
||||
return null;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
export function isFileType(filename: string, extension: string): boolean {
|
||||
return filename.endsWith(extension);
|
||||
}
|
||||
|
||||
export function isDirectRequireCall(node: TSESTree.CallExpression): boolean {
|
||||
return (
|
||||
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 === AST_NODE_TYPES.MemberExpression &&
|
||||
node.callee.object.type === AST_NODE_TYPES.Identifier &&
|
||||
node.callee.object.name === 'require' &&
|
||||
node.arguments.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function extractCredentialInfoFromArray(
|
||||
element: TSESTree.ArrayExpression['elements'][number],
|
||||
): { name: string; testedBy?: string; node: TSESTree.Node } | null {
|
||||
if (!element) return null;
|
||||
|
||||
const stringValue = getStringLiteralValue(element);
|
||||
if (stringValue) {
|
||||
return { name: stringValue, node: element };
|
||||
}
|
||||
|
||||
if (element.type === AST_NODE_TYPES.ObjectExpression) {
|
||||
const nameProperty = findObjectProperty(element, 'name');
|
||||
const testedByProperty = findObjectProperty(element, 'testedBy');
|
||||
|
||||
if (nameProperty) {
|
||||
const nameValue = getStringLiteralValue(nameProperty.value);
|
||||
const testedByValue = testedByProperty
|
||||
? getStringLiteralValue(testedByProperty.value)
|
||||
: undefined;
|
||||
|
||||
if (nameValue) {
|
||||
return {
|
||||
name: nameValue,
|
||||
testedBy: testedByValue ?? undefined,
|
||||
node: nameProperty.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractCredentialNameFromArray(
|
||||
element: TSESTree.ArrayExpression['elements'][number],
|
||||
): { name: string; node: TSESTree.Node } | null {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
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 {
|
||||
isCredentialTypeClass,
|
||||
isNodeTypeClass,
|
||||
findClassProperty,
|
||||
getStringLiteralValue,
|
||||
findArrayLiteralProperty,
|
||||
extractCredentialInfoFromArray,
|
||||
findSimilarStrings,
|
||||
} from './ast-utils.js';
|
||||
|
||||
/**
|
||||
* Checks if the given childPath is contained within the parentPath. Resolves
|
||||
* the paths before comparing them, so that relative paths are also supported.
|
||||
*/
|
||||
export function isContainedWithin(parentPath: string, childPath: string): boolean {
|
||||
parentPath = path.resolve(parentPath);
|
||||
childPath = path.resolve(childPath);
|
||||
|
||||
if (parentPath === childPath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return childPath.startsWith(parentPath + path.sep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins the given paths to the parentPath, ensuring that the resulting path
|
||||
* is still contained within the parentPath. If not, it throws an error to
|
||||
* prevent path traversal vulnerabilities.
|
||||
*
|
||||
* @throws {UnexpectedError} If the resulting path is not contained within the parentPath.
|
||||
*/
|
||||
export function safeJoinPath(parentPath: string, ...paths: string[]): string {
|
||||
const candidate = path.join(parentPath, ...paths);
|
||||
|
||||
if (!isContainedWithin(parentPath, candidate)) {
|
||||
throw new Error(
|
||||
`Path traversal detected, refusing to join paths: ${parentPath} and ${JSON.stringify(paths)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function findPackageJson(startPath: string): string | null {
|
||||
let currentDir = path.dirname(startPath);
|
||||
|
||||
while (parsePath(currentDir).dir !== parsePath(currentDir).root) {
|
||||
const testPath = safeJoinPath(currentDir, 'package.json');
|
||||
if (fileExistsWithCaseSync(testPath)) {
|
||||
return testPath;
|
||||
}
|
||||
|
||||
currentDir = dirname(currentDir);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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 content = readFileSync(packageJsonPath, 'utf8');
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (isValidPackageJson(parsed)) {
|
||||
return parsed.n8n ?? {};
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveN8nFilePaths(packageJsonPath: string, filePaths: string[]): string[] {
|
||||
const packageDir = dirname(packageJsonPath);
|
||||
const resolvedFiles: string[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const sourcePath = filePath.replace(/^dist\//, '').replace(/\.js$/, '.ts');
|
||||
const fullSourcePath = safeJoinPath(packageDir, sourcePath);
|
||||
|
||||
if (existsSync(fullSourcePath)) {
|
||||
resolvedFiles.push(fullSourcePath);
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedFiles;
|
||||
}
|
||||
|
||||
export function readPackageJsonCredentials(packageJsonPath: string): Set<string> {
|
||||
const n8nConfig = readPackageJsonN8n(packageJsonPath);
|
||||
const credentialPaths = n8nConfig.credentials ?? [];
|
||||
const credentialFiles = resolveN8nFilePaths(packageJsonPath, credentialPaths);
|
||||
const credentialNames: string[] = [];
|
||||
|
||||
for (const credentialFile of credentialFiles) {
|
||||
try {
|
||||
const credentialName = extractCredentialNameFromFile(credentialFile);
|
||||
if (credentialName) {
|
||||
credentialNames.push(credentialName);
|
||||
}
|
||||
} catch {
|
||||
// Silently continue if file can't be parsed
|
||||
}
|
||||
}
|
||||
|
||||
return new Set(credentialNames);
|
||||
}
|
||||
|
||||
export function extractCredentialNameFromFile(credentialFilePath: string): string | null {
|
||||
try {
|
||||
const sourceCode = readFileSync(credentialFilePath, 'utf8');
|
||||
const ast = parse(sourceCode, {
|
||||
jsx: false,
|
||||
range: true,
|
||||
});
|
||||
|
||||
let credentialName: string | null = null;
|
||||
|
||||
simpleTraverse(ast, {
|
||||
enter(node: TSESTree.Node) {
|
||||
if (node.type === AST_NODE_TYPES.ClassDeclaration && isCredentialTypeClass(node)) {
|
||||
const nameProperty = findClassProperty(node, 'name');
|
||||
if (nameProperty) {
|
||||
const nameValue = getStringLiteralValue(nameProperty.value);
|
||||
if (nameValue) {
|
||||
credentialName = nameValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return credentialName;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateIconPath(
|
||||
iconPath: string,
|
||||
baseDir: string,
|
||||
): {
|
||||
isValid: boolean;
|
||||
isFile: boolean;
|
||||
isSvg: boolean;
|
||||
exists: boolean;
|
||||
} {
|
||||
const isFile = iconPath.startsWith('file:');
|
||||
const relativePath = iconPath.replace(/^file:/, '');
|
||||
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 = fileExistsWithCaseSync(fullPath);
|
||||
|
||||
return {
|
||||
isValid: isFile && isSvg && exists,
|
||||
isFile,
|
||||
isSvg,
|
||||
exists,
|
||||
};
|
||||
}
|
||||
|
||||
export function readPackageJsonNodes(packageJsonPath: string): string[] {
|
||||
const n8nConfig = readPackageJsonN8n(packageJsonPath);
|
||||
const nodePaths = n8nConfig.nodes ?? [];
|
||||
return resolveN8nFilePaths(packageJsonPath, nodePaths);
|
||||
}
|
||||
|
||||
export function areAllCredentialUsagesTestedByNodes(
|
||||
credentialName: string,
|
||||
packageDir: string,
|
||||
): boolean {
|
||||
const packageJsonPath = safeJoinPath(packageDir, 'package.json');
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeFiles = readPackageJsonNodes(packageJsonPath);
|
||||
let hasAnyCredentialUsage = false;
|
||||
|
||||
for (const nodeFile of nodeFiles) {
|
||||
const result = checkCredentialUsageInFile(nodeFile, credentialName);
|
||||
if (result.hasUsage) {
|
||||
hasAnyCredentialUsage = true;
|
||||
if (!result.allTestedBy) {
|
||||
return false; // Found usage without testedBy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hasAnyCredentialUsage;
|
||||
}
|
||||
|
||||
function checkCredentialUsageInFile(
|
||||
nodeFile: string,
|
||||
credentialName: string,
|
||||
): { hasUsage: boolean; allTestedBy: boolean } {
|
||||
try {
|
||||
const sourceCode = readFileSync(nodeFile, 'utf8');
|
||||
const ast = parse(sourceCode, { jsx: false, range: true });
|
||||
|
||||
let hasUsage = false;
|
||||
let allTestedBy = true;
|
||||
|
||||
simpleTraverse(ast, {
|
||||
enter(node: TSESTree.Node) {
|
||||
if (node.type === AST_NODE_TYPES.ClassDeclaration && isNodeTypeClass(node)) {
|
||||
const descriptionProperty = findClassProperty(node, 'description');
|
||||
if (
|
||||
!descriptionProperty?.value ||
|
||||
descriptionProperty.value.type !== AST_NODE_TYPES.ObjectExpression
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialsArray = findArrayLiteralProperty(
|
||||
descriptionProperty.value,
|
||||
'credentials',
|
||||
);
|
||||
if (!credentialsArray) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const element of credentialsArray.elements) {
|
||||
const credentialInfo = extractCredentialInfoFromArray(element);
|
||||
if (credentialInfo?.name === credentialName) {
|
||||
hasUsage = true;
|
||||
if (!credentialInfo.testedBy) {
|
||||
allTestedBy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return { hasUsage, allTestedBy };
|
||||
} catch {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './ast-utils.js';
|
||||
export * from './file-utils.js';
|
||||
export * from './rule-creator.js';
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ESLintUtils } from '@typescript-eslint/utils';
|
||||
|
||||
const REPO_URL = 'https://github.com/n8n-io/n8n';
|
||||
const DOCS_PATH = 'blob/master/packages/@n8n/eslint-plugin-community-nodes/docs/rules';
|
||||
|
||||
export const createRule = ESLintUtils.RuleCreator((name) => `${REPO_URL}/${DOCS_PATH}/${name}.md`);
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@n8n/typescript-config/modern/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineConfig, mergeConfig } from 'vite';
|
||||
import { vitestConfig } from '@n8n/vitest-config/node';
|
||||
|
||||
export default mergeConfig(defineConfig({}), vitestConfig);
|
||||
Reference in New Issue
Block a user