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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
+289
View File
@@ -0,0 +1,289 @@
# @n8n/node-cli
Official CLI for developing community nodes for n8n.
## 🚀 Getting Started
**To create a new node**, run:
```bash
npm create @n8n/node@latest # or pnpm/yarn/...
```
This will generate a project with `npm` scripts that use this CLI under the hood.
## 📦 Generated Project Commands
After creating your node with `npm create @n8n/node`, you'll use these commands in your project:
### Development
```bash
npm run dev
# Runs: n8n-node dev
```
### Building
```bash
npm run build
# Runs: n8n-node build
```
### Linting
```bash
npm run lint
# Runs: n8n-node lint
npm run lint:fix
# Runs: n8n-node lint --fix
```
### Publishing
```bash
npm run release
# Runs: n8n-node release
```
## 🛠️ CLI Reference
> **Note:** These commands are typically wrapped by `npm` scripts in generated projects.
```bash
n8n-node [COMMAND] [OPTIONS]
```
### Commands
#### `n8n-node new`
Create a new node project.
```bash
n8n-node new [NAME] [OPTIONS]
```
**Flags:**
| Flag | Description |
|------|-------------|
| `-f, --force` | Overwrite destination folder if it already exists |
| `--skip-install` | Skip installing dependencies |
| `--template <template>` | Choose template: `declarative/custom`, `declarative/github-issues`, `programmatic/example` |
**Examples:**
```bash
n8n-node new
n8n-node new n8n-nodes-my-app --skip-install
n8n-node new n8n-nodes-my-app --force
n8n-node new n8n-nodes-my-app --template declarative/custom
```
> **Note:** This command is used internally by `npm create @n8n/node` to provide the interactive scaffolding experience.
#### `n8n-node dev`
Run n8n with your node in development mode with hot reload.
```bash
n8n-node dev [--external-n8n] [--custom-user-folder <value>]
```
**Flags:**
| Flag | Description |
|------|-------------|
| `--external-n8n` | Run n8n externally instead of in a subprocess |
| `--custom-user-folder <path>` | Folder to use to store user-specific n8n data (default: `~/.n8n-node-cli`) |
This command:
- Starts n8n on `http://localhost:5678` (unless using `--external-n8n`)
- Links your node to n8n's custom nodes directory (`~/.n8n-node-cli/.n8n/custom`)
- Rebuilds on file changes for live preview
- Watches for changes in your `src/` directory
**Examples:**
```bash
# Standard development with built-in n8n
n8n-node dev
# Use external n8n instance
n8n-node dev --external-n8n
# Custom n8n extensions directory
n8n-node dev --custom-user-folder /home/user
```
#### `n8n-node build`
Compile your node and prepare it for distribution.
```bash
n8n-node build
```
**Flags:** None
Generates:
- Compiled TypeScript code
- Bundled node package
- Optimized assets and icons
- Ready-to-publish package in `dist/`
#### `n8n-node lint`
Lint the node in the current directory.
```bash
n8n-node lint [--fix]
```
**Flags:**
| Flag | Description |
|------|-------------|
| `--fix` | Automatically fix problems |
**Examples:**
```bash
# Check for linting issues
n8n-node lint
# Automatically fix fixable issues
n8n-node lint --fix
```
#### `n8n-node cloud-support`
Manage n8n Cloud eligibility.
```bash
n8n-node cloud-support [enable|disable]
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| _(none)_ | Show current cloud support status |
| `enable` | Enable strict mode + default ESLint config |
| `disable` | Allow custom ESLint config (disables cloud eligibility) |
Strict mode enforces the default ESLint configuration and community node rules required for n8n Cloud verification. When disabled, you can customize your ESLint config but your node won't be eligible for n8n Cloud verification.
#### `n8n-node release`
Publish your community node package to npm.
```bash
n8n-node release
```
**Flags:** None
This command handles the complete release process using [release-it](https://github.com/release-it/release-it):
- Builds the node
- Runs linting checks
- Updates changelog
- Creates git tags
- Creates GitHub releases
- Publishes to npm
## 🔄 Development Workflow
The recommended workflow using the scaffolding tool:
1. **Create your node**:
```bash
npm create @n8n/node my-awesome-node
cd my-awesome-node
```
2. **Start development**:
```bash
npm run dev
```
- Starts n8n on `http://localhost:5678`
- Links your node automatically
- Rebuilds on file changes
3. **Test your node** at `http://localhost:5678`
4. **Lint your code**:
```bash
npm run lint
```
5. **Build for production**:
```bash
npm run build
```
6. **Publish**:
```bash
npm run release
```
## 📁 Project Structure
The CLI expects your project to follow this structure:
```
my-node/
├── src/
│ ├── nodes/
│ │ └── MyNode/
│ │ ├── MyNode.node.ts
│ │ └── MyNode.node.json
│ └── credentials/
├── package.json
└── tsconfig.json
```
## ⚙️ Configuration
The CLI reads configuration from your `package.json`:
```json
{
"name": "n8n-nodes-my-awesome-node",
"n8n": {
"n8nNodesApiVersion": 1,
"nodes": [
"dist/nodes/MyNode/MyNode.node.js"
],
"credentials": [
"dist/credentials/MyNodeAuth.credentials.js"
]
}
}
```
## 🐛 Troubleshooting
### Development server issues
```bash
# Clear n8n custom nodes cache
rm -rf ~/.n8n-node-cli/.n8n/custom
# Restart development server
npm run dev
```
### Build failures
```bash
# Run linting first
npm run lint
# Clean build
npm run build
```
## 📚 Resources
- **[Creating Nodes Guide](https://docs.n8n.io/integrations/creating-nodes/)** - Complete documentation
- **[Node Development Reference](https://docs.n8n.io/integrations/creating-nodes/build/reference/)** - API specifications
- **[Community Forum](https://community.n8n.io)** - Get help and showcase your nodes
- **[@n8n/create-node](https://www.npmjs.com/package/@n8n/create-node)** - Recommended scaffolding tool
## 🤝 Contributing
Found an issue? Contribute to the [n8n repository](https://github.com/n8n-io/n8n) on GitHub.
---
**Happy node development! 🎉**
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
import { execute } from '@oclif/core';
await execute({ dir: import.meta.url });
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import { nodeConfig } from '@n8n/eslint-config/node';
export default defineConfig(
globalIgnores(['src/template/templates/**/template', 'src/template/templates/shared']),
nodeConfig,
{
files: ['**/*.test.ts', 'src/test-utils/**/*'],
rules: {
'import-x/no-extraneous-dependencies': ['error', { devDependencies: true }],
},
},
{
files: ['src/commands/**/*.ts', 'src/modules.d.ts', 'src/configs/eslint.ts'],
rules: { 'import-x/no-default-export': 'off', '@typescript-eslint/naming-convention': 'off' },
},
);
+78
View File
@@ -0,0 +1,78 @@
{
"name": "@n8n/node-cli",
"version": "0.22.0",
"description": "Official CLI for developing community nodes for n8n",
"bin": {
"n8n-node": "bin/n8n-node.mjs"
},
"exports": {
"./eslint": {
"types": "./dist/configs/eslint.d.js",
"default": "./dist/configs/eslint.js"
}
},
"files": [
"bin",
"dist"
],
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",
"copy-templates": "node scripts/copy-templates.mjs",
"dev": "tsc-watch -p tsconfig.build.json -w --onCompilationComplete \"pnpm copy-templates\"",
"format": "biome format --write src",
"format:check": "biome ci src",
"lint": "eslint src --quiet",
"lintfix": "eslint src --fix",
"build": "tsc -p tsconfig.build.json && pnpm copy-templates",
"publish:dry": "pnpm run build && pnpm pub --dry-run",
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest --silent=false",
"start": "./bin/n8n-node.mjs"
},
"repository": {
"type": "git",
"url": "git+https://github.com/n8n-io/n8n.git"
},
"oclif": {
"bin": "n8n-node",
"commands": {
"strategy": "explicit",
"target": "./dist/index.js",
"identifier": "commands"
},
"topicSeparator": " "
},
"dependencies": {
"@clack/prompts": "^0.11.0",
"@n8n/eslint-plugin-community-nodes": "workspace:*",
"@n8n/ai-node-sdk": "workspace:*",
"@oclif/core": "^4.5.2",
"change-case": "^5.4.4",
"eslint-import-resolver-typescript": "^4.4.3",
"eslint-plugin-import-x": "^4.15.2",
"eslint-plugin-n8n-nodes-base": "1.16.5",
"fast-glob": "catalog:",
"handlebars": "4.7.8",
"picocolors": "catalog:",
"prettier": "3.6.2",
"prompts": "^2.4.2",
"rimraf": "catalog:",
"ts-morph": "catalog:",
"typescript-eslint": "^8.35.0"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@oclif/test": "^4.1.13",
"eslint": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
},
"peerDependencies": {
"eslint": ">= 9"
}
}
@@ -0,0 +1,17 @@
#!/usr/bin/env node
import glob from 'fast-glob';
import { cp } from 'node:fs/promises';
import path from 'path';
const templateFiles = glob.sync(['src/template/templates/**/*'], {
cwd: path.resolve(import.meta.dirname, '..'),
ignore: ['**/node_modules', '**/dist'],
dot: true,
});
await Promise.all(
templateFiles.map((template) =>
cp(template, `dist/${template.replace('src/', '')}`, { recursive: true }),
),
);
@@ -0,0 +1,134 @@
import { cancel, outro } from '@clack/prompts';
import fs from 'node:fs/promises';
import { CommandTester } from '../test-utils/command-tester';
import { mockSpawn } from '../test-utils/mock-child-process';
import { setupTestPackage } from '../test-utils/package-setup';
import { tmpdirTest } from '../test-utils/temp-fs';
describe('build command', () => {
beforeEach(() => {
vi.clearAllMocks();
});
tmpdirTest(
'successful build - compiles TypeScript and copies static files',
async ({ tmpdir }) => {
await setupTestPackage(tmpdir);
await fs.mkdir(`${tmpdir}/src/icons`, { recursive: true });
await fs.mkdir(`${tmpdir}/src/assets`, { recursive: true });
await fs.mkdir(`${tmpdir}/src/__schema__`, { recursive: true });
await fs.writeFile(`${tmpdir}/src/icons/icon.png`, 'fake-png-content');
await fs.writeFile(`${tmpdir}/src/assets/logo.svg`, '<svg>fake-svg</svg>');
await fs.writeFile(`${tmpdir}/src/__schema__/node.json`, '{"fake": "schema"}');
mockSpawn('pnpm', ['exec', '--', 'tsc'], { exitCode: 0 });
await CommandTester.run('build');
await expect(tmpdir).toHaveFileEqual('dist/src/icons/icon.png', 'fake-png-content');
await expect(tmpdir).toHaveFileEqual('dist/src/assets/logo.svg', '<svg>fake-svg</svg>');
await expect(tmpdir).toHaveFileEqual('dist/src/__schema__/node.json', '{"fake": "schema"}');
expect(tmpdir).toHaveFile('dist/src/icons');
expect(tmpdir).toHaveFile('dist/src/assets');
expect(tmpdir).toHaveFile('dist/src/__schema__');
expect(outro).toHaveBeenCalledWith('✓ Build successful');
},
);
tmpdirTest('TypeScript compilation failure - exits with error', async ({ tmpdir }) => {
await setupTestPackage(tmpdir);
mockSpawn('pnpm', ['exec', '--', 'tsc'], {
exitCode: 1,
stderr: "error TS2304: Cannot find name 'unknown_var'.",
});
await expect(CommandTester.run('build')).rejects.toThrow('EEXIT: 1');
expect(cancel).toHaveBeenCalledWith('TypeScript build failed');
});
tmpdirTest('child process error - handles spawn errors', async ({ tmpdir }) => {
await setupTestPackage(tmpdir);
mockSpawn('pnpm', ['exec', '--', 'tsc'], {
error: 'ENOENT: no such file or directory, spawn tsc',
});
await expect(CommandTester.run('build')).rejects.toThrow('EEXIT: 1');
expect(cancel).toHaveBeenCalledWith('TypeScript build failed');
});
tmpdirTest('invalid package - not an n8n node package', async ({ tmpdir }) => {
await fs.writeFile(
`${tmpdir}/package.json`,
JSON.stringify({
name: 'regular-package',
version: '1.0.0',
// No n8n field - this makes it an invalid n8n package
}),
);
await expect(CommandTester.run('build')).rejects.toThrow('EEXIT: 1');
expect(cancel).toHaveBeenCalledWith('n8n-node build can only be run in an n8n node package');
});
tmpdirTest('no static files - still completes successfully', async ({ tmpdir }) => {
await setupTestPackage(tmpdir);
mockSpawn('pnpm', ['exec', '--', 'tsc'], { exitCode: 0 });
await CommandTester.run('build');
expect(outro).toHaveBeenCalledWith('✓ Build successful');
});
tmpdirTest('static files in nested directories - creates correct paths', async ({ tmpdir }) => {
await setupTestPackage(tmpdir);
await fs.mkdir(`${tmpdir}/src/nodes/icons`, { recursive: true });
await fs.mkdir(`${tmpdir}/src/nodes/subdir/__schema__`, { recursive: true });
await fs.mkdir(`${tmpdir}/src/assets/images`, { recursive: true });
await fs.writeFile(`${tmpdir}/src/nodes/icons/node1.png`, 'fake-node1-png');
await fs.writeFile(`${tmpdir}/src/nodes/subdir/__schema__/schema.json`, '{"node": "schema"}');
await fs.writeFile(`${tmpdir}/src/assets/images/logo.svg`, '<svg>logo</svg>');
mockSpawn('pnpm', ['exec', '--', 'tsc'], { exitCode: 0 });
await CommandTester.run('build');
await expect(tmpdir).toHaveFileEqual('dist/src/nodes/icons/node1.png', 'fake-node1-png');
await expect(tmpdir).toHaveFileEqual(
'dist/src/nodes/subdir/__schema__/schema.json',
'{"node": "schema"}',
);
await expect(tmpdir).toHaveFileEqual('dist/src/assets/images/logo.svg', '<svg>logo</svg>');
expect(tmpdir).toHaveFile('dist/src/nodes/icons');
expect(tmpdir).toHaveFile('dist/src/nodes/subdir/__schema__');
expect(tmpdir).toHaveFile('dist/src/assets/images');
expect(outro).toHaveBeenCalledWith('✓ Build successful');
});
tmpdirTest('rimraf clears existing dist directory', async ({ tmpdir }) => {
await setupTestPackage(tmpdir);
await fs.mkdir(`${tmpdir}/dist/old-dir`, { recursive: true });
await fs.writeFile(`${tmpdir}/dist/old-file.js`, 'old content');
expect(tmpdir).toHaveFile('dist/old-file.js');
expect(tmpdir).toHaveFile('dist/old-dir');
mockSpawn('pnpm', ['exec', '--', 'tsc'], { exitCode: 0 });
await CommandTester.run('build');
expect(tmpdir).toNotHaveFile('dist/old-file.js');
expect(tmpdir).toNotHaveFile('dist/old-dir');
expect(outro).toHaveBeenCalledWith('✓ Build successful');
});
});
@@ -0,0 +1,66 @@
import { cancel, intro, log, outro, spinner } from '@clack/prompts';
import { Command } from '@oclif/core';
import glob from 'fast-glob';
import { cp, mkdir } from 'node:fs/promises';
import path from 'node:path';
import { rimraf } from 'rimraf';
import { runCommand } from '../utils/child-process';
import { ensureN8nPackage, getCommandHeader } from '../utils/prompts';
export default class Build extends Command {
static override description = 'Compile the node in the current directory and copy assets';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {};
async run(): Promise<void> {
await this.parse(Build);
const commandName = 'n8n-node build';
intro(await getCommandHeader(commandName));
await ensureN8nPackage(commandName);
const buildSpinner = spinner();
buildSpinner.start('Building TypeScript files');
await rimraf('dist');
try {
await runTscBuild();
buildSpinner.stop('TypeScript build successful');
} catch (error) {
cancel('TypeScript build failed');
this.exit(1);
}
const copyStaticFilesSpinner = spinner();
copyStaticFilesSpinner.start('Copying static files');
await copyStaticFiles();
copyStaticFilesSpinner.stop('Copied static files');
outro('✓ Build successful');
}
}
async function runTscBuild(): Promise<void> {
return await runCommand('tsc', [], {
context: 'local',
printOutput: ({ stdout, stderr }) => {
log.error(stdout.concat(stderr).toString());
},
});
}
export async function copyStaticFiles() {
const staticFiles = glob.sync(['**/*.{png,svg}', '**/__schema__/**/*.json'], {
ignore: ['dist', 'node_modules'],
});
return await Promise.all(
staticFiles.map(async (filePath) => {
const destPath = path.join('dist', filePath);
await mkdir(path.dirname(destPath), { recursive: true });
return await cp(filePath, destPath, { recursive: true });
}),
);
}
@@ -0,0 +1,124 @@
import { CommandTester } from '../test-utils/command-tester';
import { MockPrompt } from '../test-utils/mock-prompts';
import { setupTestPackage } from '../test-utils/package-setup';
import { tmpdirTest } from '../test-utils/temp-fs';
describe('cloud-support command', () => {
beforeEach(() => {
MockPrompt.reset();
});
describe('enable', () => {
tmpdirTest('writes correct eslint config and updates package.json', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
eslintConfig: "import { config } from '@n8n/node-cli/eslint'; export default config;",
});
await CommandTester.run('cloud-support enable');
await expect(tmpdir).toHaveFileEqual(
'eslint.config.mjs',
"import { config } from '@n8n/node-cli/eslint';\n\nexport default config;\n",
);
await expect(tmpdir).toHaveFileContaining('package.json', '"strict": true');
});
});
describe('status', () => {
tmpdirTest('shows enabled status when strict mode and default config', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { n8n: { strict: true } },
eslintConfig: true,
});
const result = await CommandTester.run('cloud-support');
expect(result).toHaveLoggedSuccess('ENABLED');
});
tmpdirTest('shows disabled status when not strict mode', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { n8n: { strict: false } },
eslintConfig: true,
});
const result = await CommandTester.run('cloud-support');
expect(result).toHaveLoggedWarning('DISABLED');
});
});
describe('disable', () => {
tmpdirTest('updates config when user confirms', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { n8n: { strict: true } },
eslintConfig: true,
});
MockPrompt.setup([
{
question: 'Are you sure you want to disable cloud support?',
answer: true,
},
]);
const result = await CommandTester.run('cloud-support disable');
await expect(tmpdir).toHaveFileEqual(
'eslint.config.mjs',
"import { configWithoutCloudSupport } from '@n8n/node-cli/eslint';\n\nexport default configWithoutCloudSupport;\n",
);
await expect(tmpdir).toHaveFileContaining('package.json', '"strict": false');
expect(result).toHaveLoggedSuccess(
'Updated eslint.config.mjs to use configWithoutCloudSupport',
);
expect(result).toHaveLoggedSuccess('Disabled strict mode in package.json');
});
tmpdirTest('does not update config when user cancels', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { n8n: { strict: true } },
eslintConfig: true,
});
MockPrompt.setup([
{
question: 'Are you sure you want to disable cloud support?',
answer: 'CANCEL',
},
]);
await expect(CommandTester.run('cloud-support disable')).rejects.toThrow('EEXIT: 0');
await expect(tmpdir).toHaveFileEqual(
'eslint.config.mjs',
"import { config } from '@n8n/node-cli/eslint';\n\nexport default config;\n",
);
await expect(tmpdir).toHaveFileContaining('package.json', '"strict": true');
});
tmpdirTest('does not update config when user declines', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { n8n: { strict: true } },
eslintConfig: true,
});
MockPrompt.setup([
{
question: 'Are you sure you want to disable cloud support?',
answer: false,
},
]);
await expect(CommandTester.run('cloud-support disable')).rejects.toThrow('EEXIT: 0');
await expect(tmpdir).toHaveFileEqual(
'eslint.config.mjs',
"import { config } from '@n8n/node-cli/eslint';\n\nexport default config;\n",
);
await expect(tmpdir).toHaveFileContaining('package.json', '"strict": true');
});
});
});
@@ -0,0 +1,168 @@
import { confirm, intro, log, outro } from '@clack/prompts';
import { Args, Command } from '@oclif/core';
import fs from 'node:fs/promises';
import path from 'node:path';
import picocolors from 'picocolors';
import { suggestCloudSupportCommand, suggestLintCommand } from '../utils/command-suggestions';
import { getPackageJson, updatePackageJson } from '../utils/package';
import { ensureN8nPackage, getCommandHeader, onCancel, withCancelHandler } from '../utils/prompts';
export default class CloudSupport extends Command {
static override description = 'Enable or disable cloud support for this node';
static override examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> enable',
'<%= config.bin %> <%= command.id %> disable',
];
static override args = {
action: Args.string({
description: 'Action to perform (defaults to showing current status)',
required: false,
options: ['enable', 'disable'],
}),
};
async run(): Promise<void> {
const { args } = await this.parse(CloudSupport);
await ensureN8nPackage('cloud-support');
const workingDir = process.cwd();
if (args.action === 'enable') {
await this.enableCloudSupport(workingDir);
} else if (args.action === 'disable') {
await this.disableCloudSupport(workingDir);
} else {
await this.showCloudSupportStatus(workingDir);
}
}
private async enableCloudSupport(workingDir: string): Promise<void> {
intro(await getCommandHeader('n8n-node cloud-support enable'));
await this.updateEslintConfig(workingDir, true);
log.success(`Updated ${picocolors.cyan('eslint.config.mjs')} to use default config`);
await this.updateStrictMode(workingDir, true);
log.success(`Enabled strict mode in ${picocolors.cyan('package.json')}`);
const lintCommand = await suggestLintCommand();
outro(
`Cloud support enabled. Run "${lintCommand}" to check compliance - your node must pass linting to be eligible for n8n Cloud publishing.`,
);
}
private async disableCloudSupport(workingDir: string): Promise<void> {
intro(await getCommandHeader('n8n-node cloud-support disable'));
log.warning(`This will make your node ineligible for n8n Cloud verification!
The following changes will be made:
• Switch to ${picocolors.magenta('configWithoutCloudSupport')} in ${picocolors.cyan('eslint.config.mjs')}
• Disable strict mode in ${picocolors.cyan('package.json')}`);
const confirmed = await withCancelHandler(
confirm({
message: 'Are you sure you want to disable cloud support?',
initialValue: false,
}),
);
if (!confirmed) {
onCancel('Cloud support unchanged');
return;
}
// 1. Update eslint.config.mjs
await this.updateEslintConfig(workingDir, false);
log.success(
`Updated ${picocolors.cyan('eslint.config.mjs')} to use ${picocolors.magenta('configWithoutCloudSupport')}`,
);
// 2. Disable strict mode in package.json
await this.updateStrictMode(workingDir, false);
log.success(`Disabled strict mode in ${picocolors.cyan('package.json')}`);
outro(
"Cloud support disabled. Your node may pass linting but it won't pass verification for n8n Cloud.",
);
}
private async updateEslintConfig(workingDir: string, enableCloud: boolean): Promise<void> {
const eslintConfigPath = path.resolve(workingDir, 'eslint.config.mjs');
const newConfig = enableCloud
? `import { config } from '@n8n/node-cli/eslint';
export default config;
`
: `import { configWithoutCloudSupport } from '@n8n/node-cli/eslint';
export default configWithoutCloudSupport;
`;
await fs.writeFile(eslintConfigPath, newConfig, 'utf-8');
}
private async updateStrictMode(workingDir: string, enableStrict: boolean): Promise<void> {
await updatePackageJson(workingDir, (packageJson) => {
packageJson.n8n = packageJson.n8n ?? {};
packageJson.n8n.strict = enableStrict;
return packageJson;
});
}
private async showCloudSupportStatus(workingDir: string): Promise<void> {
intro(await getCommandHeader('n8n-node cloud-support'));
try {
const packageJson = await getPackageJson(workingDir);
const eslintConfigPath = path.resolve(workingDir, 'eslint.config.mjs');
// Check strict mode
const isStrictMode = packageJson?.n8n?.strict === true;
// Check eslint config
let isUsingDefaultConfig = false;
try {
const eslintConfig = await fs.readFile(eslintConfigPath, 'utf-8');
const normalizedConfig = eslintConfig.replace(/\s+/g, ' ').trim();
const expectedDefault =
"import { config } from '@n8n/node-cli/eslint'; export default config;";
isUsingDefaultConfig = normalizedConfig === expectedDefault;
} catch {
// eslint config doesn't exist or can't be read
}
const isCloudSupported = isStrictMode && isUsingDefaultConfig;
if (isCloudSupported) {
log.success(`✅ Cloud support is ${picocolors.green('ENABLED')}
• Strict mode: ${picocolors.green('enabled')}
• ESLint config: ${picocolors.green('using default config')}
• Status: ${picocolors.green('eligible')} for n8n Cloud verification ${picocolors.dim('(if lint passes)')}`);
} else {
log.warning(`⚠️ Cloud support is ${picocolors.yellow('DISABLED')}
• Strict mode: ${isStrictMode ? picocolors.green('enabled') : picocolors.red('disabled')}
• ESLint config: ${isUsingDefaultConfig ? picocolors.green('using default config') : picocolors.red('using custom config')}
• Status: ${picocolors.red('NOT eligible')} for n8n Cloud verification`);
}
const enableCommand = await suggestCloudSupportCommand('enable');
const disableCommand = await suggestCloudSupportCommand('disable');
const lintCommand = await suggestLintCommand();
log.info(`Available commands:
${enableCommand} - Enable cloud support
${disableCommand} - Disable cloud support
${lintCommand} - Check compliance for cloud publishing`);
outro('Use the commands above to change cloud support settings or check compliance');
} catch (error) {
log.error('Failed to read package.json or determine cloud support status');
outro('Make sure you are in the root directory of your node package');
}
}
}
@@ -0,0 +1,174 @@
import type { Config } from '@oclif/core';
import path from 'node:path';
import { mock } from 'vitest-mock-extended';
import Dev from './index';
import { runCommands } from './utils';
import { setupTestPackage } from '../../test-utils/package-setup';
import { tmpdirTest } from '../../test-utils/temp-fs';
import { createSymlink } from '../../utils/filesystem';
import { onCancel } from '../../utils/prompts';
vi.mock('./utils', async () => {
const actual = await vi.importActual('./utils');
return {
...actual,
runCommands: vi.fn(),
createSpinner: vi.fn(() => vi.fn(() => 'spinner')),
openUrl: vi.fn(),
sleep: vi.fn(),
createOpenN8nHandler: vi.fn(() => ({ key: 'o', handler: vi.fn() })),
buildHelpText: vi.fn(() => 'Press q to quit | o to open n8n'),
};
});
vi.mock('../../utils/prompts', () => ({
onCancel: vi.fn((_msg: string, code?: number) => {
throw new Error(`EEXIT: ${code ?? 0}`);
}),
printCommandHeader: vi.fn(),
getCommandHeader: vi.fn().mockResolvedValue('Command Header'),
}));
vi.mock('../../utils/filesystem', async () => {
const actual = await vi.importActual('../../utils/filesystem');
return {
...actual,
createSymlink: vi.fn(),
};
});
describe('dev command', () => {
const createMockConfig = (tmpdir: string): Config =>
mock<Config>({
root: tmpdir,
runHook: async () => await Promise.resolve({ successes: [], failures: [] }),
});
beforeEach(() => {
vi.clearAllMocks();
});
tmpdirTest(
'creates symlink and starts TypeScript watcher with external-n8n flag',
async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'n8n-nodes-test' },
});
const command = new Dev(['--external-n8n'], createMockConfig(tmpdir));
await command.run();
expect(createSymlink).toHaveBeenCalled();
expect(runCommands).toHaveBeenCalled();
const calls = vi.mocked(runCommands).mock.calls[0]?.[0];
expect(calls?.commands).toHaveLength(1);
expect(calls?.commands[0]?.name).toBe('TypeScript Build (watching)');
},
);
tmpdirTest('starts both TypeScript watcher and n8n server by default', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'n8n-nodes-test' },
});
const command = new Dev([], createMockConfig(tmpdir));
await command.run();
const calls = vi.mocked(runCommands).mock.calls[0]?.[0];
expect(calls?.commands).toHaveLength(2);
expect(calls?.commands[0]?.name).toBe('TypeScript Build (watching)');
expect(calls?.commands[1]?.name).toBe('n8n Server');
});
tmpdirTest('creates symlink in default custom folder location', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'n8n-nodes-test' },
});
const command = new Dev(['--external-n8n'], createMockConfig(tmpdir));
await command.run();
const calls = vi.mocked(createSymlink).mock.calls[0];
expect(calls?.[0]).toContain(tmpdir.split('/').pop());
expect(calls?.[1]).toContain('.n8n-node-cli');
expect(calls?.[1]).toContain('node_modules/n8n-nodes-test');
});
tmpdirTest('creates symlink in custom folder when specified', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'n8n-nodes-test' },
});
const customFolder = path.join(tmpdir, 'my-custom-folder');
const command = new Dev(['--custom-user-folder', customFolder], createMockConfig(tmpdir));
await command.run();
const calls = vi.mocked(createSymlink).mock.calls[0];
expect(calls?.[0]).toContain(tmpdir.split('/').pop());
expect(calls?.[1]).toBe(
path.join(customFolder, '.n8n', 'custom', 'node_modules', 'n8n-nodes-test'),
);
});
tmpdirTest('validates node name before creating symlink', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'invalid-node-name' },
});
const command = new Dev(['--external-n8n'], createMockConfig(tmpdir));
await expect(command.run()).rejects.toThrow('EEXIT');
expect(onCancel).toHaveBeenCalled();
expect(createSymlink).not.toHaveBeenCalled();
expect(runCommands).not.toHaveBeenCalled();
});
tmpdirTest('passes correct environment to n8n server', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'n8n-nodes-test' },
});
const customFolder = path.join(tmpdir, 'custom');
const command = new Dev(['--custom-user-folder', customFolder], createMockConfig(tmpdir));
await command.run();
const calls = vi.mocked(runCommands).mock.calls[0]?.[0];
const n8nCommand = calls?.commands.find((c) => c.name === 'n8n Server');
expect(n8nCommand).toBeDefined();
expect(n8nCommand?.env).toMatchObject({
N8N_DEV_RELOAD: 'true',
N8N_USER_FOLDER: customFolder,
});
});
tmpdirTest('includes open n8n key handler when n8n is enabled', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'n8n-nodes-test' },
});
const command = new Dev([], createMockConfig(tmpdir));
await command.run();
const calls = vi.mocked(runCommands).mock.calls[0]?.[0];
expect(calls?.keyHandlers).toBeDefined();
expect(calls?.keyHandlers).toHaveLength(1);
expect(calls?.keyHandlers?.[0]?.key).toBe('o');
});
tmpdirTest('includes no key handlers with external-n8n flag', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'n8n-nodes-test' },
});
const command = new Dev(['--external-n8n'], createMockConfig(tmpdir));
await command.run();
const calls = vi.mocked(runCommands).mock.calls[0]?.[0];
expect(calls?.keyHandlers).toBeDefined();
expect(calls?.keyHandlers).toHaveLength(0);
});
});
@@ -0,0 +1,122 @@
import { Command, Flags } from '@oclif/core';
import os from 'node:os';
import path from 'node:path';
import picocolors from 'picocolors';
import { createSymlink, ensureFolder } from '../../utils/filesystem';
import { detectPackageManager } from '../../utils/package-manager';
import { getCommandHeader, onCancel } from '../../utils/prompts';
import { validateNodeName } from '../../utils/validation';
import { copyStaticFiles } from '../build';
import {
buildHelpText,
type CommandConfig,
createOpenN8nHandler,
createSpinner,
readPackageName,
runCommands,
} from './utils';
export default class Dev extends Command {
static override description = 'Run n8n with the node and rebuild on changes for live preview';
static override examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --external-n8n',
'<%= config.bin %> <%= command.id %> --custom-user-folder /Users/test',
];
static override flags = {
'external-n8n': Flags.boolean({
default: false,
description:
'By default n8n-node dev will run n8n in a sub process. Enable this option if you would like to run n8n elsewhere. Make sure to set N8N_DEV_RELOAD to true in that case.',
}),
'custom-user-folder': Flags.directory({
default: path.join(os.homedir(), '.n8n-node-cli'),
description:
'Folder to use to store user-specific n8n data. By default it will use ~/.n8n-node-cli. The node CLI will install your node here.',
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(Dev);
const packageManager = (await detectPackageManager()) ?? 'npm';
await copyStaticFiles();
const n8nUserFolder = flags['custom-user-folder'];
const customNodesFolder = path.join(n8nUserFolder, '.n8n', 'custom');
const nodeModulesFolder = path.join(customNodesFolder, 'node_modules');
await ensureFolder(nodeModulesFolder);
const packageName = await readPackageName();
const invalidNodeNameError = validateNodeName(packageName);
if (invalidNodeNameError) return onCancel(invalidNodeNameError);
const currentDir = process.cwd();
const symlinkPath = path.join(nodeModulesFolder, packageName);
try {
await createSymlink(currentDir, symlinkPath);
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unknown error creating symbolic link';
return onCancel(`Failed to create symbolic link: ${message}`);
}
let n8nReady = false;
const hasN8n = !flags['external-n8n'];
let spinnerMessage = 'Starting n8n...';
setTimeout(() => {
spinnerMessage = `Installing n8n... ${picocolors.dim('(this can take a while on first run)')}`;
}, 10_000);
const n8nSpinner = createSpinner(() => spinnerMessage);
const commandsList: CommandConfig[] = [
{
cmd: packageManager,
args: ['exec', '--', 'tsc', '--watch', '--pretty'],
name: 'TypeScript Build (watching)',
},
];
if (hasN8n) {
commandsList.push({
cmd: 'npx',
args: ['-y', '--color=always', '--prefer-online', 'n8n@latest'],
name: 'n8n Server',
cwd: n8nUserFolder,
env: {
...process.env,
N8N_DEV_RELOAD: 'true',
DB_SQLITE_POOL_SIZE: '10',
N8N_USER_FOLDER: n8nUserFolder,
},
onOutput: (line: string) => {
if (line.includes('Editor is now accessible')) {
n8nReady = true;
}
},
getPlaceholder: n8nSpinner,
});
}
const keyHandlers = [];
if (hasN8n) {
keyHandlers.push(createOpenN8nHandler());
}
const headerText = await getCommandHeader('n8n-node dev');
runCommands({
commands: commandsList,
keyHandlers,
helpText: () => buildHelpText(hasN8n, n8nReady),
headerText,
});
}
}
@@ -0,0 +1,119 @@
import { execSync } from 'node:child_process';
import { createSpinner, openUrl, sleep } from './utils';
vi.mock('node:child_process');
describe('dev utils', () => {
describe('sleep', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should resolve after specified milliseconds', async () => {
const promise = sleep(100);
let resolved = false;
void promise.then(() => {
resolved = true;
});
vi.advanceTimersByTime(99);
await Promise.resolve();
expect(resolved).toBe(false);
vi.advanceTimersByTime(1);
await vi.runAllTimersAsync();
expect(resolved).toBe(true);
await expect(promise).resolves.toBeUndefined();
});
});
describe('createSpinner', () => {
it('should return a function that cycles through spinner frames', () => {
const spinner = createSpinner('Loading');
const frame1 = spinner();
const frame2 = spinner();
expect(frame1).toContain('Loading');
expect(frame2).toContain('Loading');
expect(frame1).not.toBe(frame2);
});
it('should cycle back to the first frame after all frames', () => {
const spinner = createSpinner('Test');
const frames = [];
for (let i = 0; i < 11; i++) {
frames.push(spinner());
}
expect(frames[0]).toBe(frames[10]);
});
});
describe('openUrl', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should use "open" command on darwin platform', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin' });
openUrl('http://localhost:5678');
expect(execSync).toHaveBeenCalledWith('open "http://localhost:5678"');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should use "start" command on win32 platform with empty window title', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });
openUrl('http://localhost:5678');
expect(execSync).toHaveBeenCalledWith('start "" "http://localhost:5678"');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should use "xdg-open" command on linux platform', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'linux' });
openUrl('http://localhost:5678');
expect(execSync).toHaveBeenCalledWith('xdg-open "http://localhost:5678"');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should escape double quotes in URL', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin' });
openUrl('http://localhost:5678?query="value"');
expect(execSync).toHaveBeenCalledWith('open "http://localhost:5678?query=\\"value\\""');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should not throw if execSync fails', () => {
vi.mocked(execSync).mockImplementation(() => {
throw new Error('Command failed');
});
expect(() => openUrl('http://localhost:5678')).not.toThrow();
});
});
});
@@ -0,0 +1,578 @@
import { type ChildProcess, execSync, spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import picocolors from 'picocolors';
import { jsonParse } from '../../utils/json';
interface CommandOutput {
name: string;
lines: string[];
isRunning: boolean;
exitCode: number | null;
getPlaceholder?: () => string;
}
const ANSI = {
CLEAR_SCREEN: '\x1b[2J',
CURSOR_HOME: '\x1b[H',
ENTER_ALT_SCREEN: '\x1b[?1049h',
EXIT_ALT_SCREEN: '\x1b[?1049l',
HIDE_CURSOR: '\x1b[?25l',
SHOW_CURSOR: '\x1b[?25h',
};
const CONFIG = {
MIN_LINES_PER_PANEL: 3,
MAX_LINES_PER_PANEL: 50,
RENDER_INTERVAL_MS: 100,
SEPARATOR_WIDTH: 80,
GRACEFUL_SHUTDOWN_TIMEOUT: 5000,
KILL_TIMEOUT_MS: 1000,
PROCESS_KILL_DELAY_MS: 100,
EXIT_KILL_TIMEOUT_MS: 500,
};
function calculatePanelHeight(numPanels: number, headerLines: number): number {
const terminalRows = process.stdout.rows ?? 24;
const panelOverheadPerPanel = 2;
const blankLinesBetweenPanels = numPanels - 1;
const helpTextLines = 2;
const totalOverhead =
headerLines + numPanels * panelOverheadPerPanel + blankLinesBetweenPanels + helpTextLines;
const availableRows = Math.max(0, terminalRows - totalOverhead);
const linesPerPanel = Math.floor(availableRows / numPanels);
const minRequiredRows =
headerLines +
numPanels * (CONFIG.MIN_LINES_PER_PANEL + panelOverheadPerPanel) +
blankLinesBetweenPanels +
helpTextLines;
if (terminalRows < minRequiredRows) {
return Math.max(1, linesPerPanel);
}
return Math.max(CONFIG.MIN_LINES_PER_PANEL, Math.min(CONFIG.MAX_LINES_PER_PANEL, linesPerPanel));
}
/* eslint-disable no-control-regex */
function stripScreenControlCodes(str: string): string {
return str
.replace(/\x1b\[2J/g, '')
.replace(/\x1b\[H/g, '')
.replace(/\x1b\[(\d+)?J/g, '')
.replace(/\x1b\[(\d+)?K/g, '')
.replace(/\x1b\[(\d+)?[ABCDEFG]/g, '');
}
/* eslint-enable no-control-regex */
function getStatusDisplay(output: CommandOutput) {
if (output.isRunning) {
return { icon: '', colorFn: picocolors.green, text: 'running' };
}
const exitCode = output.exitCode ?? 1;
if (exitCode === 130) {
return { icon: '✗ ', colorFn: picocolors.red, text: 'canceled' };
}
const success = exitCode === 0;
return {
icon: success ? '✓ ' : '✗ ',
colorFn: success ? picocolors.green : picocolors.red,
text: `exit ${exitCode}`,
};
}
function getVisibleLength(str: string): number {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, '').length;
}
function truncateLine(line: string, maxWidth: number): string {
if (getVisibleLength(line) <= maxWidth) return line;
let result = '';
let visible = 0;
let inAnsi = false;
for (const char of line) {
if (char === '\x1b') inAnsi = true;
if (inAnsi) {
result += char;
if (char === 'm') inAnsi = false;
continue;
}
if (visible >= maxWidth - 1) {
result += picocolors.dim('…');
break;
}
result += char;
visible++;
}
return result;
}
function processStreamData(data: Buffer, outputLines: string[]): void {
const text = data.toString().replace(/\r\n/g, '\n');
const segments = text.split('\r');
for (let i = 0; i < segments.length; i++) {
if (i > 0 && outputLines.length > 0) {
outputLines.pop();
}
const lines = segments[i].split('\n');
for (let j = 0; j < lines.length; j++) {
const isLastLine = j === lines.length - 1;
if (lines[j] || !isLastLine) {
outputLines.push(lines[j]);
}
}
}
}
export async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
export function createSpinner(text: string | (() => string)): () => string {
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let index = 0;
return () => {
const frame = picocolors.cyan(frames[index]);
index = (index + 1) % frames.length;
const message = typeof text === 'function' ? text() : text;
return `${frame} ${message}`;
};
}
function getOpenCommand(url: string): string {
const escapedUrl = url.replace(/"/g, '\\"');
switch (process.platform) {
case 'darwin':
return `open "${escapedUrl}"`;
case 'win32':
return `start "" "${escapedUrl}"`;
default:
return `xdg-open "${escapedUrl}"`;
}
}
export function openUrl(url: string): void {
try {
execSync(getOpenCommand(url));
} catch {
// Ignore errors when opening URLs
}
}
export interface CommandConfig {
cmd: string;
args: string[];
name: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
onOutput?: (line: string) => void;
getPlaceholder?: () => string;
}
export interface KeyHandler {
key: string;
description?: string;
handler: (cleanup: () => void) => void;
}
export interface CommandsConfig {
commands: CommandConfig[];
keyHandlers?: KeyHandler[];
helpText?: () => string;
headerText?: string;
}
interface RenderState {
lastOutput: string;
}
function renderPanel(output: CommandOutput, terminalWidth: number, panelHeight: number): string {
const status = getStatusDisplay(output);
const maxWidth = terminalWidth - 4;
const header = `╭─ ${status.colorFn(status.icon)}${picocolors.bold(output.name)} ${status.colorFn(`(${status.text})`)}\n`;
const recentLines = output.lines.slice(-panelHeight);
let content = '';
if (recentLines.length === 0 && output.getPlaceholder && output.isRunning) {
content = `${output.getPlaceholder()}\n`;
for (let i = 1; i < panelHeight; i++) {
content += '│\n';
}
} else {
for (let i = 0; i < panelHeight; i++) {
const cleanedLine = stripScreenControlCodes(recentLines[i] ?? '');
content += cleanedLine ? `${truncateLine(cleanedLine, maxWidth)}\n` : '│\n';
}
}
return header + content + '╰─\n';
}
function renderUI(outputs: CommandOutput[], helpText?: string, headerText?: string): string {
const terminalWidth = process.stdout.columns ?? CONFIG.SEPARATOR_WIDTH;
let result = '';
if (headerText) {
result += `${headerText}\n\n`;
}
const headerLines = headerText ? headerText.split('\n').length + 1 : 0;
const panelHeight = calculatePanelHeight(outputs.length, headerLines);
outputs.forEach((output, index) => {
result += renderPanel(output, terminalWidth, panelHeight);
if (index < outputs.length - 1) {
result += '\n';
}
});
const allRunning = outputs.every((o) => o.isRunning);
if (allRunning && helpText) {
result += `\n${helpText}\n`;
}
return result;
}
function doRender(
state: RenderState,
outputs: CommandOutput[],
helpText?: string,
headerText?: string,
): void {
const newOutput = renderUI(outputs, helpText, headerText);
if (newOutput === state.lastOutput) return;
process.stdout.write(ANSI.CLEAR_SCREEN + ANSI.CURSOR_HOME + newOutput);
state.lastOutput = newOutput;
}
function setupKeyboardInput(
handleSignal: () => void,
cleanup: () => void,
keyHandlers?: KeyHandler[],
): void {
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
process.stdin.on('data', (key: string) => {
if (key === '\u0003' || key === 'q') {
handleSignal();
return;
}
const handler = keyHandlers?.find((h) => h.key === key && h.key !== 'q');
if (handler) {
handler.handler(cleanup);
}
});
}
async function handleCommandCompletion(
commandOutputs: CommandOutput[],
cleanup: (graceful: boolean) => Promise<void>,
): Promise<void> {
const exitedCommand = commandOutputs.find((o) => !o.isRunning);
if (!exitedCommand) return;
await cleanup(true);
const exitCode = exitedCommand.exitCode ?? 1;
const message =
exitCode === 0
? picocolors.green('Command completed successfully.')
: picocolors.red(`Command "${exitedCommand.name}" exited with code ${exitCode}.`);
process.stdout.write(`\n${picocolors.bold(message)}\n`);
process.exit(exitCode);
}
function restoreTerminal(): void {
if (process.stdout.isTTY) {
process.stdout.write(ANSI.SHOW_CURSOR);
process.stdout.write(ANSI.EXIT_ALT_SCREEN);
}
}
function printAllCommandOutputs(outputs: CommandOutput[], headerText?: string): void {
if (headerText) {
process.stdout.write(`\n${headerText}\n\n`);
}
outputs.forEach((output, index) => {
process.stdout.write(`${picocolors.bold(output.name)}\n`);
for (const line of output.lines) {
const cleanedLine = stripScreenControlCodes(line);
if (cleanedLine.trim()) {
process.stdout.write(`${cleanedLine}\n`);
}
}
if (index < outputs.length - 1) {
process.stdout.write('\n');
}
});
process.stdout.write(
`\n${picocolors.dim('Shutting down gracefully... Press Ctrl+C again to force quit.')}\n`,
);
}
async function killProcess(proc: ChildProcess, graceful: boolean): Promise<void> {
if (!proc.pid || proc.exitCode !== null) return;
const pid = proc.pid;
return await new Promise<void>((resolve) => {
let timeoutId: NodeJS.Timeout | null = null;
if (graceful) {
timeoutId = setTimeout(() => {
try {
if (process.platform === 'win32') {
execSync(`taskkill /PID ${pid} /T /F`, { timeout: CONFIG.KILL_TIMEOUT_MS });
} else {
process.kill(-pid, 'SIGKILL');
}
} catch {
// Ignore errors during force kill
}
resolve();
}, CONFIG.GRACEFUL_SHUTDOWN_TIMEOUT);
}
proc.once('exit', () => {
if (timeoutId) clearTimeout(timeoutId);
resolve();
});
try {
if (process.platform === 'win32') {
execSync(`taskkill /PID ${pid} /T /F`, { timeout: CONFIG.KILL_TIMEOUT_MS });
} else {
process.kill(-pid, graceful ? 'SIGTERM' : 'SIGKILL');
}
} catch {
try {
proc.kill(graceful ? 'SIGTERM' : 'SIGKILL');
} catch {
if (timeoutId) clearTimeout(timeoutId);
resolve();
}
}
if (!graceful) {
if (timeoutId) clearTimeout(timeoutId);
setTimeout(resolve, CONFIG.PROCESS_KILL_DELAY_MS);
}
});
}
export function runCommands(config: CommandsConfig): void {
const commandOutputs: CommandOutput[] = [];
const childProcesses: ChildProcess[] = [];
let renderInterval: NodeJS.Timeout | null = null;
let isShuttingDown = false;
let cleanupPerformed = false;
const cleanup = async (graceful: boolean = true): Promise<void> => {
if (cleanupPerformed) return;
cleanupPerformed = true;
if (renderInterval) {
clearInterval(renderInterval);
renderInterval = null;
}
restoreTerminal();
if (graceful) {
printAllCommandOutputs(commandOutputs, config.headerText);
}
await Promise.all(childProcesses.map(async (proc) => await killProcess(proc, graceful)));
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
};
const handleSignal = (): void => {
if (!isShuttingDown) {
isShuttingDown = true;
commandOutputs.forEach((output) => {
if (output.isRunning) {
output.isRunning = false;
output.exitCode = 130;
}
});
void cleanup(true).then(() => {
process.exit(130);
});
return;
}
if (cleanupPerformed) {
process.stdout.write(picocolors.yellow('\nForce quitting...\n'));
process.exit(130);
} else {
void cleanup(false).then(() => {
process.exit(130);
});
}
};
process.on('SIGINT', handleSignal);
process.on('SIGTERM', handleSignal);
process.on('exit', () => {
if (!cleanupPerformed && childProcesses.length > 0) {
for (const proc of childProcesses) {
if (!proc.pid) continue;
try {
if (process.platform === 'win32') {
execSync(`taskkill /PID ${proc.pid} /T /F`, { timeout: CONFIG.EXIT_KILL_TIMEOUT_MS });
} else {
process.kill(-proc.pid, 'SIGKILL');
}
} catch {
// Ignore errors during exit cleanup
}
}
}
});
process.on('uncaughtException', (error) => {
console.error(picocolors.red('\nUncaught exception:'), error);
void cleanup(false).then(() => {
process.exit(1);
});
});
process.on('unhandledRejection', (reason) => {
console.error(picocolors.red('\nUnhandled rejection:'), reason);
void cleanup(false).then(() => {
process.exit(1);
});
});
const startRenderLoop = (): void => {
if (renderInterval !== null) return;
if (process.stdout.isTTY) {
process.stdout.write(ANSI.ENTER_ALT_SCREEN);
process.stdout.write(ANSI.HIDE_CURSOR);
}
const state: RenderState = {
lastOutput: '',
};
if (process.stdin.isTTY) {
setupKeyboardInput(handleSignal, cleanup, config.keyHandlers);
}
doRender(state, commandOutputs, config.helpText?.(), config.headerText);
renderInterval = setInterval(() => {
doRender(state, commandOutputs, config.helpText?.(), config.headerText);
void handleCommandCompletion(commandOutputs, cleanup);
}, CONFIG.RENDER_INTERVAL_MS);
};
config.commands.forEach((cmdConfig) => {
const output: CommandOutput = {
name: cmdConfig.name,
lines: [],
isRunning: true,
exitCode: null,
getPlaceholder: cmdConfig.getPlaceholder,
};
commandOutputs.push(output);
const commandString = `${cmdConfig.cmd} ${cmdConfig.args.join(' ')}`;
const child = spawn(commandString, {
shell: true,
cwd: cmdConfig.cwd,
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
env: {
...process.env,
...cmdConfig.env,
FORCE_COLOR: '3',
COLORTERM: 'truecolor',
TERM: 'xterm-256color',
},
});
childProcesses.push(child);
const handleData = (data: Buffer) => {
processStreamData(data, output.lines);
if (cmdConfig.onOutput) {
const lines = data.toString().split('\n');
for (const line of lines) {
if (line.trim()) {
cmdConfig.onOutput(line);
}
}
}
};
child.stdout.on('data', handleData);
child.stderr.on('data', handleData);
child.on('close', (code) => {
output.isRunning = false;
output.exitCode = code;
});
});
if (commandOutputs.length > 0) {
startRenderLoop();
}
}
export async function readPackageName(): Promise<string> {
return await fs
.readFile('package.json', 'utf-8')
.then((packageJson) => jsonParse<{ name: string }>(packageJson)?.name ?? 'unknown');
}
export function createOpenN8nHandler(): KeyHandler {
return {
key: 'o',
handler: () => {
openUrl('http://localhost:5678');
},
};
}
export function buildHelpText(hasN8n: boolean, isN8nReady: boolean): string {
const quitText = `${picocolors.dim('Press')} q ${picocolors.dim('to quit')}`;
if (hasN8n && isN8nReady) {
return `${quitText} ${picocolors.dim('|')} o ${picocolors.dim('to open n8n')}`;
}
return quitText;
}
@@ -0,0 +1,200 @@
import { cancel } from '@clack/prompts';
import fs from 'node:fs/promises';
import { CommandTester } from '../test-utils/command-tester';
import { stripAnsiCodes } from '../test-utils/matchers';
import { mockSpawn } from '../test-utils/mock-child-process';
import { setupTestPackage } from '../test-utils/package-setup';
import { tmpdirTest } from '../test-utils/temp-fs';
describe('lint command', () => {
const mockProcessStdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
beforeEach(() => {
vi.clearAllMocks();
});
tmpdirTest('successful lint - runs eslint with correct arguments', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
eslintConfig: true,
});
mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], { exitCode: 0 });
const result = await CommandTester.run('lint');
expect(result).toBeDefined();
});
tmpdirTest('successful lint with warnings - shows warnings in output', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
eslintConfig: true,
});
const eslintWarnings = `
/tmp/project/src/index.ts
10:5 warning Unused variable 'unusedVar' @typescript-eslint/no-unused-vars
15:3 warning Missing return type @typescript-eslint/explicit-function-return-type
✖ 2 problems (0 errors, 2 warnings)
`;
mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], {
exitCode: 0,
stdout: eslintWarnings,
});
const result = await CommandTester.run('lint');
expect(result).toBeDefined();
const stdoutCalls = mockProcessStdout.mock.calls.flat();
const allOutput = stripAnsiCodes(
stdoutCalls.map((call) => (Buffer.isBuffer(call) ? call.toString() : String(call))).join(''),
);
expect(allOutput).toContain('Unused variable');
});
tmpdirTest('lint with fix flag - passes --fix to eslint', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
eslintConfig: true,
});
mockSpawn('pnpm', ['exec', '--', 'eslint', '.', '--fix'], { exitCode: 0 });
const result = await CommandTester.run('lint --fix');
expect(result).toBeDefined();
});
tmpdirTest('eslint failure - exits with error code', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
eslintConfig: true,
});
mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], {
exitCode: 1,
stderr: 'ESLint found 3 errors',
});
await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1');
});
tmpdirTest('eslint spawn error - handles process errors', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
eslintConfig: true,
});
mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], {
error: 'ENOENT: no such file or directory, spawn eslint',
});
await expect(CommandTester.run('lint')).rejects.toThrow();
});
tmpdirTest('invalid package - not an n8n node package', async ({ tmpdir }) => {
await fs.writeFile(
`${tmpdir}/package.json`,
JSON.stringify({
name: 'regular-package',
version: '1.0.0',
// No n8n field - this makes it an invalid n8n package
}),
);
await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1');
expect(cancel).toHaveBeenCalledWith('lint can only be run in an n8n node package');
});
tmpdirTest('strict mode with default config - passes validation', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { n8n: { strict: true } },
eslintConfig: true,
});
mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], { exitCode: 0 });
const result = await CommandTester.run('lint');
expect(result).toBeDefined();
});
tmpdirTest('cloud-only lint errors - suggests disabling cloud support', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
eslintConfig: true,
});
mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], {
exitCode: 1,
stderr: 'Error: @n8n/community-nodes/no-restricted-globals rule failed',
});
await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1');
const stdoutCalls = mockProcessStdout.mock.calls.flat();
const hasCloudMessage = stdoutCalls.some(
(call) =>
typeof call === 'string' && call.includes('n8n Cloud compatibility issues detected'),
);
expect(hasCloudMessage).toBe(true);
});
tmpdirTest('regular lint errors - no cloud suggestion', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
eslintConfig: true,
});
mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], {
exitCode: 1,
stderr: 'Error: Unexpected token',
});
await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1');
const stdoutCalls = mockProcessStdout.mock.calls.flat();
const hasCloudMessage = stdoutCalls.some(
(call) => typeof call === 'string' && call.includes('n8n Cloud compatibility'),
);
expect(hasCloudMessage).toBe(false);
});
tmpdirTest('strict mode with modified config - fails validation', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { n8n: { strict: true } },
eslintConfig:
"import { config } from '@n8n/node-cli/eslint';\n\n// Custom modification\nexport default config;\n",
});
await fs.writeFile(`${tmpdir}/pnpm-lock.yaml`, 'lockfileVersion: 5.4\n');
await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1');
const stdoutCalls = mockProcessStdout.mock.calls.flat();
const hasStrictModeError = stdoutCalls.some(
(call) => typeof call === 'string' && call.includes('Strict mode violation:'),
);
expect(hasStrictModeError).toBe(true);
});
tmpdirTest('strict mode with missing config - fails validation', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { n8n: { strict: true } },
});
await fs.writeFile(`${tmpdir}/pnpm-lock.yaml`, 'lockfileVersion: 5.4\n');
// Don't create eslint.config.mjs file (it will be missing)
await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1');
const stdoutCalls = mockProcessStdout.mock.calls.flat();
const stdout = stdoutCalls
.filter((call) => typeof call === 'string')
.map(stripAnsiCodes)
.join('\n');
expect(stdout).toContain('eslint.config.mjs not found');
});
});
+143
View File
@@ -0,0 +1,143 @@
import { intro } from '@clack/prompts';
import { Command, Flags } from '@oclif/core';
import fs from 'node:fs/promises';
import path from 'node:path';
import picocolors from 'picocolors';
import { ChildProcessError, runCommand } from '../utils/child-process';
import { suggestCloudSupportCommand } from '../utils/command-suggestions';
import { getPackageJson } from '../utils/package';
import { ensureN8nPackage, getCommandHeader } from '../utils/prompts';
import { isEnoentError } from '../utils/validation';
export default class Lint extends Command {
static override description =
'Lint the node in the current directory. Includes auto-fixing. In strict mode, verifies eslint config is unchanged from default.';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {
fix: Flags.boolean({ description: 'Automatically fix problems', default: false }),
};
async run(): Promise<void> {
const { flags } = await this.parse(Lint);
intro(await getCommandHeader('n8n-node lint'));
await ensureN8nPackage('lint');
await this.checkStrictMode();
const args = ['.'];
if (flags.fix) {
args.push('--fix');
}
let eslintOutput = '';
try {
await runCommand('eslint', args, {
context: 'local',
stdio: 'pipe',
env: { ...process.env, FORCE_COLOR: '1' },
alwaysPrintOutput: true,
printOutput: ({ stdout, stderr }) => {
eslintOutput = Buffer.concat([...stdout, ...stderr]).toString();
process.stdout.write(Buffer.concat(stdout));
process.stderr.write(Buffer.concat(stderr));
},
});
} catch (error: unknown) {
if (error instanceof ChildProcessError) {
// Check if error might be related to cloud-only rules
await this.handleLintErrors(eslintOutput);
if (error.signal) {
process.kill(process.pid, error.signal);
} else {
process.exit(error.code ?? 0);
}
}
throw error;
}
}
private async checkStrictMode(): Promise<void> {
try {
const workingDir = process.cwd();
const packageJson = await getPackageJson(workingDir);
if (!packageJson?.n8n?.strict) {
return;
}
await this.verifyEslintConfig(workingDir);
} catch (error) {
return;
}
}
private async verifyEslintConfig(workingDir: string): Promise<void> {
const eslintConfigPath = path.resolve(workingDir, 'eslint.config.mjs');
const templatePath = path.resolve(
__dirname,
'../template/templates/shared/default/eslint.config.mjs',
);
const expectedConfig = await fs.readFile(templatePath, 'utf-8');
try {
const currentConfig = await fs.readFile(eslintConfigPath, 'utf-8');
const normalizedCurrent = currentConfig.replace(/\s+/g, ' ').trim();
const normalizedExpected = expectedConfig.replace(/\s+/g, ' ').trim();
if (normalizedCurrent !== normalizedExpected) {
const enableCommand = await suggestCloudSupportCommand('enable');
this.log(`${picocolors.red('Strict mode violation:')} ${picocolors.cyan('eslint.config.mjs')} has been modified from the default configuration.
${picocolors.dim('Expected:')}
${picocolors.gray(expectedConfig)}
To restore default config: ${enableCommand}
To disable strict mode: set ${picocolors.yellow('"strict": false')} in ${picocolors.cyan('package.json')} under the ${picocolors.yellow('"n8n"')} section.`);
process.exit(1);
}
} catch (error: unknown) {
if (isEnoentError(error)) {
const enableCommand = await suggestCloudSupportCommand('enable');
this.log(
`${picocolors.red('Strict mode violation:')} ${picocolors.cyan('eslint.config.mjs')} not found. Expected default configuration.
To create default config: ${enableCommand}`,
);
process.exit(1);
}
throw error;
}
}
private async handleLintErrors(eslintOutput: string): Promise<void> {
if (this.containsCloudOnlyErrors(eslintOutput)) {
const disableCommand = await suggestCloudSupportCommand('disable');
this.log(`${picocolors.yellow('⚠️ n8n Cloud compatibility issues detected')}
These lint failures prevent verification to n8n Cloud.
To disable cloud compatibility checks:
${disableCommand}
${picocolors.dim(`Note: This will switch to ${picocolors.magenta('configWithoutCloudSupport')} and disable strict mode`)}`);
}
}
private containsCloudOnlyErrors(errorMessage: string): boolean {
const cloudOnlyRules = [
'@n8n/community-nodes/no-restricted-imports',
'@n8n/community-nodes/no-restricted-globals',
];
return cloudOnlyRules.some((rule) => errorMessage.includes(rule));
}
}
@@ -0,0 +1,548 @@
import fs from 'node:fs/promises';
import { CommandTester } from '../../test-utils/command-tester';
import { mockSpawn, mockExecSync } from '../../test-utils/mock-child-process';
import { MockPrompt } from '../../test-utils/mock-prompts';
import { tmpdirTest } from '../../test-utils/temp-fs';
vi.mock('../../utils/filesystem', async () => {
const actual = await vi.importActual('../../utils/filesystem');
return {
...actual,
delayAtLeast: vi.fn(async <T>(promise: Promise<T>) => await promise),
};
});
describe('new command', () => {
beforeEach(() => {
vi.clearAllMocks();
MockPrompt.reset();
});
tmpdirTest('creates new node project with user prompts', async ({ tmpdir }) => {
MockPrompt.setup([
{
question: 'What kind of node are you building?',
answer: 'programmatic',
},
{
question: 'What type of programmatic node are you building?',
answer: 'basic',
},
]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Test User\n' },
{ command: 'git config --get user.email', result: 'test@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
{
command: 'pnpm',
args: ['install'],
options: { exitCode: 0 },
},
]);
await CommandTester.run('new n8n-nodes-my-awesome-api');
expect(MockPrompt).toHaveAskedAllQuestions();
expect(MockPrompt).toHaveAskedQuestion('What kind of node are you building?');
expect(tmpdir).toHaveFile('n8n-nodes-my-awesome-api');
await expect(tmpdir).toHaveFileContaining(
'n8n-nodes-my-awesome-api/package.json',
'"name": "n8n-nodes-my-awesome-api"',
);
await expect(tmpdir).toHaveFileContaining(
'n8n-nodes-my-awesome-api/package.json',
'"name": "Test User"',
);
await expect(tmpdir).toHaveFileContaining(
'n8n-nodes-my-awesome-api/package.json',
'"email": "test@example.com"',
);
await expect(tmpdir).toHaveFileContaining(
'n8n-nodes-my-awesome-api/nodes/Example/Example.node.ts',
'export class Example implements INodeType',
);
// Check if credentials files exist
try {
const credentialsPath = `${tmpdir}/n8n-nodes-my-awesome-api/credentials`;
const credentialFiles = await fs.readdir(credentialsPath);
if (credentialFiles.length > 0) {
await expect(tmpdir).toHaveFileContaining(
`n8n-nodes-my-awesome-api/credentials/${credentialFiles[0]}`,
'implements ICredentialType',
);
}
} catch {
// Credentials directory doesn't exist, which is fine
}
});
tmpdirTest('creates new node project with node name prompt', async ({ tmpdir }) => {
MockPrompt.setup([
{
question: "Package name (must start with 'n8n-nodes-' or '@org/n8n-nodes-')",
answer: 'n8n-nodes-interactive-demo',
},
{
question: 'What kind of node are you building?',
answer: 'declarative',
},
{
question: 'What template do you want to use?',
answer: 'githubIssues',
},
]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Test User\n' },
{ command: 'git config --get user.email', result: 'test@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
]);
await CommandTester.run('new --skip-install');
expect(MockPrompt).toHaveAskedAllQuestions();
const projectName = 'n8n-nodes-interactive-demo';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/package.json`,
'"name": "n8n-nodes-interactive-demo"',
);
await expect(tmpdir).toHaveFileContaining(`${projectName}/package.json`, '"name": "Test User"');
await expect(tmpdir).toHaveFileContaining(
`${projectName}/package.json`,
'"email": "test@example.com"',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/GithubIssues/GithubIssues.node.ts`,
'export class GithubIssues implements INodeType',
);
// Check if credentials files exist
try {
const credentialsPath = `${tmpdir}/${projectName}/credentials`;
const credentialFiles = await fs.readdir(credentialsPath);
if (credentialFiles.length > 0) {
await expect(tmpdir).toHaveFileContaining(
`${projectName}/credentials/${credentialFiles[0]}`,
'implements ICredentialType',
);
}
} catch {
// Credentials directory doesn't exist, which is fine
}
});
tmpdirTest(
'creates new node project with custom template',
async ({ tmpdir }) => {
MockPrompt.setup([
{
question: 'What kind of node are you building?',
answer: 'declarative',
},
{
question: 'What template do you want to use?',
answer: 'custom',
},
{
question: "What's the base URL of the API?",
answer: 'https://api.custom-service.com',
},
{
question: 'What type of authentication does your API use?',
answer: 'apiKey',
},
]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Custom User\n' },
{ command: 'git config --get user.email', result: 'custom@test.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
]);
await CommandTester.run('new n8n-nodes-custom-api --skip-install');
expect(MockPrompt).toHaveAskedAllQuestions();
const projectName = 'n8n-nodes-custom-api';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/package.json`,
'"name": "n8n-nodes-custom-api"',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/package.json`,
'"name": "Custom User"',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/package.json`,
'"email": "custom@test.com"',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/CustomApi/CustomApi.node.ts`,
'implements INodeType',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/credentials/CustomApiApi.credentials.ts`,
'implements ICredentialType',
);
},
15_000,
);
test('handles prompt cancellation gracefully', async () => {
MockPrompt.setup([
{
question: 'What kind of node are you building?',
answer: 'CANCEL',
},
]);
await expect(CommandTester.run('new n8n-nodes-cancelled --skip-install')).rejects.toThrow(
'EEXIT: 0',
);
expect(MockPrompt).toHaveAskedAllQuestions();
});
tmpdirTest(
'creates new node project with all arguments provided (no prompts)',
async ({ tmpdir }) => {
MockPrompt.setup([]);
mockExecSync([
{ command: 'git config --get user.name', result: 'No Prompt User\n' },
{ command: 'git config --get user.email', result: 'noprompt@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
]);
await CommandTester.run(
'new n8n-nodes-full-args --template declarative/github-issues --force --skip-install',
);
expect(MockPrompt).toHaveAskedAllQuestions();
const projectName = 'n8n-nodes-full-args';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/package.json`,
'"name": "n8n-nodes-full-args"',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/package.json`,
'"name": "No Prompt User"',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/package.json`,
'"email": "noprompt@example.com"',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/GithubIssues/GithubIssues.node.ts`,
'export class GithubIssues implements INodeType',
);
// Check if credentials files exist
try {
const credentialsPath = `${tmpdir}/${projectName}/credentials`;
const credentialFiles = await fs.readdir(credentialsPath);
if (credentialFiles.length > 0) {
await expect(tmpdir).toHaveFileContaining(
`${projectName}/credentials/${credentialFiles[0]}`,
'implements ICredentialType',
);
}
} catch {
// Credentials directory doesn't exist, which is fine
}
},
);
tmpdirTest('creates new node project with OpenAI compatible chat model', async ({ tmpdir }) => {
MockPrompt.setup([
{
question: 'What kind of node are you building?',
answer: 'programmatic',
},
{
question: 'What type of programmatic node are you building?',
answer: 'chatModel',
},
{
question: 'What type of chat model?',
answer: 'openaiCompatible',
},
]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Chat User\n' },
{ command: 'git config --get user.email', result: 'chat@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
{
command: 'pnpm',
args: ['install'],
options: { exitCode: 0 },
},
]);
await CommandTester.run('new n8n-nodes-chat-openai --skip-install');
expect(MockPrompt).toHaveAskedAllQuestions();
expect(MockPrompt).toHaveAskedQuestion('What type of chat model?');
const projectName = 'n8n-nodes-chat-openai';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/ExampleChatModel/ExampleChatModel.node.ts`,
'export class ExampleChatModel implements INodeType',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/ExampleChatModel/ExampleChatModel.node.ts`,
'Chat model node for OpenAI API compatible providers',
);
});
tmpdirTest('creates new node project with custom chat model', async ({ tmpdir }) => {
MockPrompt.setup([
{
question: 'What kind of node are you building?',
answer: 'programmatic',
},
{
question: 'What type of programmatic node are you building?',
answer: 'chatModel',
},
{
question: 'What type of chat model?',
answer: 'custom',
},
]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Custom Chat User\n' },
{ command: 'git config --get user.email', result: 'customchat@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
]);
await CommandTester.run('new n8n-nodes-custom-chat --skip-install');
expect(MockPrompt).toHaveAskedAllQuestions();
const projectName = 'n8n-nodes-custom-chat';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/ExampleChatModel/ExampleChatModel.node.ts`,
'export class ExampleChatModel implements INodeType',
);
});
tmpdirTest('creates new node project with custom chat model example', async ({ tmpdir }) => {
MockPrompt.setup([
{
question: 'What kind of node are you building?',
answer: 'programmatic',
},
{
question: 'What type of programmatic node are you building?',
answer: 'chatModel',
},
{
question: 'What type of chat model?',
answer: 'customExample',
},
]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Example User\n' },
{ command: 'git config --get user.email', result: 'example@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
]);
await CommandTester.run('new n8n-nodes-chat-example --skip-install');
expect(MockPrompt).toHaveAskedAllQuestions();
const projectName = 'n8n-nodes-chat-example';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/ExampleChatModel/ExampleChatModel.node.ts`,
'export class ExampleChatModel implements INodeType',
);
});
tmpdirTest('creates new node project with chat memory', async ({ tmpdir }) => {
MockPrompt.setup([
{
question: 'What kind of node are you building?',
answer: 'programmatic',
},
{
question: 'What type of programmatic node are you building?',
answer: 'chatMemory',
},
]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Memory User\n' },
{ command: 'git config --get user.email', result: 'memory@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
]);
await CommandTester.run('new n8n-nodes-chat-memory --skip-install');
expect(MockPrompt).toHaveAskedAllQuestions();
expect(MockPrompt).toHaveAskedQuestion('What type of programmatic node are you building?');
const projectName = 'n8n-nodes-chat-memory';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/ExampleChatMemory/ExampleChatMemory.node.ts`,
'export class ExampleChatMemory implements INodeType',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/ExampleChatMemory/ExampleChatMemory.node.ts`,
'Store conversation history in memory',
);
});
tmpdirTest(
'creates new node project with --template programmatic/openai-chat-model',
async ({ tmpdir }) => {
MockPrompt.setup([]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Template User\n' },
{ command: 'git config --get user.email', result: 'template@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
]);
await CommandTester.run(
'new n8n-nodes-template-chat --template programmatic/openai-chat-model --skip-install',
);
expect(MockPrompt).toHaveAskedAllQuestions();
const projectName = 'n8n-nodes-template-chat';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/ExampleChatModel/ExampleChatModel.node.ts`,
'export class ExampleChatModel implements INodeType',
);
},
);
tmpdirTest(
'creates new node project with --template programmatic/custom-chat-memory',
async ({ tmpdir }) => {
MockPrompt.setup([]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Memory Template User\n' },
{ command: 'git config --get user.email', result: 'memory-tpl@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
]);
await CommandTester.run(
'new n8n-nodes-template-memory --template programmatic/custom-chat-memory --skip-install',
);
expect(MockPrompt).toHaveAskedAllQuestions();
const projectName = 'n8n-nodes-template-memory';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/ExampleChatMemory/ExampleChatMemory.node.ts`,
'export class ExampleChatMemory implements INodeType',
);
},
);
});
@@ -0,0 +1,191 @@
import { confirm, intro, isCancel, log, note, outro, spinner } from '@clack/prompts';
import { Args, Command, Flags } from '@oclif/core';
import { camelCase } from 'change-case';
import fs from 'node:fs/promises';
import path from 'node:path';
import {
chatModelTypePrompt,
declarativeTemplatePrompt,
nodeNamePrompt,
nodeTypePrompt,
programmaticNodeTypePrompt,
} from './prompts';
import { createIntro } from './utils';
import type { TemplateData, TemplateWithRun } from '../../template/core';
import { getTemplate, isTemplateName, isTemplateType, templates } from '../../template/templates';
import { ChildProcessError, runCommand } from '../../utils/child-process';
import { delayAtLeast, folderExists } from '../../utils/filesystem';
import { initGit, tryReadGitUser } from '../../utils/git';
import { detectPackageManager } from '../../utils/package-manager';
import { onCancel } from '../../utils/prompts';
import { validateNodeName } from '../../utils/validation';
export default class New extends Command {
static override description = 'Create a starter community node in a new directory';
static override examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> n8n-nodes-my-app --skip-install',
'<%= config.bin %> <%= command.id %> n8n-nodes-my-app --force',
'<%= config.bin %> <%= command.id %> n8n-nodes-my-app --template declarative/custom',
];
static override args = {
name: Args.string({ name: 'Name' }),
};
static override flags = {
force: Flags.boolean({
char: 'f',
description: 'Overwrite destination folder if it already exists',
}),
'skip-install': Flags.boolean({ description: 'Skip installing dependencies' }),
template: Flags.string({
options: [
'declarative/github-issues',
'declarative/custom',
'programmatic/example',
'programmatic/openai-chat-model',
'programmatic/custom-chat-model',
'programmatic/custom-chat-model-example',
'programmatic/custom-chat-memory',
] as const,
}),
};
async run(): Promise<void> {
const { flags, args } = await this.parse(New);
const [typeFlag, templateFlag] = flags.template?.split('/') ?? [];
intro(await createIntro());
const nodeName = args.name ?? (await nodeNamePrompt());
const invalidNodeNameError = validateNodeName(nodeName);
if (invalidNodeNameError) return onCancel(invalidNodeNameError);
const destination = path.resolve(process.cwd(), nodeName);
let overwrite = false;
if (await folderExists(destination)) {
if (!flags.force) {
const shouldOverwrite = await confirm({
message: `./${nodeName} already exists, do you want to overwrite?`,
});
if (isCancel(shouldOverwrite) || !shouldOverwrite) return onCancel();
}
overwrite = true;
}
const type = typeFlag ?? (await nodeTypePrompt());
if (!isTemplateType(type)) {
return onCancel(`Invalid template type: ${type}`);
}
let template: TemplateWithRun = templates.programmatic.example;
if (templateFlag) {
const name = camelCase(templateFlag);
if (!isTemplateName(type, name)) {
return onCancel(`Invalid template name: ${name} for type: ${type}`);
}
template = getTemplate(type, name);
} else if (type === 'declarative') {
const chosenTemplate = await declarativeTemplatePrompt();
template = getTemplate('declarative', chosenTemplate) as TemplateWithRun;
} else if (type === 'programmatic') {
const programmaticNodeType = await programmaticNodeTypePrompt();
if (programmaticNodeType === 'basic') {
template = templates.programmatic.example;
} else if (programmaticNodeType === 'chatModel') {
const chatModelType = await chatModelTypePrompt();
if (chatModelType === 'openaiCompatible') {
template = templates.programmatic.openaiChatModel as TemplateWithRun;
} else if (chatModelType === 'custom') {
template = templates.programmatic.customChatModel as TemplateWithRun;
} else if (chatModelType === 'customExample') {
template = templates.programmatic.customChatModelExample as TemplateWithRun;
}
} else if (programmaticNodeType === 'chatMemory') {
template = templates.programmatic.customChatMemory as TemplateWithRun;
}
}
const config = (await template.prompts?.()) ?? {};
const packageManager = (await detectPackageManager()) ?? 'npm';
const templateData: TemplateData = {
destinationPath: destination,
nodePackageName: nodeName,
config,
user: tryReadGitUser(),
packageManager: {
name: packageManager,
installCommand: packageManager === 'npm' ? 'ci' : 'install',
},
};
const copyingSpinner = spinner();
copyingSpinner.start('Copying files');
if (overwrite) {
await fs.rm(destination, { recursive: true, force: true });
}
await delayAtLeast(template.run(templateData), 1000);
copyingSpinner.stop('Files copied');
const gitSpinner = spinner();
gitSpinner.start('Initializing git repository');
try {
await initGit(destination);
gitSpinner.stop('Git repository initialized');
} catch (error: unknown) {
if (error instanceof ChildProcessError) {
gitSpinner.stop(
`Could not initialize git repository: ${error.message}`,
error.code ?? undefined,
);
process.exit(error.code ?? 1);
} else {
throw error;
}
}
if (!flags['skip-install']) {
const installingSpinner = spinner();
installingSpinner.start('Installing dependencies');
try {
await delayAtLeast(
runCommand(packageManager, ['install'], {
cwd: destination,
printOutput: ({ stdout, stderr }) => {
log.error(stdout.concat(stderr).toString());
},
}),
1000,
);
} catch (error: unknown) {
if (error instanceof ChildProcessError) {
installingSpinner.stop(
`Could not install dependencies: ${error.message}`,
error.code ?? undefined,
);
process.exit(error.code ?? 1);
} else {
throw error;
}
}
installingSpinner.stop('Dependencies installed');
}
note(
`cd ./${nodeName} && ${packageManager} run dev
📚 Documentation: https://docs.n8n.io/integrations/creating-nodes/build/${type}-style-node/
💬 Community: https://community.n8n.io`,
'Next Steps',
);
outro(`Created ./${nodeName}`);
}
}
@@ -0,0 +1,98 @@
import { select, text } from '@clack/prompts';
import { templates } from '../../template/templates';
import { withCancelHandler } from '../../utils/prompts';
import { validateNodeName } from '../../utils/validation';
export const nodeNamePrompt = async () =>
await withCancelHandler(
text({
message: "Package name (must start with 'n8n-nodes-' or '@org/n8n-nodes-')",
placeholder: 'n8n-nodes-my-app',
validate: validateNodeName,
defaultValue: 'n8n-nodes-my-app',
}),
);
export const nodeTypePrompt = async () =>
await withCancelHandler(
select<'declarative' | 'programmatic'>({
message: 'What kind of node are you building?',
options: [
{
label: 'HTTP API',
value: 'declarative',
hint: 'Low-code, faster approval for n8n Cloud',
},
{
label: 'Programmatic',
value: 'programmatic',
hint: 'Programmatic node with full flexibility',
},
],
initialValue: 'declarative',
}),
);
export const declarativeTemplatePrompt = async () =>
await withCancelHandler(
select<keyof typeof templates.declarative>({
message: 'What template do you want to use?',
options: Object.entries(templates.declarative).map(([value, template]) => ({
value: value as keyof typeof templates.declarative,
label: template.name,
hint: template.description,
})),
initialValue: 'githubIssues',
}),
);
export const programmaticNodeTypePrompt = async () =>
await withCancelHandler(
select<'basic' | 'chatModel' | 'chatMemory'>({
message: 'What type of programmatic node are you building?',
options: [
{
label: 'Basic',
value: 'basic',
hint: 'Standard programmatic node',
},
{
label: 'Chat Model (preview)',
value: 'chatModel',
hint: 'AI chat model node',
},
{
label: 'Chat Memory (preview)',
value: 'chatMemory',
hint: 'AI chat memory node',
},
],
initialValue: 'basic',
}),
);
export const chatModelTypePrompt = async () =>
await withCancelHandler(
select<'openaiCompatible' | 'custom' | 'customExample'>({
message: 'What type of chat model?',
options: [
{
label: 'OpenAI compatible',
value: 'openaiCompatible',
hint: 'Chat model for OpenAI-compatible providers',
},
{
label: 'Custom',
value: 'custom',
hint: 'Custom chat model implementation',
},
{
label: 'Custom Example',
value: 'customExample',
hint: 'OpenAI chat model example implementation',
},
],
initialValue: 'openaiCompatible',
}),
);
@@ -0,0 +1,9 @@
import { detectPackageManagerFromUserAgent } from '../../utils/package-manager';
import { getCommandHeader } from '../../utils/prompts';
export const createIntro = async () => {
const maybePackageManager = detectPackageManagerFromUserAgent();
const packageManager = maybePackageManager ?? 'npm';
const commandName = maybePackageManager ? `${packageManager} create @n8n/node` : 'n8n-node new';
return await getCommandHeader(commandName);
};
@@ -0,0 +1,35 @@
import { CommandTester } from '../test-utils/command-tester';
describe('prerelease command', () => {
const originalEnv = process.env;
const mockProcessStdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
delete process.env.RELEASE_MODE;
});
afterEach(() => {
process.env = originalEnv;
});
test('without RELEASE_MODE - exits with error and shows message', async () => {
await expect(CommandTester.run('prerelease')).rejects.toThrow('EEXIT: 1');
const stdoutCalls = mockProcessStdout.mock.calls.flat();
const hasReleaseMessage = stdoutCalls.some(
(call) => typeof call === 'string' && call.includes('run release` to publish the package'),
);
expect(hasReleaseMessage).toBe(true);
});
test('with RELEASE_MODE - succeeds without logging', async () => {
process.env.RELEASE_MODE = 'true';
const result = await CommandTester.run('prerelease');
expect(result).toBeDefined();
expect(mockProcessStdout).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,22 @@
import { Command } from '@oclif/core';
import { detectPackageManager } from '../utils/package-manager';
export default class Prerelease extends Command {
static override description =
'Only for internal use. Prevent npm publish, instead require npm run release';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {};
static override hidden = true;
async run(): Promise<void> {
await this.parse(Prerelease);
const packageManager = (await detectPackageManager()) ?? 'npm';
if (!process.env.RELEASE_MODE) {
this.log(`Run \`${packageManager} run release\` to publish the package`);
process.exit(1);
}
}
}
@@ -0,0 +1,83 @@
import fs from 'node:fs/promises';
import { CommandTester } from '../test-utils/command-tester';
import { mockSpawn } from '../test-utils/mock-child-process';
import { tmpdirTest } from '../test-utils/temp-fs';
describe('release command', () => {
const originalEnv = process.env;
const releaseItArgs = [
'exec',
'--',
'release-it',
'-n',
'--git.requireBranch main',
'--git.requireCleanWorkingDir',
'--git.requireUpstream',
'--git.requireCommits',
'--git.commit',
'--git.tag',
'--git.push',
'--git.changelog="npx auto-changelog --stdout --unreleased --commit-limit false -u --hide-credit"',
'--github.release',
];
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
delete process.env.npm_config_user_agent;
});
afterEach(() => {
process.env = originalEnv;
});
tmpdirTest('successful release - runs release-it with correct arguments', async ({ tmpdir }) => {
await fs.writeFile(
`${tmpdir}/package.json`,
JSON.stringify({
name: 'test-node',
version: '1.0.0',
n8n: {
nodes: ['dist/nodes/TestNode.node.js'],
},
}),
);
await fs.writeFile(`${tmpdir}/pnpm-lock.yaml`, '# pnpm lock file');
mockSpawn(
'pnpm',
[
...releaseItArgs,
'--hooks.before:init="pnpm run lint && pnpm run build"',
'--hooks.after:bump="npx auto-changelog -p"',
],
{ exitCode: 0 },
);
const result = await CommandTester.run('release');
expect(result).toBeDefined();
});
tmpdirTest('release-it failure - exits with error code', async ({ tmpdir }) => {
await fs.writeFile(
`${tmpdir}/package.json`,
JSON.stringify({
name: 'test-node',
version: '1.0.0',
n8n: {
nodes: ['dist/nodes/TestNode.node.js'],
},
}),
);
mockSpawn('npm', expect.any(Array) as string[], {
exitCode: 1,
stderr: 'Release failed: Git working directory is not clean',
});
await expect(CommandTester.run('release')).rejects.toThrow('EEXIT: 1');
});
});
@@ -0,0 +1,56 @@
import { intro } from '@clack/prompts';
import { Command } from '@oclif/core';
import { ChildProcessError, runCommand } from '../utils/child-process';
import { detectPackageManager } from '../utils/package-manager';
import { getCommandHeader } from '../utils/prompts';
export default class Release extends Command {
static override description = 'Publish your community node package to npm';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {};
async run(): Promise<void> {
await this.parse(Release);
intro(await getCommandHeader('n8n-node release'));
const pm = (await detectPackageManager()) ?? 'npm';
try {
await runCommand(
'release-it',
[
'-n',
'--git.requireBranch main',
'--git.requireCleanWorkingDir',
'--git.requireUpstream',
'--git.requireCommits',
'--git.commit',
'--git.tag',
'--git.push',
'--git.changelog="npx auto-changelog --stdout --unreleased --commit-limit false -u --hide-credit"',
'--github.release',
`--hooks.before:init="${pm} run lint && ${pm} run build"`,
'--hooks.after:bump="npx auto-changelog -p"',
],
{
stdio: 'inherit',
context: 'local',
env: {
RELEASE_MODE: 'true',
},
},
);
} catch (error) {
if (error instanceof ChildProcessError) {
if (error.signal) {
process.kill(process.pid, error.signal);
} else {
process.exit(error.code ?? 0);
}
}
throw error;
}
}
}
@@ -0,0 +1,71 @@
import eslint from '@eslint/js';
import { n8nCommunityNodesPlugin } from '@n8n/eslint-plugin-community-nodes';
import { globalIgnores } from 'eslint/config';
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript';
import importPlugin from 'eslint-plugin-import-x';
import n8nNodesPlugin from 'eslint-plugin-n8n-nodes-base';
import tseslint, { type ConfigArray } from 'typescript-eslint';
function createConfig(supportCloud = true): ConfigArray {
return tseslint.config(
globalIgnores(['dist']),
{
files: ['**/*.ts'],
extends: [
eslint.configs.recommended,
tseslint.configs.recommended,
supportCloud
? n8nCommunityNodesPlugin.configs.recommended
: n8nCommunityNodesPlugin.configs.recommendedWithoutN8nCloudSupport,
importPlugin.configs['flat/recommended'],
],
rules: {
'prefer-spread': 'off',
'no-console': 'error',
},
},
{
plugins: { 'n8n-nodes-base': n8nNodesPlugin },
settings: {
'import-x/resolver-next': [createTypeScriptImportResolver()],
},
},
{
files: ['package.json'],
rules: {
...n8nNodesPlugin.configs.community.rules,
},
languageOptions: {
parser: tseslint.parser,
parserOptions: {
extraFileExtensions: ['.json'],
},
},
},
{
files: ['./credentials/**/*.ts'],
rules: {
...n8nNodesPlugin.configs.credentials.rules,
// Not valid for community nodes
'n8n-nodes-base/cred-class-field-documentation-url-miscased': 'off',
// @n8n/eslint-plugin-community-nodes credential-password-field rule is more accurate
'n8n-nodes-base/cred-class-field-type-options-password-missing': 'off',
},
},
{
files: ['./nodes/**/*.ts'],
rules: {
...n8nNodesPlugin.configs.nodes.rules,
// Inputs and outputs can be enum instead of string "main"
'n8n-nodes-base/node-class-description-inputs-wrong-regular-node': 'off',
'n8n-nodes-base/node-class-description-outputs-wrong': 'off',
// Sometimes the 3rd party API does have a maximum limit, so maxValue is valid
'n8n-nodes-base/node-param-type-options-max-value-present': 'off',
},
},
);
}
export const config = createConfig();
export const configWithoutCloudSupport = createConfig(false);
export default config;
+18
View File
@@ -0,0 +1,18 @@
import Build from './commands/build';
import CloudSupport from './commands/cloud-support';
import Dev from './commands/dev';
import Lint from './commands/lint';
import New from './commands/new';
import Prerelease from './commands/prerelease';
import Release from './commands/release';
export const commands = {
new: New,
build: Build,
dev: Dev,
prerelease: Prerelease,
release: Release,
lint: Lint,
// eslint-disable-next-line @typescript-eslint/naming-convention
'cloud-support': CloudSupport,
};
+19
View File
@@ -0,0 +1,19 @@
declare module 'eslint-plugin-n8n-nodes-base' {
import type { ESLint } from 'eslint';
const plugin: ESLint.Plugin & {
configs: {
community: {
rules: Record<string, Linter.RuleEntry>;
};
credentials: {
rules: Record<string, Linter.RuleEntry>;
};
nodes: {
rules: Record<string, Linter.RuleEntry>;
};
};
};
export default plugin;
}
@@ -0,0 +1,111 @@
import * as glob from 'fast-glob';
import handlebars from 'handlebars';
import * as fs from 'node:fs/promises';
import {
copyTemplateFilesToDestination,
templateStaticFiles,
createTemplate,
type TemplateData,
} from './core';
import { copyFolder } from '../utils/filesystem';
vi.mock('node:fs/promises');
vi.mock('fast-glob');
vi.mock('handlebars');
vi.mock('../utils/filesystem');
const mockFs = vi.mocked(fs);
const mockGlob = vi.mocked(glob);
const mockHandlebars = vi.mocked(handlebars);
const mockCopyFolder = vi.mocked(copyFolder);
const baseData: TemplateData = {
destinationPath: '/dest',
nodePackageName: 'MyNode',
packageManager: {
name: 'npm',
installCommand: 'npm ci',
},
config: {},
user: {
name: 'Alice',
email: 'alice@example.com',
},
};
describe('Templates > core', () => {
describe('copyTemplateFilesToDestination', () => {
it('copies template folder with ignore rules', async () => {
const template = {
path: '/template',
name: 'MyTemplate',
description: 'desc',
};
await copyTemplateFilesToDestination(template, baseData);
expect(mockCopyFolder).toHaveBeenCalledWith({
source: '/template',
destination: '/dest',
ignore: ['dist', 'node_modules'],
});
});
});
describe('templateStaticFiles', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders and writes changed content', async () => {
mockGlob.default.mockResolvedValue(['/dest/file.md']);
mockFs.readFile.mockResolvedValue('Hello {{nodePackageName}}');
mockHandlebars.compile.mockReturnValue(() => 'Hello MyNode');
mockFs.writeFile.mockResolvedValue();
await templateStaticFiles(baseData);
expect(mockFs.readFile).toHaveBeenCalledWith('/dest/file.md', 'utf-8');
expect(mockFs.writeFile).toHaveBeenCalledWith('/dest/file.md', 'Hello MyNode');
});
it('skips writing if content unchanged', async () => {
mockGlob.default.mockResolvedValue(['/dest/file.md']);
mockFs.readFile.mockResolvedValue('Hello MyNode');
mockHandlebars.compile.mockReturnValue(() => 'Hello MyNode');
await templateStaticFiles(baseData);
expect(mockFs.writeFile).not.toHaveBeenCalled();
});
});
describe('createTemplate', () => {
it('adds run function that invokes sub-steps and original run', async () => {
const originalRun = vi.fn().mockResolvedValue(undefined);
const template = {
name: 'MyTemplate',
description: '',
path: '/template',
run: originalRun,
};
mockCopyFolder.mockResolvedValue();
mockGlob.default.mockResolvedValue([]);
mockFs.readFile.mockResolvedValue('');
mockHandlebars.compile.mockReturnValue(() => '');
mockFs.writeFile.mockResolvedValue();
const wrapped = createTemplate(template);
await wrapped.run(baseData);
expect(mockCopyFolder).toHaveBeenCalledWith({
source: '/template',
destination: '/dest',
ignore: ['dist', 'node_modules'],
});
expect(originalRun).toHaveBeenCalledWith(baseData);
});
});
});
@@ -0,0 +1,84 @@
import glob from 'fast-glob';
import handlebars from 'handlebars';
import fs from 'node:fs/promises';
import path from 'node:path';
import { copyFolder } from '../utils/filesystem';
export type TemplateData<Config extends object = object> = {
destinationPath: string;
nodePackageName: string;
user?: Partial<{
name: string;
email: string;
}>;
packageManager: {
name: 'npm' | 'yarn' | 'pnpm';
installCommand: string;
};
config: Config;
};
type Require<T, K extends keyof T> = T & { [P in K]-?: T[P] };
export type Template<Config extends object = object> = {
name: string;
description: string;
path: string;
prompts?: () => Promise<Config>;
run?: (data: TemplateData<Config>) => Promise<void>;
};
export type TemplateWithRun<Config extends object = object> = Require<Template<Config>, 'run'>;
export async function copyTemplateFilesToDestination<Config extends object>(
template: Template<Config>,
data: TemplateData,
) {
await copyFolder({
source: template.path,
destination: data.destinationPath,
ignore: ['dist', 'node_modules'],
});
}
export async function copyDefaultTemplateFilesToDestination(data: TemplateData) {
await copyFolder({
source: path.resolve(__dirname, 'templates/shared/default'),
destination: data.destinationPath,
ignore: ['dist', 'node_modules'],
});
}
export async function templateStaticFiles(data: TemplateData) {
const files = await glob('**/*.{md,json,yml}', {
ignore: ['tsconfig.json', 'tsconfig.build.json'],
cwd: data.destinationPath,
absolute: true,
dot: true,
});
await Promise.all(
files.map(async (file) => {
const content = await fs.readFile(file, 'utf-8');
const newContent = handlebars.compile(content, { noEscape: true })(data);
if (newContent !== content) {
await fs.writeFile(file, newContent);
}
}),
);
}
export function createTemplate<Config extends object>(
template: Template<Config>,
): TemplateWithRun<Config> {
return {
...template,
run: async (data) => {
await copyDefaultTemplateFilesToDestination(data);
await copyTemplateFilesToDestination(template, data);
await templateStaticFiles(data);
await template.run?.(data);
},
};
}
@@ -0,0 +1,161 @@
import { camelCase, capitalCase } from 'change-case';
import { ts, SyntaxKind, printNode } from 'ts-morph';
import {
getChildObjectLiteral,
loadSingleSourceFile,
updateStringProperty,
} from '../../../../utils/ast';
export function updateNodeAst({
nodePath,
className,
baseUrl,
}: { nodePath: string; className: string; baseUrl: string }) {
const sourceFile = loadSingleSourceFile(nodePath);
const classDecl = sourceFile.getClasses()[0];
classDecl.rename(className);
const nodeDescriptionObj = classDecl
.getPropertyOrThrow('description')
.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
updateStringProperty({
obj: nodeDescriptionObj,
key: 'displayName',
value: capitalCase(className),
});
updateStringProperty({
obj: nodeDescriptionObj,
key: 'name',
value: camelCase(className),
});
updateStringProperty({
obj: nodeDescriptionObj,
key: 'description',
value: `Interact with the ${capitalCase(className)} API`,
});
const icon = getChildObjectLiteral({ obj: nodeDescriptionObj, key: 'icon' });
updateStringProperty({
obj: icon,
key: 'light',
value: `file:${camelCase(className)}.svg`,
});
updateStringProperty({
obj: icon,
key: 'dark',
value: `file:${camelCase(className)}.dark.svg`,
});
const requestDefaults = getChildObjectLiteral({
obj: nodeDescriptionObj,
key: 'requestDefaults',
});
updateStringProperty({
obj: requestDefaults,
key: 'baseURL',
value: baseUrl,
});
const defaults = getChildObjectLiteral({
obj: nodeDescriptionObj,
key: 'defaults',
});
updateStringProperty({ obj: defaults, key: 'name', value: capitalCase(className) });
return sourceFile;
}
export function updateCredentialAst({
repoName,
baseUrl,
credentialPath,
credentialName,
credentialDisplayName,
credentialClassName,
}: {
repoName: string;
credentialPath: string;
credentialName: string;
credentialDisplayName: string;
credentialClassName: string;
baseUrl: string;
}) {
const sourceFile = loadSingleSourceFile(credentialPath);
const classDecl = sourceFile.getClasses()[0];
classDecl.rename(credentialClassName);
updateStringProperty({
obj: classDecl,
key: 'displayName',
value: credentialDisplayName,
});
updateStringProperty({
obj: classDecl,
key: 'name',
value: credentialName,
});
const docUrlProp = classDecl.getProperty('documentationUrl');
if (docUrlProp) {
const initializer = docUrlProp.getInitializerIfKindOrThrow(SyntaxKind.StringLiteral);
const newUrl = initializer.getLiteralText().replace('/repo', `/${repoName}`);
initializer.setLiteralValue(newUrl);
}
const testProperty = classDecl.getProperty('test');
if (testProperty) {
const testRequest = testProperty
.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression)
.getPropertyOrThrow('request')
.asKindOrThrow(SyntaxKind.PropertyAssignment)
.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
updateStringProperty({
obj: testRequest,
key: 'baseURL',
value: baseUrl,
});
}
return sourceFile;
}
export function addCredentialToNode({
nodePath,
credentialName,
}: { nodePath: string; credentialName: string }) {
const sourceFile = loadSingleSourceFile(nodePath);
const classDecl = sourceFile.getClasses()[0];
const descriptionProp = classDecl
.getPropertyOrThrow('description')
.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
const credentialsProp = descriptionProp.getPropertyOrThrow('credentials');
if (credentialsProp.getKind() === SyntaxKind.PropertyAssignment) {
const initializer = credentialsProp.getFirstDescendantByKindOrThrow(
SyntaxKind.ArrayLiteralExpression,
);
const credentialObject = ts.factory.createObjectLiteralExpression([
ts.factory.createPropertyAssignment(
ts.factory.createIdentifier('name'),
ts.factory.createStringLiteral(credentialName, true),
),
ts.factory.createPropertyAssignment(
ts.factory.createIdentifier('required'),
ts.factory.createTrue(),
),
]);
initializer.addElement(printNode(credentialObject));
}
return sourceFile;
}
@@ -0,0 +1,87 @@
import { select, text } from '@clack/prompts';
import type { CredentialType } from './types';
import { withCancelHandler } from '../../../../utils/prompts';
export const credentialTypePrompt = async () =>
await withCancelHandler(
select<CredentialType>({
message: 'What type of authentication does your API use?',
options: [
{
label: 'API Key',
value: 'apiKey',
hint: 'Send a secret key via headers, query, or body',
},
{
label: 'Bearer Token',
value: 'bearer',
hint: 'Send a token via Authorization header (Authorization: Bearer <token>)',
},
{
label: 'OAuth2',
value: 'oauth2',
hint: 'Use an OAuth 2.0 flow to obtain access tokens on behalf of a user or app',
},
{
label: 'Basic Auth',
value: 'basicAuth',
hint: 'Send username and password encoded in base64 via the Authorization header',
},
{
label: 'Custom',
value: 'custom',
hint: 'Create your own credential logic; an empty credential class will be scaffolded for you',
},
{
label: 'None',
value: 'none',
hint: 'No authentication; no credential class will be generated',
},
],
initialValue: 'apiKey',
}),
);
export const baseUrlPrompt = async () =>
await withCancelHandler(
text({
message: "What's the base URL of the API?",
placeholder: 'https://api.example.com/v2',
defaultValue: 'https://api.example.com/v2',
validate: (value) => {
if (!value) return;
if (!value.startsWith('https://') && !value.startsWith('http://')) {
return 'Base URL must start with http(s)://';
}
try {
new URL(value);
} catch (error) {
return 'Must be a valid URL';
}
return;
},
}),
);
export const oauthFlowPrompt = async () =>
await withCancelHandler(
select<'clientCredentials' | 'authorizationCode'>({
message: 'What OAuth2 flow does your API use?',
options: [
{
label: 'Authorization code',
value: 'authorizationCode',
hint: 'Users log in and approve access (use this if unsure)',
},
{
label: 'Client credentials',
value: 'clientCredentials',
hint: 'Server-to-server auth without user interaction',
},
],
initialValue: 'authorizationCode',
}),
);
@@ -0,0 +1,121 @@
import { camelCase, capitalCase, pascalCase } from 'change-case';
import path from 'node:path';
import { addCredentialToNode, updateCredentialAst, updateNodeAst } from './ast';
import { baseUrlPrompt, credentialTypePrompt, oauthFlowPrompt } from './prompts';
import type { CustomTemplateConfig } from './types';
import {
renameDirectory,
renameFilesInDirectory,
writeFileSafe,
} from '../../../../utils/filesystem';
import {
setNodesPackageJson,
addCredentialPackageJson,
getPackageJsonNodes,
} from '../../../../utils/package';
import { createTemplate, type TemplateData } from '../../../core';
export const customTemplate = createTemplate({
name: 'Start from scratch',
description: 'Blank template with guided setup',
path: path.join(__dirname, 'template'),
prompts: async (): Promise<CustomTemplateConfig> => {
const baseUrl = await baseUrlPrompt();
const credentialType = await credentialTypePrompt();
if (credentialType === 'oauth2') {
const flow = await oauthFlowPrompt();
return { credentialType, baseUrl, flow };
}
return { credentialType, baseUrl };
},
run: async (data) => {
await renameNode(data, 'Example');
await addCredential(data);
},
});
async function renameNode(data: TemplateData<CustomTemplateConfig>, oldNodeName: string) {
const { config, nodePackageName: nodeName, destinationPath } = data;
const newClassName = pascalCase(nodeName.replace('n8n-nodes-', ''));
const oldNodeDir = path.resolve(destinationPath, `nodes/${oldNodeName}`);
await renameFilesInDirectory(oldNodeDir, oldNodeName, newClassName);
const newNodeDir = await renameDirectory(oldNodeDir, newClassName);
const newNodePath = path.resolve(newNodeDir, `${newClassName}.node.ts`);
const newNodeAst = updateNodeAst({
nodePath: newNodePath,
baseUrl: config.baseUrl,
className: newClassName,
});
await writeFileSafe(newNodePath, newNodeAst.getFullText());
const nodes = [`dist/nodes/${newClassName}/${newClassName}.node.js`];
await setNodesPackageJson(destinationPath, nodes);
}
async function addCredential(data: TemplateData<CustomTemplateConfig>) {
const { config, destinationPath, nodePackageName } = data;
if (config.credentialType === 'none') return;
const credentialTemplateName =
config.credentialType === 'oauth2'
? config.credentialType + pascalCase(config.flow)
: config.credentialType;
const credentialTemplatePath = path.resolve(
__dirname,
`../../shared/credentials/${credentialTemplateName}.credentials.ts`,
);
const nodeName = nodePackageName.replace('n8n-nodes', '');
const repoName = nodeName;
const { baseUrl, credentialType } = config;
const credentialClassName =
config.credentialType === 'oauth2'
? pascalCase(`${nodeName}-OAuth2-api`)
: pascalCase(`${nodeName}-api`);
const credentialName = camelCase(
`${nodeName}${credentialType === 'oauth2' ? 'OAuth2Api' : 'Api'}`,
);
const credentialDisplayName = `${capitalCase(nodeName)} ${
credentialType === 'oauth2' ? 'OAuth2 API' : 'API'
}`;
const updatedCredentialAst = updateCredentialAst({
repoName,
baseUrl,
credentialName,
credentialDisplayName,
credentialClassName,
credentialPath: credentialTemplatePath,
});
await writeFileSafe(
path.resolve(destinationPath, `credentials/${credentialClassName}.credentials.ts`),
updatedCredentialAst.getFullText(),
);
await addCredentialPackageJson(
destinationPath,
`dist/credentials/${credentialClassName}.credentials.js`,
);
for (const nodePath of await getPackageJsonNodes(destinationPath)) {
const srcNodePath = path.resolve(
destinationPath,
nodePath.replace(/.js$/, '.ts').replace(/^dist\//, ''),
);
const updatedNodeAst = addCredentialToNode({
nodePath: srcNodePath,
credentialName,
});
await writeFileSafe(srcNodePath, updatedNodeAst.getFullText());
}
}
@@ -0,0 +1,46 @@
# {{nodePackageName}}
This is an n8n community node. It lets you use _app/service name_ in your n8n workflows.
_App/service name_ is _one or two sentences describing the service this node integrates with_.
[n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/sustainable-use-license/) workflow automation platform.
[Installation](#installation)
[Operations](#operations)
[Credentials](#credentials)
[Compatibility](#compatibility)
[Usage](#usage)
[Resources](#resources)
[Version history](#version-history)
## Installation
Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community nodes documentation.
## Operations
_List the operations supported by your node._
## Credentials
_If users need to authenticate with the app/service, provide details here. You should include prerequisites (such as signing up with the service), available authentication methods, and how to set them up._
## Compatibility
_State the minimum n8n version, as well as which versions you test against. You can also include any known version incompatibility issues._
## Usage
_This is an optional section. Use it to help users with any difficult or confusing aspects of the node._
_By the time users are looking for community nodes, they probably already know n8n basics. But if you expect new users, you can link to the [Try it out](https://docs.n8n.io/try-it-out/) documentation to help them get started._
## Resources
* [n8n community nodes documentation](https://docs.n8n.io/integrations/#community-nodes)
* _Link to app/service documentation._
## Version history
_This is another optional section. If your node has multiple versions, include a short description of available versions and what changed, as well as any compatibility impact._
@@ -0,0 +1,18 @@
{
"node": "{{nodePackageName}}",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Development", "Developer Tools"],
"resources": {
"credentialDocumentation": [
{
"url": "https://github.com/org/repo?tab=readme-ov-file#credentials"
}
],
"primaryDocumentation": [
{
"url": "https://github.com/org/repo?tab=readme-ov-file"
}
]
}
}
@@ -0,0 +1,50 @@
import { NodeConnectionTypes, type INodeType, type INodeTypeDescription } from 'n8n-workflow';
import { userDescription } from './resources/user';
import { companyDescription } from './resources/company';
export class Example implements INodeType {
description: INodeTypeDescription = {
displayName: 'Example',
name: 'example',
icon: { light: 'file:example.svg', dark: 'file:example.dark.svg' },
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Interact with the Example API',
defaults: {
name: 'Example',
},
usableAsTool: true,
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [],
requestDefaults: {
baseURL: 'https://api.example.com',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'User',
value: 'user',
},
{
name: 'Company',
value: 'company',
},
],
default: 'user',
},
...userDescription,
...companyDescription,
],
};
}
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="aquamarine"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-cpu">
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
<rect x="9" y="9" width="6" height="6"></rect>
<line x1="9" y1="1" x2="9" y2="4"></line>
<line x1="15" y1="1" x2="15" y2="4"></line>
<line x1="9" y1="20" x2="9" y2="23"></line>
<line x1="15" y1="20" x2="15" y2="23"></line>
<line x1="20" y1="9" x2="23" y2="9"></line>
<line x1="20" y1="14" x2="23" y2="14"></line>
<line x1="1" y1="9" x2="4" y2="9"></line>
<line x1="1" y1="14" x2="4" y2="14"></line>
</svg>

After

Width:  |  Height:  |  Size: 698 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="darkblue"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-cpu">
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
<rect x="9" y="9" width="6" height="6"></rect>
<line x1="9" y1="1" x2="9" y2="4"></line>
<line x1="15" y1="1" x2="15" y2="4"></line>
<line x1="9" y1="20" x2="9" y2="23"></line>
<line x1="15" y1="20" x2="15" y2="23"></line>
<line x1="20" y1="9" x2="23" y2="9"></line>
<line x1="20" y1="14" x2="23" y2="14"></line>
<line x1="1" y1="9" x2="4" y2="9"></line>
<line x1="1" y1="14" x2="4" y2="14"></line>
</svg>

After

Width:  |  Height:  |  Size: 696 B

@@ -0,0 +1,61 @@
import type { INodeProperties } from 'n8n-workflow';
const showOnlyForCompanyGetMany = {
operation: ['getAll'],
resource: ['company'],
};
export const companyGetManyDescription: INodeProperties[] = [
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
...showOnlyForCompanyGetMany,
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 100,
},
default: 50,
routing: {
send: {
type: 'query',
property: 'limit',
},
output: {
maxResults: '={{$value}}',
},
},
description: 'Max number of results to return',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: showOnlyForCompanyGetMany,
},
default: false,
description: 'Whether to return all results or only up to a given limit',
routing: {
send: {
paginate: '={{ $value }}',
},
operations: {
pagination: {
type: 'offset',
properties: {
limitParameter: 'limit',
offsetParameter: 'offset',
pageSize: 100,
type: 'query',
},
},
},
},
},
];
@@ -0,0 +1,34 @@
import type { INodeProperties } from 'n8n-workflow';
import { companyGetManyDescription } from './getAll';
const showOnlyForCompanies = {
resource: ['company'],
};
export const companyDescription: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: showOnlyForCompanies,
},
options: [
{
name: 'Get Many',
value: 'getAll',
action: 'Get companies',
description: 'Get companies',
routing: {
request: {
method: 'GET',
url: '/companies',
},
},
},
],
default: 'getAll',
},
...companyGetManyDescription,
];
@@ -0,0 +1,26 @@
import type { INodeProperties } from 'n8n-workflow';
const showOnlyForUserCreate = {
operation: ['create'],
resource: ['user'],
};
export const userCreateDescription: INodeProperties[] = [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
required: true,
displayOptions: {
show: showOnlyForUserCreate,
},
description: 'The name of the user',
routing: {
send: {
type: 'body',
property: 'name',
},
},
},
];
@@ -0,0 +1,17 @@
import type { INodeProperties } from 'n8n-workflow';
const showOnlyForUserGet = {
operation: ['get'],
resource: ['user'],
};
export const userGetDescription: INodeProperties[] = [
{
displayName: 'User ID',
name: 'userId',
type: 'string',
displayOptions: { show: showOnlyForUserGet },
default: '',
description: "The user's ID to retrieve",
},
];
@@ -0,0 +1,60 @@
import type { INodeProperties } from 'n8n-workflow';
import { userCreateDescription } from './create';
import { userGetDescription } from './get';
const showOnlyForUsers = {
resource: ['user'],
};
export const userDescription: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: showOnlyForUsers,
},
options: [
{
name: 'Get Many',
value: 'getAll',
action: 'Get users',
description: 'Get many users',
routing: {
request: {
method: 'GET',
url: '/users',
},
},
},
{
name: 'Get',
value: 'get',
action: 'Get a user',
description: 'Get the data of a single user',
routing: {
request: {
method: 'GET',
url: '=/users/{{$parameter.userId}}',
},
},
},
{
name: 'Create',
value: 'create',
action: 'Create a new user',
description: 'Create a new user',
routing: {
request: {
method: 'POST',
url: '/users',
},
},
},
],
default: 'getAll',
},
...userGetDescription,
...userCreateDescription,
];
@@ -0,0 +1,48 @@
{
"name": "{{nodePackageName}}",
"version": "0.1.0",
"description": "",
"license": "MIT",
"homepage": "",
"keywords": [
"n8n-community-node-package"
],
"author": {
"name": "{{user.name}}",
"email": "{{user.email}}"
},
"repository": {
"type": "git",
"url": "https://github.com/<...>/n8n-nodes-<...>.git"
},
"scripts": {
"build": "n8n-node build",
"build:watch": "tsc --watch",
"dev": "n8n-node dev",
"lint": "n8n-node lint",
"lint:fix": "n8n-node lint --fix",
"release": "n8n-node release",
"prepublishOnly": "n8n-node prerelease"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"strict": true,
"credentials": [],
"nodes": [
"dist/nodes/Example/Example.node.js"
]
},
"devDependencies": {
"@n8n/node-cli": "*",
"eslint": "9.32.0",
"prettier": "3.6.2",
"release-it": "^19.0.4",
"typescript": "5.9.2"
},
"peerDependencies": {
"n8n-workflow": "*"
}
}
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"strict": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2019",
"lib": ["es2019", "es2020", "es2022.error"],
"removeComments": true,
"useUnknownInCatchVariables": false,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"preserveConstEnums": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"incremental": true,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true,
"outDir": "./dist/"
},
"include": ["credentials/**/*", "nodes/**/*", "nodes/**/*.json", "package.json"]
}
@@ -0,0 +1,8 @@
export type CustomTemplateConfig =
| {
credentialType: 'apiKey' | 'bearer' | 'basicAuth' | 'custom' | 'none';
baseUrl: string;
}
| { credentialType: 'oauth2'; baseUrl: string; flow: string };
export type CredentialType = CustomTemplateConfig['credentialType'];
@@ -0,0 +1,9 @@
import path from 'node:path';
import { createTemplate } from '../../../core';
export const githubIssuesTemplate = createTemplate({
name: 'GitHub Issues API',
description: 'Demo node with multiple operations and credentials',
path: path.join(__dirname, 'template'),
});
@@ -0,0 +1,73 @@
# {{nodePackageName}}
This is an n8n community node. It lets you use GitHub Issues in your n8n workflows.
[n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/sustainable-use-license/) workflow automation platform.
[Installation](#installation)
[Operations](#operations)
[Credentials](#credentials)
[Compatibility](#compatibility)
[Usage](#usage)
[Resources](#resources)
## Installation
Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community nodes documentation.
## Operations
- Issues
- Get an issue
- Get many issues in a repository
- Create a new issue
- Issue Comments
- Get many issue comments
## Credentials
You can use either access token or OAuth2 to use this node.
### Access token
1. Open your GitHub profile [Settings](https://github.com/settings/profile).
2. In the left navigation, select [Developer settings](https://github.com/settings/apps).
3. In the left navigation, under Personal access tokens, select Tokens (classic).
4. Select Generate new token > Generate new token (classic).
5. Enter a descriptive name for your token in the Note field, like n8n integration.
6. Select the Expiration you'd like for the token, or select No expiration.
7. Select Scopes for your token. For most of the n8n GitHub nodes, add the `repo` scope.
- A token without assigned scopes can only access public information.
8. Select Generate token.
9. Copy the token.
Refer to [Creating a personal access token (classic)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) for more information. Refer to Scopes for OAuth apps for more information on GitHub scopes.
![Generated Access token in GitHub](https://docs.github.com/assets/cb-17251/mw-1440/images/help/settings/personal-access-tokens.webp)
### OAuth2
If you're self-hosting n8n, create a new GitHub [OAuth app](https://docs.github.com/en/apps/oauth-apps):
1. Open your GitHub profile [Settings](https://github.com/settings/profile).
2. In the left navigation, select [Developer settings](https://github.com/settings/apps).
3. In the left navigation, select OAuth apps.
4. Select New OAuth App.
- If you haven't created an app before, you may see Register a new application instead. Select it.
5. Enter an Application name, like n8n integration.
6. Enter the Homepage URL for your app's website.
7. If you'd like, add the optional Application description, which GitHub displays to end-users.
8. From n8n, copy the OAuth Redirect URL and paste it into the GitHub Authorization callback URL.
9. Select Register application.
10. Copy the Client ID and Client Secret this generates and add them to your n8n credential.
Refer to the [GitHub Authorizing OAuth apps documentation](https://docs.github.com/en/apps/oauth-apps/using-oauth-apps/authorizing-oauth-apps) for more information on the authorization process.
## Compatibility
Compatible with n8n@1.60.0 or later
## Resources
* [n8n community nodes documentation](https://docs.n8n.io/integrations/#community-nodes)
* [GitHub API docs](https://docs.github.com/en/rest/issues)
@@ -0,0 +1,45 @@
import type {
IAuthenticateGeneric,
Icon,
ICredentialTestRequest,
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class GithubIssuesApi implements ICredentialType {
name = 'githubIssuesApi';
displayName = 'GitHub Issues API';
icon: Icon = { light: 'file:../icons/github.svg', dark: 'file:../icons/github.dark.svg' };
documentationUrl =
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#deleting-a-personal-access-token';
properties: INodeProperties[] = [
{
displayName: 'Access Token',
name: 'accessToken',
type: 'string',
typeOptions: { password: true },
default: '',
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
Authorization: '=token {{$credentials?.accessToken}}',
},
},
};
test: ICredentialTestRequest = {
request: {
baseURL: 'https://api.github.com',
url: '/user',
method: 'GET',
},
};
}
@@ -0,0 +1,54 @@
import type { Icon, ICredentialType, INodeProperties } from 'n8n-workflow';
export class GithubIssuesOAuth2Api implements ICredentialType {
name = 'githubIssuesOAuth2Api';
extends = ['oAuth2Api'];
displayName = 'GitHub Issues OAuth2 API';
icon: Icon = { light: 'file:../icons/github.svg', dark: 'file:../icons/github.dark.svg' };
documentationUrl = 'https://docs.github.com/en/apps/oauth-apps';
properties: INodeProperties[] = [
{
displayName: 'Grant Type',
name: 'grantType',
type: 'hidden',
default: 'authorizationCode',
},
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden',
default: 'https://github.com/login/oauth/authorize',
required: true,
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden',
default: 'https://github.com/login/oauth/access_token',
required: true,
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden',
default: 'repo',
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden',
default: '',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden',
default: 'header',
},
];
}
@@ -0,0 +1,3 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0165 0C8.94791 0 0 9.01388 0 20.1653C0 29.0792 5.73324 36.6246 13.6868 39.2952C14.6812 39.496 15.0454 38.8613 15.0454 38.3274C15.0454 37.8599 15.0126 36.2575 15.0126 34.5879C9.4445 35.79 8.28498 32.1841 8.28498 32.1841C7.39015 29.847 6.06429 29.2463 6.06429 29.2463C4.24185 28.011 6.19704 28.011 6.19704 28.011C8.21861 28.1446 9.27938 30.081 9.27938 30.081C11.0686 33.1522 13.9518 32.2844 15.1118 31.7502C15.2773 30.4481 15.8079 29.5467 16.3713 29.046C11.9303 28.5785 7.25781 26.8425 7.25781 19.0967C7.25781 16.8932 8.05267 15.0905 9.31216 13.6884C9.11344 13.1877 8.41732 11.1174 9.51128 8.34644C9.51128 8.34644 11.2014 7.81217 15.0122 10.4164C16.6438 9.97495 18.3263 9.7504 20.0165 9.74851C21.7067 9.74851 23.4295 9.98246 25.0205 10.4164C28.8317 7.81217 30.5218 8.34644 30.5218 8.34644C31.6158 11.1174 30.9192 13.1877 30.7205 13.6884C32.0132 15.0905 32.7753 16.8932 32.7753 19.0967C32.7753 26.8425 28.1028 28.5449 23.6287 29.046C24.358 29.6802 24.9873 30.882 24.9873 32.7851C24.9873 35.4893 24.9545 37.6596 24.9545 38.327C24.9545 38.8613 25.3192 39.496 26.3132 39.2956C34.2667 36.6242 39.9999 29.0792 39.9999 20.1653C40.0327 9.01388 31.052 0 20.0165 0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,3 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0165 0C8.94791 0 0 9.01388 0 20.1653C0 29.0792 5.73324 36.6246 13.6868 39.2952C14.6812 39.496 15.0454 38.8613 15.0454 38.3274C15.0454 37.8599 15.0126 36.2575 15.0126 34.5879C9.4445 35.79 8.28498 32.1841 8.28498 32.1841C7.39015 29.847 6.06429 29.2463 6.06429 29.2463C4.24185 28.011 6.19704 28.011 6.19704 28.011C8.21861 28.1446 9.27938 30.081 9.27938 30.081C11.0686 33.1522 13.9518 32.2844 15.1118 31.7502C15.2773 30.4481 15.8079 29.5467 16.3713 29.046C11.9303 28.5785 7.25781 26.8425 7.25781 19.0967C7.25781 16.8932 8.05267 15.0905 9.31216 13.6884C9.11344 13.1877 8.41732 11.1174 9.51128 8.34644C9.51128 8.34644 11.2014 7.81217 15.0122 10.4164C16.6438 9.97495 18.3263 9.7504 20.0165 9.74851C21.7067 9.74851 23.4295 9.98246 25.0205 10.4164C28.8317 7.81217 30.5218 8.34644 30.5218 8.34644C31.6158 11.1174 30.9192 13.1877 30.7205 13.6884C32.0132 15.0905 32.7753 16.8932 32.7753 19.0967C32.7753 26.8425 28.1028 28.5449 23.6287 29.046C24.358 29.6802 24.9873 30.882 24.9873 32.7851C24.9873 35.4893 24.9545 37.6596 24.9545 38.327C24.9545 38.8613 25.3192 39.496 26.3132 39.2956C34.2667 36.6242 39.9999 29.0792 39.9999 20.1653C40.0327 9.01388 31.052 0 20.0165 0Z" fill="#24292F"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,18 @@
{
"node": "{{nodePackageName}}",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Development", "Developer Tools"],
"resources": {
"credentialDocumentation": [
{
"url": "https://github.com/org/repo?tab=readme-ov-file#credentials"
}
],
"primaryDocumentation": [
{
"url": "https://github.com/org/repo?tab=readme-ov-file"
}
]
}
}
@@ -0,0 +1,96 @@
import { NodeConnectionTypes, type INodeType, type INodeTypeDescription } from 'n8n-workflow';
import { issueDescription } from './resources/issue';
import { issueCommentDescription } from './resources/issueComment';
import { getRepositories } from './listSearch/getRepositories';
import { getUsers } from './listSearch/getUsers';
import { getIssues } from './listSearch/getIssues';
export class GithubIssues implements INodeType {
description: INodeTypeDescription = {
displayName: 'GitHub Issues',
name: 'githubIssues',
icon: { light: 'file:../../icons/github.svg', dark: 'file:../../icons/github.dark.svg' },
group: ['input'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume issues from the GitHub API',
defaults: {
name: 'GitHub Issues',
},
usableAsTool: true,
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'githubIssuesApi',
required: true,
displayOptions: {
show: {
authentication: ['accessToken'],
},
},
},
{
name: 'githubIssuesOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: ['oAuth2'],
},
},
},
],
requestDefaults: {
baseURL: 'https://api.github.com',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
properties: [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'Access Token',
value: 'accessToken',
},
{
name: 'OAuth2',
value: 'oAuth2',
},
],
default: 'accessToken',
},
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Issue',
value: 'issue',
},
{
name: 'Issue Comment',
value: 'issueComment',
},
],
default: 'issue',
},
...issueDescription,
...issueCommentDescription,
],
};
methods = {
listSearch: {
getRepositories,
getUsers,
getIssues,
},
};
}
@@ -0,0 +1,49 @@
import type {
ILoadOptionsFunctions,
INodeListSearchResult,
INodeListSearchItems,
} from 'n8n-workflow';
import { githubApiRequest } from '../shared/transport';
type IssueSearchItem = {
number: number;
title: string;
html_url: string;
};
type IssueSearchResponse = {
items: IssueSearchItem[];
total_count: number;
};
export async function getIssues(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const page = paginationToken ? +paginationToken : 1;
const per_page = 100;
let responseData: IssueSearchResponse = {
items: [],
total_count: 0,
};
const owner = this.getNodeParameter('owner', '', { extractValue: true });
const repository = this.getNodeParameter('repository', '', { extractValue: true });
const filters = [filter, `repo:${owner}/${repository}`];
responseData = await githubApiRequest.call(this, 'GET', '/search/issues', {
q: filters.filter(Boolean).join(' '),
page,
per_page,
});
const results: INodeListSearchItems[] = responseData.items.map((item: IssueSearchItem) => ({
name: item.title,
value: item.number,
url: item.html_url,
}));
const nextPaginationToken = page * per_page < responseData.total_count ? page + 1 : undefined;
return { results, paginationToken: nextPaginationToken };
}
@@ -0,0 +1,50 @@
import type {
ILoadOptionsFunctions,
INodeListSearchItems,
INodeListSearchResult,
} from 'n8n-workflow';
import { githubApiRequest } from '../shared/transport';
type RepositorySearchItem = {
name: string;
html_url: string;
};
type RepositorySearchResponse = {
items: RepositorySearchItem[];
total_count: number;
};
export async function getRepositories(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const owner = this.getCurrentNodeParameter('owner', { extractValue: true });
const page = paginationToken ? +paginationToken : 1;
const per_page = 100;
const q = `${filter ?? ''} user:${owner} fork:true`;
let responseData: RepositorySearchResponse = {
items: [],
total_count: 0,
};
try {
responseData = await githubApiRequest.call(this, 'GET', '/search/repositories', {
q,
page,
per_page,
});
} catch {
// will fail if the owner does not have any repositories
}
const results: INodeListSearchItems[] = responseData.items.map((item: RepositorySearchItem) => ({
name: item.name,
value: item.name,
url: item.html_url,
}));
const nextPaginationToken = page * per_page < responseData.total_count ? page + 1 : undefined;
return { results, paginationToken: nextPaginationToken };
}
@@ -0,0 +1,49 @@
import type {
ILoadOptionsFunctions,
INodeListSearchResult,
INodeListSearchItems,
} from 'n8n-workflow';
import { githubApiRequest } from '../shared/transport';
type UserSearchItem = {
login: string;
html_url: string;
};
type UserSearchResponse = {
items: UserSearchItem[];
total_count: number;
};
export async function getUsers(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const page = paginationToken ? +paginationToken : 1;
const per_page = 100;
let responseData: UserSearchResponse = {
items: [],
total_count: 0,
};
try {
responseData = await githubApiRequest.call(this, 'GET', '/search/users', {
q: filter,
page,
per_page,
});
} catch {
// will fail if the owner does not have any users
}
const results: INodeListSearchItems[] = responseData.items.map((item: UserSearchItem) => ({
name: item.login,
value: item.login,
url: item.html_url,
}));
const nextPaginationToken = page * per_page < responseData.total_count ? page + 1 : undefined;
return { results, paginationToken: nextPaginationToken };
}
@@ -0,0 +1,74 @@
import type { INodeProperties } from 'n8n-workflow';
const showOnlyForIssueCreate = {
operation: ['create'],
resource: ['issue'],
};
export const issueCreateDescription: INodeProperties[] = [
{
displayName: 'Title',
name: 'title',
type: 'string',
default: '',
required: true,
displayOptions: {
show: showOnlyForIssueCreate,
},
description: 'The title of the issue',
routing: {
send: {
type: 'body',
property: 'title',
},
},
},
{
displayName: 'Body',
name: 'body',
type: 'string',
typeOptions: {
rows: 5,
},
default: '',
displayOptions: {
show: showOnlyForIssueCreate,
},
description: 'The body of the issue',
routing: {
send: {
type: 'body',
property: 'body',
},
},
},
{
displayName: 'Labels',
name: 'labels',
type: 'collection',
typeOptions: {
multipleValues: true,
multipleValueButtonText: 'Add Label',
},
displayOptions: {
show: showOnlyForIssueCreate,
},
default: { label: '' },
options: [
{
displayName: 'Label',
name: 'label',
type: 'string',
default: '',
description: 'Label to add to issue',
},
],
routing: {
send: {
type: 'body',
property: 'labels',
value: '={{$value.map((data) => data.label)}}',
},
},
},
];
@@ -0,0 +1,14 @@
import type { INodeProperties } from 'n8n-workflow';
import { issueSelect } from '../../shared/descriptions';
const showOnlyForIssueGet = {
operation: ['get'],
resource: ['issue'],
};
export const issueGetDescription: INodeProperties[] = [
{
...issueSelect,
displayOptions: { show: showOnlyForIssueGet },
},
];
@@ -0,0 +1,124 @@
import type { INodeProperties } from 'n8n-workflow';
import { parseLinkHeader } from '../../shared/utils';
const showOnlyForIssueGetMany = {
operation: ['getAll'],
resource: ['issue'],
};
export const issueGetManyDescription: INodeProperties[] = [
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
...showOnlyForIssueGetMany,
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 100,
},
default: 50,
routing: {
send: {
type: 'query',
property: 'per_page',
},
output: {
maxResults: '={{$value}}',
},
},
description: 'Max number of results to return',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: showOnlyForIssueGetMany,
},
default: false,
description: 'Whether to return all results or only up to a given limit',
routing: {
send: {
paginate: '={{ $value }}',
type: 'query',
property: 'per_page',
value: '100',
},
operations: {
pagination: {
type: 'generic',
properties: {
continue: `={{ !!(${parseLinkHeader.toString()})($response.headers?.link).next }}`,
request: {
url: `={{ (${parseLinkHeader.toString()})($response.headers?.link)?.next ?? $request.url }}`,
},
},
},
},
},
},
{
displayName: 'Filters',
name: 'filters',
type: 'collection',
typeOptions: {
multipleValueButtonText: 'Add Filter',
},
displayOptions: {
show: showOnlyForIssueGetMany,
},
default: {},
options: [
{
displayName: 'Updated Since',
name: 'since',
type: 'dateTime',
default: '',
description: 'Return only issues updated at or after this time',
routing: {
request: {
qs: {
since: '={{$value}}',
},
},
},
},
{
displayName: 'State',
name: 'state',
type: 'options',
options: [
{
name: 'All',
value: 'all',
description: 'Returns issues with any state',
},
{
name: 'Closed',
value: 'closed',
description: 'Return issues with "closed" state',
},
{
name: 'Open',
value: 'open',
description: 'Return issues with "open" state',
},
],
default: 'open',
description: 'The issue state to filter on',
routing: {
request: {
qs: {
state: '={{$value}}',
},
},
},
},
],
},
];
@@ -0,0 +1,75 @@
import type { INodeProperties } from 'n8n-workflow';
import { repoNameSelect, repoOwnerSelect } from '../../shared/descriptions';
import { issueGetManyDescription } from './getAll';
import { issueGetDescription } from './get';
import { issueCreateDescription } from './create';
const showOnlyForIssues = {
resource: ['issue'],
};
export const issueDescription: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: showOnlyForIssues,
},
options: [
{
name: 'Get Many',
value: 'getAll',
action: 'Get issues in a repository',
description: 'Get many issues in a repository',
routing: {
request: {
method: 'GET',
url: '=/repos/{{$parameter.owner}}/{{$parameter.repository}}/issues',
},
},
},
{
name: 'Get',
value: 'get',
action: 'Get an issue',
description: 'Get the data of a single issue',
routing: {
request: {
method: 'GET',
url: '=/repos/{{$parameter.owner}}/{{$parameter.repository}}/issues/{{$parameter.issue}}',
},
},
},
{
name: 'Create',
value: 'create',
action: 'Create a new issue',
description: 'Create a new issue',
routing: {
request: {
method: 'POST',
url: '=/repos/{{$parameter.owner}}/{{$parameter.repository}}/issues',
},
},
},
],
default: 'getAll',
},
{
...repoOwnerSelect,
displayOptions: {
show: showOnlyForIssues,
},
},
{
...repoNameSelect,
displayOptions: {
show: showOnlyForIssues,
},
},
...issueGetManyDescription,
...issueGetDescription,
...issueCreateDescription,
];
@@ -0,0 +1,65 @@
import type { INodeProperties } from 'n8n-workflow';
import { parseLinkHeader } from '../../shared/utils';
const showOnlyForIssueCommentGetMany = {
operation: ['getAll'],
resource: ['issueComment'],
};
export const issueCommentGetManyDescription: INodeProperties[] = [
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
...showOnlyForIssueCommentGetMany,
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 100,
},
default: 50,
routing: {
send: {
type: 'query',
property: 'per_page',
},
output: {
maxResults: '={{$value}}',
},
},
description: 'Max number of results to return',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: showOnlyForIssueCommentGetMany,
},
default: false,
description: 'Whether to return all results or only up to a given limit',
routing: {
send: {
paginate: '={{ $value }}',
type: 'query',
property: 'per_page',
value: '100',
},
operations: {
pagination: {
type: 'generic',
properties: {
continue: `={{ !!(${parseLinkHeader.toString()})($response.headers?.link).next }}`,
request: {
url: `={{ (${parseLinkHeader.toString()})($response.headers?.link)?.next ?? $request.url }}`,
},
},
},
},
},
},
];
@@ -0,0 +1,47 @@
import type { INodeProperties } from 'n8n-workflow';
import { repoNameSelect, repoOwnerSelect } from '../../shared/descriptions';
import { issueCommentGetManyDescription } from './getAll';
const showOnlyForIssueComments = {
resource: ['issueComment'],
};
export const issueCommentDescription: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: showOnlyForIssueComments,
},
options: [
{
name: 'Get Many',
value: 'getAll',
action: 'Get issue comments',
description: 'Get issue comments',
routing: {
request: {
method: 'GET',
url: '=/repos/{{$parameter.owner}}/{{$parameter.repository}}/issues/comments',
},
},
},
],
default: 'getAll',
},
{
...repoOwnerSelect,
displayOptions: {
show: showOnlyForIssueComments,
},
},
{
...repoNameSelect,
displayOptions: {
show: showOnlyForIssueComments,
},
},
...issueCommentGetManyDescription,
];
@@ -0,0 +1,151 @@
import type { INodeProperties } from 'n8n-workflow';
export const repoOwnerSelect: INodeProperties = {
displayName: 'Repository Owner',
name: 'owner',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'Repository Owner',
name: 'list',
type: 'list',
placeholder: 'Select an owner...',
typeOptions: {
searchListMethod: 'getUsers',
searchable: true,
searchFilterRequired: false,
},
},
{
displayName: 'Link',
name: 'url',
type: 'string',
placeholder: 'e.g. https://github.com/n8n-io',
extractValue: {
type: 'regex',
regex: 'https:\\/\\/github.com\\/([-_0-9a-zA-Z]+)',
},
validation: [
{
type: 'regex',
properties: {
regex: 'https:\\/\\/github.com\\/([-_0-9a-zA-Z]+)(?:.*)',
errorMessage: 'Not a valid GitHub URL',
},
},
],
},
{
displayName: 'By Name',
name: 'name',
type: 'string',
placeholder: 'e.g. n8n-io',
validation: [
{
type: 'regex',
properties: {
regex: '[-_a-zA-Z0-9]+',
errorMessage: 'Not a valid GitHub Owner Name',
},
},
],
url: '=https://github.com/{{$value}}',
},
],
};
export const repoNameSelect: INodeProperties = {
displayName: 'Repository Name',
name: 'repository',
type: 'resourceLocator',
default: {
mode: 'list',
value: '',
},
required: true,
modes: [
{
displayName: 'Repository Name',
name: 'list',
type: 'list',
placeholder: 'Select an Repository...',
typeOptions: {
searchListMethod: 'getRepositories',
searchable: true,
},
},
{
displayName: 'Link',
name: 'url',
type: 'string',
placeholder: 'e.g. https://github.com/n8n-io/n8n',
extractValue: {
type: 'regex',
regex: 'https:\\/\\/github.com\\/(?:[-_0-9a-zA-Z]+)\\/([-_.0-9a-zA-Z]+)',
},
validation: [
{
type: 'regex',
properties: {
regex: 'https:\\/\\/github.com\\/(?:[-_0-9a-zA-Z]+)\\/([-_.0-9a-zA-Z]+)(?:.*)',
errorMessage: 'Not a valid GitHub Repository URL',
},
},
],
},
{
displayName: 'By Name',
name: 'name',
type: 'string',
placeholder: 'e.g. n8n',
validation: [
{
type: 'regex',
properties: {
regex: '[-_.0-9a-zA-Z]+',
errorMessage: 'Not a valid GitHub Repository Name',
},
},
],
url: '=https://github.com/{{$parameter["owner"]}}/{{$value}}',
},
],
displayOptions: {
hide: {
resource: ['user', 'organization'],
operation: ['getRepositories'],
},
},
};
export const issueSelect: INodeProperties = {
displayName: 'Issue',
name: 'issue',
type: 'resourceLocator',
default: {
mode: 'list',
value: '',
},
required: true,
modes: [
{
displayName: 'Issue',
name: 'list',
type: 'list',
placeholder: 'Select an Issue...',
typeOptions: {
searchListMethod: 'getIssues',
searchable: true,
},
},
{
displayName: 'By ID',
name: 'name',
type: 'string',
placeholder: 'e.g. 123',
url: '=https://github.com/{{$parameter.owner}}/{{$parameter.repository}}/issues/{{$value}}',
},
],
};
@@ -0,0 +1,32 @@
import type {
IHookFunctions,
IExecuteFunctions,
IExecuteSingleFunctions,
ILoadOptionsFunctions,
IHttpRequestMethods,
IDataObject,
IHttpRequestOptions,
} from 'n8n-workflow';
export async function githubApiRequest(
this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
resource: string,
qs: IDataObject = {},
body: IDataObject | undefined = undefined,
) {
const authenticationMethod = this.getNodeParameter('authentication', 0);
const options: IHttpRequestOptions = {
method: method,
qs,
body,
url: `https://api.github.com${resource}`,
json: true,
};
const credentialType =
authenticationMethod === 'accessToken' ? 'githubIssuesApi' : 'githubIssuesOAuth2Api';
return this.helpers.httpRequestWithAuthentication.call(this, credentialType, options);
}
@@ -0,0 +1,14 @@
export function parseLinkHeader(header?: string): { [rel: string]: string } {
const links: { [rel: string]: string } = {};
for (const part of header?.split(',') ?? []) {
const section = part.trim();
const match = section.match(/^<([^>]+)>\s*;\s*rel="?([^"]+)"?/);
if (match) {
const [, url, rel] = match;
links[rel] = url;
}
}
return links;
}
@@ -0,0 +1,51 @@
{
"name": "{{nodePackageName}}",
"version": "0.1.0",
"description": "",
"license": "MIT",
"homepage": "",
"keywords": [
"n8n-community-node-package"
],
"author": {
"name": "{{user.name}}",
"email": "{{user.email}}"
},
"repository": {
"type": "git",
"url": "https://github.com/<...>/n8n-nodes-<...>.git"
},
"scripts": {
"build": "n8n-node build",
"build:watch": "tsc --watch",
"dev": "n8n-node dev",
"lint": "n8n-node lint",
"lint:fix": "n8n-node lint --fix",
"release": "n8n-node release",
"prepublishOnly": "n8n-node prerelease"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"strict": true,
"credentials": [
"dist/credentials/GithubIssuesApi.credentials.js",
"dist/credentials/GithubIssuesOAuth2Api.credentials.js"
],
"nodes": [
"dist/nodes/GithubIssues/GithubIssues.node.js"
]
},
"devDependencies": {
"@n8n/node-cli": "*",
"eslint": "9.32.0",
"prettier": "3.6.2",
"release-it": "^19.0.4",
"typescript": "5.9.2"
},
"peerDependencies": {
"n8n-workflow": "*"
}
}
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"strict": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2019",
"lib": ["es2019", "es2020", "es2022.error"],
"removeComments": true,
"useUnknownInCatchVariables": false,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"preserveConstEnums": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"incremental": true,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true,
"outDir": "./dist/"
},
"include": ["credentials/**/*", "nodes/**/*", "nodes/**/*.json", "package.json"]
}
@@ -0,0 +1,43 @@
import { customTemplate } from './declarative/custom/template';
import { githubIssuesTemplate } from './declarative/github-issues/template';
import { customMemoryTemplate } from './programmatic/ai/memory-custom/template';
import { customChatModelTemplate } from './programmatic/ai/model-ai-custom/template';
import { customChatModelExampleTemplate } from './programmatic/ai/model-ai-custom-example/template';
import { openaiChatModelTemplate } from './programmatic/ai/model-openai-compatible/template';
import { exampleTemplate } from './programmatic/example/template';
export const templates = {
declarative: {
githubIssues: githubIssuesTemplate,
custom: customTemplate,
},
programmatic: {
example: exampleTemplate,
openaiChatModel: openaiChatModelTemplate,
customChatModel: customChatModelTemplate,
customChatMemory: customMemoryTemplate,
customChatModelExample: customChatModelExampleTemplate,
},
} as const;
export type TemplateMap = typeof templates;
export type TemplateType = keyof TemplateMap;
export type TemplateName<T extends TemplateType> = keyof TemplateMap[T];
export function getTemplate<T extends TemplateType, N extends TemplateName<T>>(
type: T,
name: N,
): TemplateMap[T][N] {
return templates[type][name];
}
export function isTemplateType(val: unknown): val is TemplateType {
return typeof val === 'string' && val in templates;
}
export function isTemplateName<T extends TemplateType>(
type: T,
name: unknown,
): name is TemplateName<T> {
return typeof name === 'string' && name in templates[type];
}
@@ -0,0 +1,9 @@
import path from 'node:path';
import { createTemplate } from '../../../../core';
export const customMemoryTemplate = createTemplate({
name: 'Custom memory node',
description: 'Memory node with custom in-memory storage implementation',
path: path.join(__dirname, 'template'),
});
@@ -0,0 +1,46 @@
# {{nodePackageName}}
This is an n8n community node. It lets you use _app/service name_ in your n8n workflows.
_App/service name_ is _one or two sentences describing the service this node integrates with_.
[n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/sustainable-use-license/) workflow automation platform.
[Installation](#installation)
[Operations](#operations)
[Credentials](#credentials)
[Compatibility](#compatibility)
[Usage](#usage)
[Resources](#resources)
[Version history](#version-history)
## Installation
Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community nodes documentation.
## Operations
_List the operations supported by your node._
## Credentials
_If users need to authenticate with the app/service, provide details here. You should include prerequisites (such as signing up with the service), available authentication methods, and how to set them up._
## Compatibility
_State the minimum n8n version, as well as which versions you test against. You can also include any known version incompatibility issues._
## Usage
_This is an optional section. Use it to help users with any difficult or confusing aspects of the node._
_By the time users are looking for community nodes, they probably already know n8n basics. But if you expect new users, you can link to the [Try it out](https://docs.n8n.io/try-it-out/) documentation to help them get started._
## Resources
* [n8n community nodes documentation](https://docs.n8n.io/integrations/#community-nodes)
* _Link to app/service documentation._
## Version history
_This is another optional section. If your node has multiple versions, include a short description of available versions and what changed, as well as any compatibility impact._
@@ -0,0 +1,18 @@
{
"node": "{{nodePackageName}}",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Development", "Developer Tools"],
"resources": {
"credentialDocumentation": [
{
"url": "https://github.com/org/repo?tab=readme-ov-file#credentials"
}
],
"primaryDocumentation": [
{
"url": "https://github.com/org/repo?tab=readme-ov-file"
}
]
}
}
@@ -0,0 +1,84 @@
import type { INodeType, INodeTypeDescription, ISupplyDataFunctions } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { supplyMemory, WindowedChatMemory } from '@n8n/ai-node-sdk';
import { InMemoryChatHistory } from './memory';
type MemoryOptions = {
windowSize?: number;
};
export class ExampleChatMemory implements INodeType {
description: INodeTypeDescription = {
displayName: 'Example Memory',
name: 'exampleChatMemory',
icon: { light: 'file:example.svg', dark: 'file:example.dark.svg' },
group: ['transform'],
version: [1],
description: 'Store conversation history in memory',
defaults: {
name: 'Example Memory',
},
codex: {
categories: ['assistant'],
subcategories: {
AI: ['Memory', 'Root Nodes'],
Memory: ['Other memories'],
},
resources: {
primaryDocumentation: [],
},
},
inputs: [],
outputs: [NodeConnectionTypes.AiMemory],
outputNames: ['Memory'],
credentials: [],
properties: [
{
displayName: 'Session ID',
name: 'sessionId',
type: 'string',
default: '={{ $json.sessionId }}',
description: 'Unique identifier for the conversation session',
placeholder: 'user-123',
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
description: 'Additional options for memory management',
type: 'collection',
default: {},
options: [
{
displayName: 'Window Size',
name: 'windowSize',
type: 'number',
default: 10,
description: 'Number of recent message pairs to keep in context',
typeOptions: {
minValue: 1,
maxValue: 100,
},
},
],
},
],
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number) {
const sessionId = this.getNodeParameter('sessionId', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as MemoryOptions;
// Create the in-memory chat history storage
const history = new InMemoryChatHistory(sessionId);
// Wrap with windowed memory to limit context size
const memory = new WindowedChatMemory(history, {
windowSize: options.windowSize ?? 10,
});
return supplyMemory(this, memory);
}
}
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="aquamarine"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-cpu">
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
<rect x="9" y="9" width="6" height="6"></rect>
<line x1="9" y1="1" x2="9" y2="4"></line>
<line x1="15" y1="1" x2="15" y2="4"></line>
<line x1="9" y1="20" x2="9" y2="23"></line>
<line x1="15" y1="20" x2="15" y2="23"></line>
<line x1="20" y1="9" x2="23" y2="9"></line>
<line x1="20" y1="14" x2="23" y2="14"></line>
<line x1="1" y1="9" x2="4" y2="9"></line>
<line x1="1" y1="14" x2="4" y2="14"></line>
</svg>

After

Width:  |  Height:  |  Size: 698 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="darkblue"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-cpu">
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
<rect x="9" y="9" width="6" height="6"></rect>
<line x1="9" y1="1" x2="9" y2="4"></line>
<line x1="15" y1="1" x2="15" y2="4"></line>
<line x1="9" y1="20" x2="9" y2="23"></line>
<line x1="15" y1="20" x2="15" y2="23"></line>
<line x1="20" y1="9" x2="23" y2="9"></line>
<line x1="20" y1="14" x2="23" y2="14"></line>
<line x1="1" y1="9" x2="4" y2="9"></line>
<line x1="1" y1="14" x2="4" y2="14"></line>
</svg>

After

Width:  |  Height:  |  Size: 696 B

@@ -0,0 +1,29 @@
import { BaseChatHistory, Message } from '@n8n/ai-node-sdk';
/**
* In-memory chat history storage
* Stores conversation messages in memory by session ID
* DO NOT use this in production, in-memory storage is not persistent
*/
export class InMemoryChatHistory extends BaseChatHistory {
private static storage: Map<string, Message[]> = new Map();
constructor(private sessionId: string) {
super();
}
async getMessages(): Promise<Message[]> {
const messages = InMemoryChatHistory.storage.get(this.sessionId);
return messages ? [...messages] : [];
}
async addMessage(message: Message): Promise<void> {
const messages = InMemoryChatHistory.storage.get(this.sessionId) || [];
messages.push(message);
InMemoryChatHistory.storage.set(this.sessionId, messages);
}
async clear(): Promise<void> {
InMemoryChatHistory.storage.delete(this.sessionId);
}
}
@@ -0,0 +1,50 @@
{
"name": "{{nodePackageName}}",
"version": "0.1.0",
"description": "",
"license": "MIT",
"homepage": "",
"keywords": [
"n8n-community-node-package"
],
"author": {
"name": "{{user.name}}",
"email": "{{user.email}}"
},
"repository": {
"type": "git",
"url": "https://github.com/<...>/n8n-nodes-<...>.git"
},
"scripts": {
"build": "n8n-node build",
"build:watch": "tsc --watch",
"dev": "n8n-node dev",
"lint": "n8n-node lint",
"lint:fix": "n8n-node lint --fix",
"release": "n8n-node release",
"prepublishOnly": "n8n-node prerelease"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"aiNodeSdkVersion": 1,
"strict": true,
"credentials": [],
"nodes": [
"dist/nodes/ExampleChatMemory/ExampleChatMemory.node.js"
]
},
"devDependencies": {
"@n8n/node-cli": "*",
"eslint": "9.32.0",
"prettier": "3.6.2",
"release-it": "^19.0.4",
"typescript": "5.9.2"
},
"peerDependencies": {
"n8n-workflow": "*",
"@n8n/ai-node-sdk": "*"
}
}
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"strict": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2019",
"lib": ["es2019", "es2020", "es2022.error"],
"removeComments": true,
"useUnknownInCatchVariables": false,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"preserveConstEnums": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"incremental": true,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true,
"outDir": "./dist/"
},
"include": ["credentials/**/*", "nodes/**/*", "nodes/**/*.json", "package.json"]
}
@@ -0,0 +1,9 @@
import path from 'node:path';
import { createTemplate } from '../../../../core';
export const customChatModelExampleTemplate = createTemplate({
name: 'OpenAI chat model node',
description: 'Chat model node with custom implementation',
path: path.join(__dirname, 'template'),
});
@@ -0,0 +1,46 @@
# {{nodePackageName}}
This is an n8n community node. It lets you use _app/service name_ in your n8n workflows.
_App/service name_ is _one or two sentences describing the service this node integrates with_.
[n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/sustainable-use-license/) workflow automation platform.
[Installation](#installation)
[Operations](#operations)
[Credentials](#credentials)
[Compatibility](#compatibility)
[Usage](#usage)
[Resources](#resources)
[Version history](#version-history)
## Installation
Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community nodes documentation.
## Operations
_List the operations supported by your node._
## Credentials
_If users need to authenticate with the app/service, provide details here. You should include prerequisites (such as signing up with the service), available authentication methods, and how to set them up._
## Compatibility
_State the minimum n8n version, as well as which versions you test against. You can also include any known version incompatibility issues._
## Usage
_This is an optional section. Use it to help users with any difficult or confusing aspects of the node._
_By the time users are looking for community nodes, they probably already know n8n basics. But if you expect new users, you can link to the [Try it out](https://docs.n8n.io/try-it-out/) documentation to help them get started._
## Resources
* [n8n community nodes documentation](https://docs.n8n.io/integrations/#community-nodes)
* _Link to app/service documentation._
## Version history
_This is another optional section. If your node has multiple versions, include a short description of available versions and what changed, as well as any compatibility impact._
@@ -0,0 +1,52 @@
import type {
ICredentialDataDecryptedObject,
ICredentialTestRequest,
ICredentialType,
IHttpRequestOptions,
INodeProperties,
Icon,
} from 'n8n-workflow';
export class ExampleApi implements ICredentialType {
name = 'exampleApi';
displayName = 'Example API';
icon: Icon = { light: 'file:../icons/example.svg', dark: 'file:../icons/example.dark.svg' };
properties: INodeProperties[] = [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string',
typeOptions: { password: true },
required: true,
default: '',
},
{
displayName: 'Base URL',
name: 'url',
type: 'string',
default: '',
description: 'Override the default base URL for the API',
},
];
test: ICredentialTestRequest = {
request: {
baseURL: '={{$credentials?.url}}',
url: '/models',
},
};
async authenticate(
credentials: ICredentialDataDecryptedObject,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
requestOptions.headers ??= {};
requestOptions.headers['Authorization'] = `Bearer ${credentials.apiKey}`;
return requestOptions;
}
}
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="aquamarine"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-cpu">
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
<rect x="9" y="9" width="6" height="6"></rect>
<line x1="9" y1="1" x2="9" y2="4"></line>
<line x1="15" y1="1" x2="15" y2="4"></line>
<line x1="9" y1="20" x2="9" y2="23"></line>
<line x1="15" y1="20" x2="15" y2="23"></line>
<line x1="20" y1="9" x2="23" y2="9"></line>
<line x1="20" y1="14" x2="23" y2="14"></line>
<line x1="1" y1="9" x2="4" y2="9"></line>
<line x1="1" y1="14" x2="4" y2="14"></line>
</svg>

After

Width:  |  Height:  |  Size: 698 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="darkblue"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-cpu">
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
<rect x="9" y="9" width="6" height="6"></rect>
<line x1="9" y1="1" x2="9" y2="4"></line>
<line x1="15" y1="1" x2="15" y2="4"></line>
<line x1="9" y1="20" x2="9" y2="23"></line>
<line x1="15" y1="20" x2="15" y2="23"></line>
<line x1="20" y1="9" x2="23" y2="9"></line>
<line x1="20" y1="14" x2="23" y2="14"></line>
<line x1="1" y1="9" x2="4" y2="9"></line>
<line x1="1" y1="14" x2="4" y2="14"></line>
</svg>

After

Width:  |  Height:  |  Size: 696 B

@@ -0,0 +1,18 @@
{
"node": "{{nodePackageName}}",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Development", "Developer Tools"],
"resources": {
"credentialDocumentation": [
{
"url": "https://github.com/org/repo?tab=readme-ov-file#credentials"
}
],
"primaryDocumentation": [
{
"url": "https://github.com/org/repo?tab=readme-ov-file"
}
]
}
}
@@ -0,0 +1,114 @@
import type {
INodeType,
INodeTypeDescription,
ISupplyDataFunctions,
IDataObject,
} from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { ProviderTool, supplyModel } from '@n8n/ai-node-sdk';
import { OpenAIChatModel } from './model';
import { openAiProperties } from './properties';
import { formatBuiltInTools } from './common';
type ModelOptions = {
temperature?: number;
};
export class ExampleChatModel implements INodeType {
description: INodeTypeDescription = {
displayName: 'Example Chat Model',
name: 'exampleChatModel',
icon: { light: 'file:../../icons/example.svg', dark: 'file:../../icons/example.dark.svg' },
group: ['transform'],
version: [1],
description: 'Custom Chat Model Node',
defaults: {
name: 'Example Chat Model',
},
codex: {
categories: ['assistant'],
subcategories: {
AI: ['Language Models', 'Root Nodes'],
'Language Models': ['Chat Models (Recommended)'],
},
resources: {
primaryDocumentation: [],
},
},
inputs: [],
outputs: [NodeConnectionTypes.AiLanguageModel],
outputNames: ['Model'],
credentials: [
{
name: 'exampleApi',
required: true,
},
],
requestDefaults: {
ignoreHttpStatusErrors: true,
baseURL:
'={{ $credentials?.url?.split("/").slice(0,-1).join("/") || "https://api.openai.com" }}',
},
properties: openAiProperties,
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number) {
const credentials = await this.getCredentials('exampleApi');
const modelName = this.getNodeParameter('model', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as ModelOptions;
const providerTools: ProviderTool[] = [];
const builtInToolsParams = formatBuiltInTools(
this.getNodeParameter('builtInTools', itemIndex, {}) as IDataObject,
);
if (builtInToolsParams.length) {
providerTools.push(...builtInToolsParams);
}
const model = new OpenAIChatModel(
modelName,
{
httpRequest: async (method, url, body, headers) => {
const response = await this.helpers.httpRequestWithAuthentication.call(
this,
'exampleApi',
{
url,
method,
body,
headers,
},
);
return {
body: response,
};
},
openStream: async (method, url, body, headers) => {
const response = await this.helpers.httpRequestWithAuthentication.call(
this,
'exampleApi',
{
method,
url,
body,
headers,
encoding: 'stream',
},
);
return {
body: response,
};
},
},
{
baseURL: credentials.url as string,
apiKey: credentials.apiKey as string,
providerTools,
temperature: options.temperature,
},
);
return supplyModel(this, model);
}
}
@@ -0,0 +1,43 @@
import type { IDataObject } from 'n8n-workflow';
import type { ProviderTool } from '@n8n/ai-node-sdk';
const toArray = (str: string) =>
str
.split(',')
.map((e) => e.trim())
.filter(Boolean);
export const formatBuiltInTools = (builtInTools: IDataObject): ProviderTool[] => {
const tools: ProviderTool[] = [];
if (builtInTools) {
const webSearchOptions = builtInTools.webSearch as IDataObject;
if (webSearchOptions) {
let allowedDomains: string[] | undefined;
const allowedDomainsRaw = webSearchOptions.allowedDomains as string;
if (allowedDomainsRaw) {
allowedDomains = toArray(allowedDomainsRaw);
}
let userLocation: IDataObject | undefined;
if (webSearchOptions.country || webSearchOptions.city || webSearchOptions.region) {
userLocation = {
type: 'approximate',
country: webSearchOptions.country as string,
city: webSearchOptions.city as string,
region: webSearchOptions.region as string,
};
}
tools.push({
type: 'provider',
name: 'web_search',
args: {
search_context_size: webSearchOptions.searchContextSize as string,
user_location: userLocation,
...(allowedDomains && { filters: { allowed_domains: allowedDomains } }),
},
});
}
}
return tools;
};
@@ -0,0 +1,534 @@
import type { IHttpRequestMethods } from 'n8n-workflow';
import {
BaseChatModel,
getParametersJsonSchema,
parseSSEStream,
type TokenUsage,
type Tool,
type ToolCall,
type ChatModelConfig,
type GenerateResult,
type Message,
type MessageContent,
type ProviderTool,
type StreamChunk,
} from '@n8n/ai-node-sdk';
// Types
type OpenAITool =
| {
type: 'function';
name: string;
description?: string;
parameters: unknown;
strict?: boolean;
}
| {
type: 'web_search';
};
type OpenAIToolChoice = 'auto' | 'required' | 'none' | { type: 'function'; name: string };
type ResponsesInputItem =
| { role: 'user'; content: string }
| { role: 'user'; content: Array<{ type: 'input_text'; text: string }> }
| {
type: 'message';
role: 'assistant';
content: Array<{ type: 'output_text'; text: string }>;
}
| {
type: 'function_call';
call_id: string;
name: string;
arguments: string;
}
| { type: 'function_call_output'; call_id: string; output: string };
interface OpenAIResponsesRequest {
model: string;
input: string | ResponsesInputItem[];
instructions?: string;
max_output_tokens?: number;
temperature?: number;
top_p?: number;
tools?: OpenAITool[];
tool_choice?: OpenAIToolChoice;
parallel_tool_calls?: boolean;
store?: boolean;
stream?: boolean;
metadata?: Record<string, unknown>;
}
interface OpenAIResponsesResponse {
id: string;
object: string;
created_at: string;
model: string;
output: ResponsesOutputItem[];
status: string;
usage?: {
input_tokens: number;
output_tokens: number;
total_tokens: number;
input_tokens_details?: {
cached_tokens?: number;
};
output_tokens_details?: {
reasoning_tokens?: number;
};
};
incomplete_details?: Record<string, unknown>;
metadata?: Record<string, unknown>;
user?: string;
service_tier?: string;
}
type ResponsesOutputItem =
| {
type: 'message';
role: 'assistant';
id?: string;
content: Array<{
type: 'output_text';
text: string;
}>;
}
| {
type: 'function_call';
id?: string;
call_id: string;
name: string;
arguments: string;
}
| {
type: 'reasoning';
id?: string;
summary: Array<{
type: string;
text: string;
}>;
};
interface OpenAIStreamEvent {
type: string;
delta?: string;
output_index?: number;
item?: Record<string, unknown>;
response?: Record<string, unknown>;
}
// Helpers
async function* parseOpenAIStreamEvents(
body: AsyncIterableIterator<Buffer | Uint8Array>,
): AsyncIterable<OpenAIStreamEvent> {
for await (const message of parseSSEStream(body)) {
if (!message.data) continue;
if (message.data === '[DONE]') continue;
try {
const event = JSON.parse(message.data);
yield event as OpenAIStreamEvent;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
// ignore error
}
}
}
function genericMessagesToResponsesInput(messages: Message[]): {
instructions?: string;
input: string | ResponsesInputItem[];
} {
const instructionsParts: string[] = [];
const inputItems: ResponsesInputItem[] = [];
for (const msg of messages) {
if (msg.role === 'system') {
for (const contentPart of msg.content) {
if (contentPart.type === 'text') {
instructionsParts.push(contentPart.text);
}
}
}
if (msg.role === 'user') {
for (const contentPart of msg.content) {
if (contentPart.type === 'text') {
inputItems.push({
role: 'user',
content: contentPart.text,
});
}
}
continue;
}
if (msg.role === 'assistant') {
for (const contentPart of msg.content) {
if (contentPart.type === 'text') {
inputItems.push({
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text: contentPart.text,
},
],
});
} else if (contentPart.type === 'tool-call') {
if (!contentPart.toolCallId) {
throw new Error('Tool call ID is required');
}
inputItems.push({
type: 'function_call',
call_id: contentPart.toolCallId,
name: contentPart.toolName,
arguments: contentPart.input,
});
} else if (contentPart.type === 'reasoning') {
inputItems.push({
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text: contentPart.text,
},
],
});
}
}
}
if (msg.role === 'tool') {
for (const contentPart of msg.content) {
if (contentPart.type === 'tool-result') {
const output =
typeof contentPart.result === 'string'
? contentPart.result
: JSON.stringify(contentPart.result);
inputItems.push({
type: 'function_call_output',
call_id: contentPart.toolCallId,
output,
});
}
}
}
}
const instructions = instructionsParts.length > 0 ? instructionsParts.join('\n\n') : undefined;
const single = inputItems[0];
if (
inputItems.length === 1 &&
single &&
'role' in single &&
single.role === 'user' &&
typeof single.content === 'string'
) {
return { instructions, input: single.content };
}
return { instructions, input: inputItems };
}
function genericToolToResponsesTool(tool: Tool): OpenAITool {
if (tool.type === 'provider') {
if (tool.name === 'web_search') {
return {
type: 'web_search',
...tool.args,
};
}
throw new Error(`Unsupported provider tool: ${tool.name}`);
}
const parameters = getParametersJsonSchema(tool);
return {
type: 'function',
name: tool.name,
description: tool.description,
parameters,
strict: tool.strict,
};
}
function parseResponsesOutput(output: ResponsesOutputItem[]): {
text: string;
toolCalls: ToolCall[];
} {
let text = '';
const toolCalls: ToolCall[] = [];
for (const item of output) {
if (item.type === 'message' && item.role === 'assistant') {
for (const block of item.content) {
if (block.type === 'output_text') {
text += block.text;
}
}
}
if (item.type === 'function_call') {
try {
toolCalls.push({
id: item.call_id,
name: item.name,
arguments: JSON.parse(item.arguments) as Record<string, unknown>,
argumentsRaw: item.arguments,
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
throw new Error(`Failed to parse function call arguments: ${item.arguments}`);
}
}
}
return { text, toolCalls };
}
function parseTokenUsage(
usage: OpenAIResponsesResponse['usage'] | undefined,
): TokenUsage | undefined {
return usage
? {
promptTokens: usage.input_tokens ?? 0,
completionTokens: usage.output_tokens ?? 0,
totalTokens: usage.total_tokens ?? 0,
inputTokenDetails: {
...(!!usage.input_tokens_details?.cached_tokens && {
cacheRead: usage.input_tokens_details.cached_tokens,
}),
},
outputTokenDetails: {
...(!!usage.output_tokens_details?.reasoning_tokens && {
reasoning: usage.output_tokens_details.reasoning_tokens,
}),
},
}
: undefined;
}
interface OpenAIChatModelConfig extends ChatModelConfig {
apiKey?: string;
baseURL?: string;
providerTools?: ProviderTool[];
}
interface RequestConfig {
httpRequest: (
method: IHttpRequestMethods,
url: string,
body?: object,
headers?: Record<string, string>,
) => Promise<{ body: unknown }>;
openStream: (
method: IHttpRequestMethods,
url: string,
body?: object,
headers?: Record<string, string>,
) => Promise<{ body: AsyncIterableIterator<Buffer | Uint8Array> }>;
}
export class OpenAIChatModel extends BaseChatModel<OpenAIChatModelConfig> {
private baseURL: string;
constructor(
modelId: string = 'gpt-4o',
private requests: RequestConfig,
config?: OpenAIChatModelConfig,
) {
super('openai', modelId, config);
this.baseURL = config?.baseURL ?? 'https://api.openai.com/v1';
}
private getTools(config?: OpenAIChatModelConfig) {
const ownTools = this.tools;
const providerTools = config?.providerTools ?? this.defaultConfig?.providerTools ?? [];
return [...ownTools, ...providerTools].map(genericToolToResponsesTool);
}
async generate(messages: Message[], config?: OpenAIChatModelConfig): Promise<GenerateResult> {
const merged = this.mergeConfig(config);
const { instructions, input } = genericMessagesToResponsesInput(messages);
const tools = this.getTools(config);
const requestBody: OpenAIResponsesRequest = {
model: this.modelId,
input,
instructions,
max_output_tokens: merged.maxTokens,
temperature: merged.temperature,
top_p: merged.topP,
tools,
parallel_tool_calls: true,
store: false,
stream: false,
};
const response = await this.requests.httpRequest(
'POST',
`${this.baseURL}/responses`,
requestBody,
);
const body = response.body as OpenAIResponsesResponse;
const { text, toolCalls } = parseResponsesOutput(body.output);
const usage = parseTokenUsage(body.usage);
const responseMetadata: Record<string, unknown> = {
model_provider: 'openai',
model: body.model,
created_at: body.created_at,
id: body.id,
incomplete_details: body.incomplete_details,
metadata: body.metadata,
object: body.object,
status: body.status,
user: body.user,
service_tier: body.service_tier,
model_name: body.model,
output: body.output,
};
for (const item of body.output as unknown[]) {
const o = item as Record<string, unknown>;
if (o.type === 'reasoning') {
responseMetadata.reasoning = o;
}
}
const content: MessageContent[] = [];
if (toolCalls.length) {
for (const toolCall of toolCalls) {
content.push({
type: 'tool-call',
toolCallId: toolCall.id,
toolName: toolCall.name,
input: JSON.stringify(toolCall.arguments),
});
}
}
content.push({ type: 'text', text });
const message: Message = {
role: 'assistant',
content,
id: body.id,
};
return {
id: body.id,
finishReason: body.status === 'completed' ? 'stop' : 'other',
usage,
message,
rawResponse: body,
providerMetadata: responseMetadata,
};
}
async *stream(messages: Message[], config?: OpenAIChatModelConfig): AsyncIterable<StreamChunk> {
const merged = this.mergeConfig(config) as OpenAIChatModelConfig;
const { instructions, input } = genericMessagesToResponsesInput(messages);
const tools = this.getTools(config);
const requestBody: OpenAIResponsesRequest = {
model: this.modelId,
input,
instructions,
max_output_tokens: merged.maxTokens,
temperature: merged.temperature,
top_p: merged.topP,
tools,
parallel_tool_calls: true,
store: false,
stream: true,
};
const streamResponse = await this.requests.openStream(
'POST',
`${this.baseURL}/responses`,
requestBody,
);
const streamBody = streamResponse.body;
const toolCallBuffers: Record<number, { name: string; arguments: string }> = {};
for await (const event of parseOpenAIStreamEvents(streamBody)) {
const type = event.type;
if (type === 'response.output_text.delta') {
const delta = event.delta;
if (delta) {
yield { type: 'text-delta', delta };
}
}
if (type === 'response.output_item.added') {
const item = event.item;
if (item?.type === 'function_call') {
const idx = event.output_index ?? 0;
toolCallBuffers[idx] = {
name: (item.name as string) ?? '',
arguments: (item.arguments as string) ?? '',
};
}
if (item?.type === 'reasoning') {
const summary = (item.summary as Array<Record<string, unknown>>) ?? [];
const reasoningText = summary
.map((s) => s.text)
.filter(Boolean)
.join('');
if (reasoningText) {
yield { type: 'reasoning-delta', delta: reasoningText };
}
}
}
if (type === 'response.reasoning_summary_text.delta') {
const delta = event.delta;
if (delta) {
yield { type: 'reasoning-delta', delta };
}
}
if (type === 'response.function_call_arguments.delta') {
const idx = event.output_index ?? 0;
const delta = event.delta;
if (toolCallBuffers[idx] && delta) {
toolCallBuffers[idx].arguments += delta;
}
}
if (type === 'response.output_item.done') {
const item = event.item;
if (item?.type === 'function_call') {
const idx = event.output_index ?? 0;
const buf = toolCallBuffers[idx];
if (buf) {
yield {
type: 'tool-call-delta',
id: (item.call_id as string) ?? (item.id as string),
name: buf.name,
argumentsDelta: buf.arguments,
};
}
}
}
if (type === 'response.done' || type === 'response.completed') {
const responseData =
(event.response as unknown as OpenAIResponsesResponse) ??
(event as unknown as OpenAIResponsesResponse);
yield {
type: 'finish',
finishReason: 'stop',
usage: parseTokenUsage(responseData.usage),
};
}
}
}
}
@@ -0,0 +1,130 @@
import type { INodeProperties } from 'n8n-workflow';
export const openAiProperties: INodeProperties[] = [
{
displayName: 'Model',
name: 'model',
type: 'options',
description:
'The model which will generate the completion. <a href="https://beta.openai.com/docs/models/overview">Learn more</a>.',
typeOptions: {
loadOptions: {
routing: {
request: {
method: 'GET',
url: '={{ $parameter.options?.baseURL?.split("/").slice(-1).pop() || $credentials?.url?.split("/").slice(-1).pop() || "v1" }}/models',
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'data',
},
},
{
type: 'setKeyValue',
properties: {
name: '={{$responseItem.id}}',
value: '={{$responseItem.id}}',
},
},
{
type: 'sort',
properties: {
key: 'name',
},
},
],
},
},
},
},
routing: {
send: {
type: 'body',
property: 'model',
},
},
default: 'gpt-5-mini',
},
{
displayName: 'Built-in Tools',
name: 'builtInTools',
placeholder: 'Add Built-in Tool',
type: 'collection',
default: {},
options: [
{
displayName: 'Web Search',
name: 'webSearch',
type: 'collection',
default: { searchContextSize: 'medium' },
options: [
{
displayName: 'Search Context Size',
name: 'searchContextSize',
type: 'options',
default: 'medium',
description:
'High level guidance for the amount of context window space to use for the search',
options: [
{ name: 'Low', value: 'low' },
{ name: 'Medium', value: 'medium' },
{ name: 'High', value: 'high' },
],
},
{
displayName: 'Web Search Allowed Domains',
name: 'allowedDomains',
type: 'string',
default: '',
description:
'Comma-separated list of domains to search. Only domains in this list will be searched.',
placeholder: 'e.g. google.com, wikipedia.org',
},
{
displayName: 'Country',
name: 'country',
type: 'string',
default: '',
placeholder: 'e.g. US, GB',
},
{
displayName: 'City',
name: 'city',
type: 'string',
default: '',
placeholder: 'e.g. New York, London',
},
{
displayName: 'Region',
name: 'region',
type: 'string',
default: '',
placeholder: 'e.g. New York, London',
},
],
},
],
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
description: 'Additional options to add',
type: 'collection',
default: {},
options: [
{
displayName: 'Sampling Temperature',
name: 'temperature',
default: 0.7,
typeOptions: { maxValue: 2, minValue: 0, numberPrecision: 1 },
description:
'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.',
type: 'number',
},
],
},
];
@@ -0,0 +1,52 @@
{
"name": "{{nodePackageName}}",
"version": "0.1.0",
"description": "",
"license": "MIT",
"homepage": "",
"keywords": [
"n8n-community-node-package"
],
"author": {
"name": "{{user.name}}",
"email": "{{user.email}}"
},
"repository": {
"type": "git",
"url": "https://github.com/<...>/n8n-nodes-<...>.git"
},
"scripts": {
"build": "n8n-node build",
"build:watch": "tsc --watch",
"dev": "n8n-node dev",
"lint": "n8n-node lint",
"lint:fix": "n8n-node lint --fix",
"release": "n8n-node release",
"prepublishOnly": "n8n-node prerelease"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"aiNodeSdkVersion": 1,
"strict": true,
"credentials": [
"dist/credentials/ExampleApi.credentials.js"
],
"nodes": [
"dist/nodes/ExampleChatModel/ExampleChatModel.node.js"
]
},
"devDependencies": {
"@n8n/node-cli": "*",
"eslint": "9.32.0",
"prettier": "3.6.2",
"release-it": "^19.0.4",
"typescript": "5.9.2"
},
"peerDependencies": {
"n8n-workflow": "*",
"@n8n/ai-node-sdk": "*"
}
}
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"strict": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2019",
"lib": ["es2019", "es2020", "es2022.error"],
"removeComments": true,
"useUnknownInCatchVariables": false,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"preserveConstEnums": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"incremental": true,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true,
"outDir": "./dist/"
},
"include": ["credentials/**/*", "nodes/**/*", "nodes/**/*.json", "package.json"]
}
@@ -0,0 +1,9 @@
import path from 'node:path';
import { createTemplate } from '../../../../core';
export const customChatModelTemplate = createTemplate({
name: 'Custom chat model node',
description: 'Chat model node with custom implementation',
path: path.join(__dirname, 'template'),
});
@@ -0,0 +1,46 @@
# {{nodePackageName}}
This is an n8n community node. It lets you use _app/service name_ in your n8n workflows.
_App/service name_ is _one or two sentences describing the service this node integrates with_.
[n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/sustainable-use-license/) workflow automation platform.
[Installation](#installation)
[Operations](#operations)
[Credentials](#credentials)
[Compatibility](#compatibility)
[Usage](#usage)
[Resources](#resources)
[Version history](#version-history)
## Installation
Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community nodes documentation.
## Operations
_List the operations supported by your node._
## Credentials
_If users need to authenticate with the app/service, provide details here. You should include prerequisites (such as signing up with the service), available authentication methods, and how to set them up._
## Compatibility
_State the minimum n8n version, as well as which versions you test against. You can also include any known version incompatibility issues._
## Usage
_This is an optional section. Use it to help users with any difficult or confusing aspects of the node._
_By the time users are looking for community nodes, they probably already know n8n basics. But if you expect new users, you can link to the [Try it out](https://docs.n8n.io/try-it-out/) documentation to help them get started._
## Resources
* [n8n community nodes documentation](https://docs.n8n.io/integrations/#community-nodes)
* _Link to app/service documentation._
## Version history
_This is another optional section. If your node has multiple versions, include a short description of available versions and what changed, as well as any compatibility impact._
@@ -0,0 +1,54 @@
import type {
ICredentialDataDecryptedObject,
ICredentialTestRequest,
ICredentialType,
IHttpRequestOptions,
INodeProperties,
Icon,
} from 'n8n-workflow';
export class ExampleApi implements ICredentialType {
name = 'exampleApi';
displayName = 'Example API';
documentationUrl = 'https://github.com/org/repo?tab=readme-ov-file#credentials';
icon: Icon = { light: 'file:../icons/example.svg', dark: 'file:../icons/example.dark.svg' };
properties: INodeProperties[] = [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string',
typeOptions: { password: true },
required: true,
default: '',
},
{
displayName: 'Base URL',
name: 'url',
type: 'string',
default: '',
description: 'Override the default base URL for the API',
},
];
test: ICredentialTestRequest = {
request: {
baseURL: '={{$credentials?.url}}',
url: '/',
},
};
async authenticate(
credentials: ICredentialDataDecryptedObject,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
requestOptions.headers ??= {};
requestOptions.headers['Authorization'] = `Bearer ${credentials.apiKey}`;
return requestOptions;
}
}
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="aquamarine"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-cpu">
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
<rect x="9" y="9" width="6" height="6"></rect>
<line x1="9" y1="1" x2="9" y2="4"></line>
<line x1="15" y1="1" x2="15" y2="4"></line>
<line x1="9" y1="20" x2="9" y2="23"></line>
<line x1="15" y1="20" x2="15" y2="23"></line>
<line x1="20" y1="9" x2="23" y2="9"></line>
<line x1="20" y1="14" x2="23" y2="14"></line>
<line x1="1" y1="9" x2="4" y2="9"></line>
<line x1="1" y1="14" x2="4" y2="14"></line>
</svg>

After

Width:  |  Height:  |  Size: 698 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="darkblue"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-cpu">
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
<rect x="9" y="9" width="6" height="6"></rect>
<line x1="9" y1="1" x2="9" y2="4"></line>
<line x1="15" y1="1" x2="15" y2="4"></line>
<line x1="9" y1="20" x2="9" y2="23"></line>
<line x1="15" y1="20" x2="15" y2="23"></line>
<line x1="20" y1="9" x2="23" y2="9"></line>
<line x1="20" y1="14" x2="23" y2="14"></line>
<line x1="1" y1="9" x2="4" y2="9"></line>
<line x1="1" y1="14" x2="4" y2="14"></line>
</svg>

After

Width:  |  Height:  |  Size: 696 B

@@ -0,0 +1,18 @@
{
"node": "{{nodePackageName}}",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Development", "Developer Tools"],
"resources": {
"credentialDocumentation": [
{
"url": "https://github.com/org/repo?tab=readme-ov-file#credentials"
}
],
"primaryDocumentation": [
{
"url": "https://github.com/org/repo?tab=readme-ov-file"
}
]
}
}
@@ -0,0 +1,113 @@
import type { INodeType, INodeTypeDescription, ISupplyDataFunctions } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { supplyModel } from '@n8n/ai-node-sdk';
import { CustomChatModel } from './model';
type ModelOptions = {
temperature?: number;
};
export class ExampleChatModel implements INodeType {
description: INodeTypeDescription = {
displayName: 'Example Chat Model',
name: 'exampleChatModel',
icon: { light: 'file:../../icons/example.svg', dark: 'file:../../icons/example.dark.svg' },
group: ['transform'],
version: [1],
description: 'Custom Chat Model Node',
defaults: {
name: 'Example Chat Model',
},
codex: {
categories: ['assistant'],
subcategories: {
AI: ['Language Models', 'Root Nodes'],
'Language Models': ['Chat Models (Recommended)'],
},
resources: {
primaryDocumentation: [],
},
},
inputs: [],
outputs: [NodeConnectionTypes.AiLanguageModel],
outputNames: ['Model'],
credentials: [
{
name: 'exampleApi',
required: true,
},
],
properties: [
{
displayName: 'Model',
name: 'model',
type: 'string',
default: '',
description: 'The model which will generate the completion',
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
description: 'Additional options to add',
type: 'collection',
default: {},
options: [
{
displayName: 'Sampling Temperature',
name: 'temperature',
default: 0.7,
typeOptions: { maxValue: 2, minValue: 0, numberPrecision: 1 },
description:
'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.',
type: 'number',
},
],
},
],
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number) {
const credentials = await this.getCredentials('exampleApi');
const modelName = this.getNodeParameter('model', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as ModelOptions;
const model = new CustomChatModel(
modelName,
{
httpRequest: async () => {
// make a request to the API using this.helpers.httpRequestWithAuthentication.call
return {
body: {
response: 'Hello World!',
tokenUsage: {
promptTokens: 10,
completionTokens: 10,
totalTokens: 20,
},
},
};
},
openStream: async () => {
// make a request to the API using this.helpers.httpRequestWithAuthentication.call
const mockStream = (async function* () {
yield 'Hello ';
yield 'World';
yield '!';
})();
return {
body: mockStream,
};
},
},
{
url: credentials.url as string,
temperature: options.temperature,
},
);
return supplyModel(this, model);
}
}
@@ -0,0 +1,115 @@
import type { IHttpRequestMethods } from 'n8n-workflow';
import {
BaseChatModel,
type ChatModelConfig,
type GenerateResult,
type Message,
type StreamChunk,
} from '@n8n/ai-node-sdk';
interface ModelConfig extends ChatModelConfig {
url: string;
}
interface ProviderResponse {
id?: string;
response: string;
tokenUsage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
}
interface RequestConfig {
httpRequest: (
method: IHttpRequestMethods,
url: string,
body?: object,
headers?: Record<string, string>,
) => Promise<{ body: unknown }>;
openStream: (
method: IHttpRequestMethods,
url: string,
body?: object,
headers?: Record<string, string>,
) => Promise<{ body: AsyncIterableIterator<string> }>;
}
export class CustomChatModel extends BaseChatModel<ModelConfig> {
private baseURL: string;
constructor(
modelId: string = 'my-model',
private requests: RequestConfig,
config: ModelConfig,
) {
super('custom-provider', modelId, config);
this.baseURL = config.url;
}
async generate(messages: Message[], config?: ModelConfig): Promise<GenerateResult> {
const merged = this.mergeConfig(config);
// Convert n8n messages to provider format
const providerMessages = messages.map((m) => ({
role: m.role,
content: m.content
.filter((c) => c.type === 'text')
.map((c) => c.text)
.join('\n'),
}));
const requestBody = {
model: this.modelId,
messages: providerMessages,
temperature: merged.temperature,
};
const response = await this.requests.httpRequest(
'POST',
`${this.baseURL}/generate`,
requestBody,
);
const body = response.body as ProviderResponse;
// Convert provider response to n8n message
const message: Message = {
role: 'assistant',
content: [{ type: 'text', text: body.response }],
};
return {
id: body.id,
finishReason: 'stop',
usage: {
promptTokens: body.tokenUsage?.promptTokens ?? 0,
completionTokens: body.tokenUsage?.completionTokens ?? 0,
totalTokens: body.tokenUsage?.totalTokens ?? 0,
},
message,
};
}
async *stream(messages: Message[], config?: ModelConfig): AsyncIterable<StreamChunk> {
const merged = this.mergeConfig(config);
// Convert n8n messages to provider format
const providerMessages = messages.map((m) => ({
role: m.role,
content: m.content
.filter((c) => c.type === 'text')
.map((c) => c.text)
.join('\n'),
}));
const requestBody = {
model: this.modelId,
messages: providerMessages,
temperature: merged.temperature,
};
const response = await this.requests.openStream('POST', `${this.baseURL}/stream`, requestBody);
for await (const chunk of response.body) {
yield { type: 'text-delta', delta: chunk };
}
yield { type: 'finish', finishReason: 'stop' };
}
}
@@ -0,0 +1,52 @@
{
"name": "{{nodePackageName}}",
"version": "0.1.0",
"description": "",
"license": "MIT",
"homepage": "",
"keywords": [
"n8n-community-node-package"
],
"author": {
"name": "{{user.name}}",
"email": "{{user.email}}"
},
"repository": {
"type": "git",
"url": "https://github.com/<...>/n8n-nodes-<...>.git"
},
"scripts": {
"build": "n8n-node build",
"build:watch": "tsc --watch",
"dev": "n8n-node dev",
"lint": "n8n-node lint",
"lint:fix": "n8n-node lint --fix",
"release": "n8n-node release",
"prepublishOnly": "n8n-node prerelease"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"aiNodeSdkVersion": 1,
"strict": true,
"credentials": [
"dist/credentials/ExampleApi.credentials.js"
],
"nodes": [
"dist/nodes/ExampleChatModel/ExampleChatModel.node.js"
]
},
"devDependencies": {
"@n8n/node-cli": "*",
"eslint": "9.32.0",
"prettier": "3.6.2",
"release-it": "^19.0.4",
"typescript": "5.9.2"
},
"peerDependencies": {
"n8n-workflow": "*",
"@n8n/ai-node-sdk": "*"
}
}
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"strict": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2019",
"lib": ["es2019", "es2020", "es2022.error"],
"removeComments": true,
"useUnknownInCatchVariables": false,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"preserveConstEnums": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"incremental": true,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true,
"outDir": "./dist/"
},
"include": ["credentials/**/*", "nodes/**/*", "nodes/**/*.json", "package.json"]
}
@@ -0,0 +1,9 @@
import path from 'node:path';
import { createTemplate } from '../../../../core';
export const openaiChatModelTemplate = createTemplate({
name: 'OpenAI compatible chat model node',
description: 'Chat model node for OpenAI-compatible providers',
path: path.join(__dirname, 'template'),
});
@@ -0,0 +1,46 @@
# {{nodePackageName}}
This is an n8n community node. It lets you use _app/service name_ in your n8n workflows.
_App/service name_ is _one or two sentences describing the service this node integrates with_.
[n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/sustainable-use-license/) workflow automation platform.
[Installation](#installation)
[Operations](#operations)
[Credentials](#credentials)
[Compatibility](#compatibility)
[Usage](#usage)
[Resources](#resources)
[Version history](#version-history)
## Installation
Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community nodes documentation.
## Operations
_List the operations supported by your node._
## Credentials
_If users need to authenticate with the app/service, provide details here. You should include prerequisites (such as signing up with the service), available authentication methods, and how to set them up._
## Compatibility
_State the minimum n8n version, as well as which versions you test against. You can also include any known version incompatibility issues._
## Usage
_This is an optional section. Use it to help users with any difficult or confusing aspects of the node._
_By the time users are looking for community nodes, they probably already know n8n basics. But if you expect new users, you can link to the [Try it out](https://docs.n8n.io/try-it-out/) documentation to help them get started._
## Resources
* [n8n community nodes documentation](https://docs.n8n.io/integrations/#community-nodes)
* _Link to app/service documentation._
## Version history
_This is another optional section. If your node has multiple versions, include a short description of available versions and what changed, as well as any compatibility impact._

Some files were not shown because too many files have changed in this diff Show More