feat: Add strict mode and cloud lint rules to @n8n/node-cli (#20142)

This commit is contained in:
Elias Meire
2025-10-10 19:26:35 +02:00
committed by GitHub
parent 9d4db4b658
commit b1baca5c6c
45 changed files with 1981 additions and 198 deletions
+8
View File
@@ -160,6 +160,14 @@ Validates:
- Common integration issues
- Cloud publication readiness
### Cloud support
```bash
npx n8n-node cloud-support
```
Manage n8n Cloud publication eligibility. In strict mode, your node must use the default ESLint config and pass all community node rules to be eligible for n8n Cloud publication.
Fix issues automatically:
```bash
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@n8n/create-node",
"version": "0.11.0",
"version": "0.12.0",
"description": "Official CLI to create new community nodes for n8n",
"bin": {
"create-node": "bin/create-node.cjs"
@@ -1,7 +1,9 @@
{
"name": "@n8n/eslint-plugin-community-nodes",
"type": "module",
"version": "0.4.0",
"version": "0.5.0",
"main": "./dist/plugin.js",
"types": "./dist/plugin.d.ts",
"exports": {
".": {
"types": "./dist/plugin.d.ts",
@@ -8,7 +8,7 @@ const plugin = {
meta: {
name: pkg.name,
version: pkg.version,
namespace: 'n8n-community-nodes',
namespace: '@n8n/eslint-plugin-community-nodes',
},
// @ts-expect-error Rules type does not match for typescript-eslint and eslint
rules: rules as ESLint.Plugin['rules'],
@@ -18,35 +18,35 @@ const configs = {
recommended: {
ignores: ['eslint.config.{js,mjs,ts,mts}'],
plugins: {
'n8n-community-nodes': plugin,
'@n8n/eslint-plugin-community-nodes': plugin,
},
rules: {
'n8n-community-nodes/no-restricted-globals': 'error',
'n8n-community-nodes/no-restricted-imports': 'error',
'n8n-community-nodes/credential-password-field': 'error',
'n8n-community-nodes/no-deprecated-workflow-functions': 'error',
'n8n-community-nodes/node-usable-as-tool': 'error',
'n8n-community-nodes/package-name-convention': 'error',
'n8n-community-nodes/credential-test-required': 'error',
'n8n-community-nodes/no-credential-reuse': 'error',
'n8n-community-nodes/icon-validation': 'error',
'n8n-community-nodes/resource-operation-pattern': 'warn',
'@n8n/eslint-plugin-community-nodes/no-restricted-globals': 'error',
'@n8n/eslint-plugin-community-nodes/no-restricted-imports': 'error',
'@n8n/eslint-plugin-community-nodes/credential-password-field': 'error',
'@n8n/eslint-plugin-community-nodes/no-deprecated-workflow-functions': 'error',
'@n8n/eslint-plugin-community-nodes/node-usable-as-tool': 'error',
'@n8n/eslint-plugin-community-nodes/package-name-convention': 'error',
'@n8n/eslint-plugin-community-nodes/credential-test-required': 'error',
'@n8n/eslint-plugin-community-nodes/no-credential-reuse': 'error',
'@n8n/eslint-plugin-community-nodes/icon-validation': 'error',
'@n8n/eslint-plugin-community-nodes/resource-operation-pattern': 'warn',
},
},
recommendedWithoutN8nCloudSupport: {
ignores: ['eslint.config.{js,mjs,ts,mts}'],
plugins: {
'n8n-community-nodes': plugin,
'@n8n/eslint-plugin-community-nodes': plugin,
},
rules: {
'n8n-community-nodes/credential-password-field': 'error',
'n8n-community-nodes/no-deprecated-workflow-functions': 'error',
'n8n-community-nodes/node-usable-as-tool': 'error',
'n8n-community-nodes/package-name-convention': 'error',
'n8n-community-nodes/credential-test-required': 'error',
'n8n-community-nodes/no-credential-reuse': 'error',
'n8n-community-nodes/icon-validation': 'error',
'n8n-community-nodes/resource-operation-pattern': 'warn',
'@n8n/eslint-plugin-community-nodes/credential-password-field': 'error',
'@n8n/eslint-plugin-community-nodes/no-deprecated-workflow-functions': 'error',
'@n8n/eslint-plugin-community-nodes/node-usable-as-tool': 'error',
'@n8n/eslint-plugin-community-nodes/package-name-convention': 'error',
'@n8n/eslint-plugin-community-nodes/credential-test-required': 'error',
'@n8n/eslint-plugin-community-nodes/no-credential-reuse': 'error',
'@n8n/eslint-plugin-community-nodes/icon-validation': 'error',
'@n8n/eslint-plugin-community-nodes/resource-operation-pattern': 'warn',
},
},
} satisfies Record<string, Linter.Config>;
@@ -147,7 +147,8 @@ export function validateIconPath(
const isFile = iconPath.startsWith('file:');
const relativePath = iconPath.replace(/^file:/, '');
const isSvg = relativePath.endsWith('.svg');
const fullPath = safeJoinPath(baseDir, relativePath);
// Should not use safeJoinPath here because iconPath can be outside of the node class folder
const fullPath = path.join(baseDir, relativePath);
const exists = existsSync(fullPath);
return {
+17
View File
@@ -148,6 +148,23 @@ n8n-node lint
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.
+2 -2
View File
@@ -5,9 +5,9 @@ export default defineConfig(
globalIgnores(['src/template/templates/**/template', 'src/template/templates/shared']),
nodeConfig,
{
ignores: ['**/*.test.ts'],
files: ['**/*.test.ts', 'src/test-utils/**/*'],
rules: {
'import-x/no-extraneous-dependencies': ['error', { devDependencies: false }],
'import-x/no-extraneous-dependencies': ['error', { devDependencies: true }],
},
},
{
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@n8n/node-cli",
"version": "0.11.0",
"version": "0.12.0",
"description": "Official CLI for developing community nodes for n8n",
"bin": {
"n8n-node": "bin/n8n-node.mjs"
@@ -45,12 +45,12 @@
},
"dependencies": {
"@clack/prompts": "^0.11.0",
"@n8n/eslint-plugin-community-nodes": "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.3",
"@n8n/eslint-plugin-community-nodes": "workspace:*",
"fast-glob": "catalog:",
"handlebars": "4.7.8",
"picocolors": "catalog:",
@@ -67,6 +67,7 @@
"@oclif/test": "^4.1.13",
"eslint": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
},
"peerDependencies": {
@@ -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,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, 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(picocolors.inverse(' 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(picocolors.inverse(' 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(picocolors.inverse(' 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,27 @@
import { intro } from '@clack/prompts';
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('dev command', () => {
beforeEach(() => {
vi.clearAllMocks();
});
tmpdirTest(
'successful dev setup with external-n8n flag - links node and starts watcher',
async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'test-custom-node' },
});
mockSpawn('pnpm', ['link'], { exitCode: 0 });
await expect(CommandTester.run('dev --external-n8n')).rejects.toThrow('EEXIT: 0');
expect(intro).toHaveBeenCalledWith(expect.stringContaining('n8n-node dev'));
},
);
});
@@ -1,5 +1,5 @@
/* eslint-disable no-control-regex */
import { type ChildProcess, spawn } from 'child_process';
import { type ChildProcess, spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import type { Formatter } from 'picocolors/types';
@@ -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/eslint-plugin-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');
});
});
+107 -2
View File
@@ -1,9 +1,17 @@
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 } 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.';
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 }),
@@ -12,16 +20,33 @@ export default class Lint extends Command {
async run(): Promise<void> {
const { flags } = await this.parse(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: 'inherit' });
await runCommand('eslint', args, {
context: 'local',
stdio: 'pipe',
env: { ...process.env, FORCE_COLOR: '1' },
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 {
@@ -31,4 +56,84 @@ export default class Lint extends Command {
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/eslint-plugin-community-nodes/no-restricted-globals',
'@n8n/eslint-plugin-community-nodes/no-restricted-imports',
];
return cloudOnlyRules.some((rule) => errorMessage.includes(rule));
}
}
@@ -0,0 +1,293 @@
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',
},
]);
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',
);
});
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
}
},
);
});
@@ -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();
});
});
@@ -15,7 +15,7 @@ export default class Prerelease extends Command {
const packageManager = (await detectPackageManager()) ?? 'npm';
if (!process.env.RELEASE_MODE) {
console.log(`Run \`${packageManager} run release\` to publish the package`);
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');
});
});
+57 -46
View File
@@ -1,59 +1,70 @@
// Included with peer dependency eslint
// eslint-disable-next-line import-x/no-extraneous-dependencies
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';
export const config: ConfigArray = tseslint.config(
globalIgnores(['dist']),
{
files: ['**/*.ts'],
extends: [
eslint.configs.recommended,
tseslint.configs.recommended,
importPlugin.configs['flat/recommended'],
],
rules: {
'prefer-spread': 'off',
},
},
{
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'],
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',
},
},
},
{
files: ['./credentials/**/*.ts'],
rules: {
...n8nNodesPlugin.configs.credentials.rules,
'n8n-nodes-base/cred-class-field-documentation-url-miscased': 'off',
{
plugins: { 'n8n-nodes-base': n8nNodesPlugin },
settings: {
'import-x/resolver-next': [createTypeScriptImportResolver()],
},
},
},
{
files: ['./nodes/**/*.ts'],
rules: {
...n8nNodesPlugin.configs.nodes.rules,
'n8n-nodes-base/node-class-description-inputs-wrong-regular-node': 'off',
'n8n-nodes-base/node-class-description-outputs-wrong': 'off',
'n8n-nodes-base/node-param-type-options-max-value-present': 'off',
{
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;
+3
View File
@@ -1,4 +1,5 @@
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';
@@ -12,4 +13,6 @@ export const commands = {
prerelease: Prerelease,
release: Release,
lint: Lint,
// eslint-disable-next-line @typescript-eslint/naming-convention
'cloud-support': CloudSupport,
};
@@ -1,9 +1,9 @@
{
"name": "{{nodePackageName}}",
"version": "0.1.0",
"description": "n8n community node to work with the Example API",
"description": "",
"license": "MIT",
"homepage": "https://example.com",
"homepage": "",
"keywords": [
"n8n-community-node-package"
],
@@ -13,7 +13,7 @@
},
"repository": {
"type": "git",
"url": ""
"url": "https://github.com/<...>/n8n-nodes-<...>.git"
},
"scripts": {
"build": "n8n-node build",
@@ -29,6 +29,7 @@
],
"n8n": {
"n8nNodesApiVersion": 1,
"strict": true,
"credentials": [],
"nodes": [
"dist/nodes/Example/Example.node.js"
@@ -1,3 +0,0 @@
import { config } from '@n8n/node-cli/eslint';
export default config;
@@ -1,9 +1,9 @@
{
"name": "{{nodePackageName}}",
"version": "0.1.0",
"description": "n8n community node to work with the GitHub Issues API",
"description": "",
"license": "MIT",
"homepage": "https://example.com",
"homepage": "",
"keywords": [
"n8n-community-node-package"
],
@@ -13,7 +13,7 @@
},
"repository": {
"type": "git",
"url": ""
"url": "https://github.com/<...>/n8n-nodes-<...>.git"
},
"scripts": {
"build": "n8n-node build",
@@ -29,6 +29,7 @@
],
"n8n": {
"n8nNodesApiVersion": 1,
"strict": true,
"credentials": [
"dist/credentials/GithubIssuesApi.credentials.js",
"dist/credentials/GithubIssuesOAuth2Api.credentials.js"
@@ -1,3 +0,0 @@
import { config } from '@n8n/node-cli/eslint';
export default config;
@@ -1,9 +1,9 @@
{
"name": "{{nodePackageName}}",
"version": "0.1.0",
"description": "Example n8n community node",
"description": "",
"license": "MIT",
"homepage": "https://example.com",
"homepage": "",
"keywords": [
"n8n-community-node-package"
],
@@ -13,7 +13,7 @@
},
"repository": {
"type": "git",
"url": ""
"url": "https://github.com/<...>/n8n-nodes-<...>.git"
},
"scripts": {
"build": "n8n-node build",
@@ -29,6 +29,7 @@
],
"n8n": {
"n8nNodesApiVersion": 1,
"strict": true,
"credentials": [],
"nodes": [
"dist/nodes/Example/Example.node.js"
@@ -0,0 +1,49 @@
import { log } from '@clack/prompts';
import type { Config } from '@oclif/core';
import { mock } from 'vitest-mock-extended';
import { commands } from '../index';
function isValidCommand(commandName: string): commandName is keyof typeof commands {
return commandName in commands;
}
export type LogLevel = 'success' | 'warning' | 'error' | 'info';
export interface CommandResult {
getLogMessages(type: LogLevel): string[];
}
export class CommandTester {
static async run(commandLine: string): Promise<CommandResult> {
const argv = commandLine.trim().split(/\s+/);
const [commandName, ...restArgv] = argv;
if (!isValidCommand(commandName)) {
throw new Error(
`Unknown command: ${commandName}. Available: ${Object.keys(commands).join(', ')}`,
);
}
const CommandClass = commands[commandName];
const command = new CommandClass(
restArgv,
mock<Config>({
root: process.cwd(),
name: '@n8n/node-cli',
version: '1.0.0',
runHook: async () => await Promise.resolve({ successes: [], failures: [] }),
}),
);
await command.run();
return {
getLogMessages(type: LogLevel): string[] {
const mockFn = vi.mocked(log[type]);
return mockFn.mock.calls?.map((call) => call[0]) ?? [];
},
};
}
}
@@ -0,0 +1,12 @@
export { CommandTester, type CommandResult, type LogLevel } from './command-tester';
export {
mockSpawn,
mockExecSync,
type MockChildProcess,
type MockSpawnOptions,
type CommandMockConfig,
type ExecSyncMockConfig,
} from './mock-child-process';
export { tmpdirTest } from './temp-fs';
export { MockPrompt } from './mock-prompts';
export { setupTestPackage, type PackageSetupOptions } from './package-setup';
@@ -0,0 +1,190 @@
import fsSync from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { expect } from 'vitest';
import type { CommandResult } from './command-tester';
import type { MockPrompt } from './mock-prompts';
import { isEnoentError } from '../utils/validation';
export function stripAnsiCodes(text: string): string {
// Need to strip ANSI escape codes for colors and styles
// eslint-disable-next-line no-control-regex
return text.replace(/\u001b\[.*?m/g, '');
}
function createLogMatcher(logLevel: 'success' | 'warning' | 'error') {
return function (received: CommandResult, expected: string) {
const messages = received.getLogMessages(logLevel);
const cleanMessages = messages.map(stripAnsiCodes);
const hasMessage = cleanMessages.some((msg) => msg.includes(expected));
return {
pass: hasMessage,
message: () =>
hasMessage
? `Expected command NOT to log ${logLevel} message containing "${expected}"`
: `Expected command to log ${logLevel} message containing "${expected}". Got: ${cleanMessages.join(', ')}`,
};
};
}
expect.extend({
toHaveLoggedSuccess: createLogMatcher('success'),
toHaveLoggedWarning: createLogMatcher('warning'),
toHaveLoggedError: createLogMatcher('error'),
toHaveFile(received: string, filename: string) {
const fullPath = path.resolve(received, filename);
const exists = fsSync.existsSync(fullPath);
return {
pass: exists,
message: () =>
exists
? `Expected file "${filename}" NOT to exist`
: `Expected file "${filename}" to exist`,
};
},
async toHaveFileEqual(received: string, filename: string, expectedContent?: string) {
const fullPath = path.resolve(received, filename);
let content: string | undefined;
try {
content = await fs.readFile(fullPath, 'utf8');
} catch (error) {
if (isEnoentError(error)) {
content = undefined;
} else {
throw error;
}
}
if (content === undefined) {
return {
pass: false,
message: () => `Expected file "${filename}" to exist`,
};
}
if (expectedContent !== undefined && content !== expectedContent) {
return {
pass: false,
message: () =>
`Expected file "${filename}" to have content "${expectedContent}". Got: "${content}"`,
};
}
return {
pass: true,
message: () =>
expectedContent !== undefined
? `Expected file "${filename}" NOT to have content "${expectedContent}"`
: `Expected file "${filename}" NOT to exist`,
};
},
async toHaveFileContaining(received: string, filename: string, text: string) {
const fullPath = path.resolve(received, filename);
let content: string | undefined;
try {
content = await fs.readFile(fullPath, 'utf8');
} catch (error) {
if (isEnoentError(error)) {
content = undefined;
} else {
throw error;
}
}
const contains = content?.includes(text) ?? false;
return {
pass: contains,
message: () =>
contains
? `Expected file "${filename}" NOT to contain "${text}"`
: `Expected file "${filename}" to contain "${text}". File content: "${content ?? 'File not found'}"`,
};
},
async toHaveFileMatchingPattern(received: string, filename: string, pattern: RegExp) {
const fullPath = path.resolve(received, filename);
let content: string | undefined;
try {
content = await fs.readFile(fullPath, 'utf8');
} catch (error) {
if (isEnoentError(error)) {
content = undefined;
} else {
throw error;
}
}
const matches = content ? new RegExp(pattern).test(content) : false;
return {
pass: matches,
message: () =>
matches
? `Expected file "${filename}" NOT to match pattern ${pattern.toString()}`
: `Expected file "${filename}" to match pattern ${pattern.toString()}. File content: "${content ?? 'File not found'}"`,
};
},
toNotHaveFile(received: string, filename: string) {
const fullPath = path.resolve(received, filename);
const exists = fsSync.existsSync(fullPath);
return {
pass: !exists,
message: () =>
exists
? `Expected file "${filename}" NOT to exist`
: `Expected file "${filename}" to exist`,
};
},
toHaveAskedAllQuestions(received: typeof MockPrompt) {
const questionAnswers = received['questionAnswers'];
const askedQuestions = received['askedQuestions'];
const expectedQuestions = Array.from(questionAnswers.keys());
const askedQuestionsArray = Array.from(askedQuestions);
const unaskedQuestions = expectedQuestions.filter((q) => !askedQuestions.has(q));
const allAsked = unaskedQuestions.length === 0;
return {
pass: allAsked,
message: () =>
allAsked
? 'Expected some questions to remain unasked'
: `Expected questions were not asked: ${unaskedQuestions.join(', ')}\nExpected: [${expectedQuestions.join(', ')}]\nAsked: [${askedQuestionsArray.join(', ')}]`,
};
},
toHaveAskedQuestion(received: typeof MockPrompt, question: string) {
const askedQuestions = received.getAskedQuestions();
const wasAsked = askedQuestions.includes(question);
return {
pass: wasAsked,
message: () =>
wasAsked
? `Expected question "${question}" NOT to have been asked`
: `Expected question "${question}" to have been asked. Asked questions: [${askedQuestions.join(', ')}]`,
};
},
});
declare module 'vitest' {
interface Assertion<T> {
toHaveLoggedSuccess(message: string): T;
toHaveLoggedWarning(message: string): T;
toHaveLoggedError(message: string): T;
toHaveFile(filename: string): T;
toHaveFileEqual(filename: string, expectedContent?: string): Promise<T>;
toHaveFileContaining(filename: string, text: string): Promise<T>;
toHaveFileMatchingPattern(filename: string, pattern: RegExp): Promise<T>;
toNotHaveFile(filename: string): T;
toHaveAskedAllQuestions(): T;
toHaveAskedQuestion(question: string): T;
}
}
@@ -0,0 +1,122 @@
import { spawn, execSync, type ChildProcess } from 'node:child_process';
import { EventEmitter } from 'node:events';
export interface MockChildProcess extends EventEmitter {
stdout: EventEmitter | null;
stderr: EventEmitter | null;
}
export interface MockSpawnOptions {
exitCode?: number;
signal?: NodeJS.Signals;
stdout?: string;
stderr?: string;
error?: string;
}
export interface CommandMockConfig {
command: string;
args: string[];
options?: MockSpawnOptions;
}
function createMockProcess(): ChildProcess {
const emitter = new EventEmitter();
const mockProcess: MockChildProcess = Object.assign(emitter, {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
});
return mockProcess as unknown as ChildProcess;
}
function emitProcessEvents(mockProcess: MockChildProcess, options: MockSpawnOptions): void {
const {
exitCode = options.signal ? null : 0,
signal = null,
stdout = '',
stderr = '',
error,
} = options;
setImmediate(() => {
if (error) {
mockProcess.emit('error', new Error(error));
setImmediate(() => {
mockProcess.emit('close', exitCode !== 0 ? exitCode : 1, signal);
});
return;
}
if (stdout && mockProcess.stdout) {
mockProcess.stdout.emit('data', Buffer.from(stdout));
}
if (stderr && mockProcess.stderr) {
mockProcess.stderr.emit('data', Buffer.from(stderr));
}
mockProcess.emit('close', exitCode, signal);
});
}
export function mockSpawn(command: string, args: string[], options?: MockSpawnOptions): void;
export function mockSpawn(commands: CommandMockConfig[]): void;
export function mockSpawn(
commandOrCommands: string | CommandMockConfig[],
args?: string[],
options?: MockSpawnOptions,
): void {
if (Array.isArray(commandOrCommands)) {
const commands = commandOrCommands;
let callIndex = 0;
vi.mocked(spawn).mockImplementation((cmd, cmdArgs): ChildProcess => {
if (callIndex >= commands.length) {
throw new Error(`Unexpected spawn call: ${cmd} ${cmdArgs?.join(' ')}`);
}
const expectedConfig = commands[callIndex];
expect(cmd).toBe(expectedConfig.command);
expect(cmdArgs).toEqual(expectedConfig.args);
const mockProcess = createMockProcess();
const options = expectedConfig.options ?? {};
emitProcessEvents(mockProcess, options);
callIndex++;
return mockProcess;
});
} else {
const command = commandOrCommands;
if (!args) throw new Error('args required for single command mock');
vi.mocked(spawn).mockImplementation((cmd, cmdArgs): ChildProcess => {
expect(cmd).toBe(command);
expect(cmdArgs).toEqual(args);
const mockProcess = createMockProcess();
const mockOptions = options ?? {};
emitProcessEvents(mockProcess, mockOptions);
return mockProcess;
});
}
}
export interface ExecSyncMockConfig {
command: string;
result: string;
}
export function mockExecSync(configs: ExecSyncMockConfig[]): void {
const configMap = new Map(configs.map((c) => [c.command, c.result]));
vi.mocked(execSync).mockImplementation((command) => {
const result = configMap.get(String(command));
if (result === undefined) {
throw new Error(`Unexpected execSync call: ${command}`);
}
return Buffer.from(result);
});
}
@@ -0,0 +1,87 @@
import { confirm, isCancel, text, select } from '@clack/prompts';
interface PromptConfig {
message: string;
placeholder?: string;
defaultValue?: string;
options?: Array<{ label: string; value: unknown; hint?: string }>;
}
type PromptAnswer<T = unknown> = T | 'CANCEL';
interface QuestionAnswerPair<T = unknown> {
question: string | Partial<PromptConfig>;
answer: PromptAnswer<T>;
}
export class MockPrompt {
private static readonly questionAnswers = new Map<string, PromptAnswer>();
private static readonly askedQuestions = new Set<string>();
static setup(pairs: QuestionAnswerPair[]): void {
MockPrompt.reset();
for (const { question, answer } of pairs) {
const key = typeof question === 'string' ? question : question.message!;
MockPrompt.questionAnswers.set(key, answer);
}
MockPrompt.setupMocks();
}
static reset(): void {
vi.mocked(confirm).mockReset();
vi.mocked(text).mockReset();
vi.mocked(select).mockReset();
vi.mocked(isCancel).mockReset();
MockPrompt.questionAnswers.clear();
MockPrompt.askedQuestions.clear();
}
static getAskedQuestions(): string[] {
return Array.from(MockPrompt.askedQuestions);
}
private static setupMocks(): void {
vi.mocked(select).mockImplementation(async (config) => {
MockPrompt.askedQuestions.add(config.message);
const answer = MockPrompt.questionAnswers.get(config.message);
if (answer === undefined) {
throw new Error(`No mock answer configured for select question: "${config.message}"`);
}
if (answer === 'CANCEL') {
return await Promise.resolve(Symbol('cancel'));
}
return answer;
});
vi.mocked(text).mockImplementation(async (config) => {
MockPrompt.askedQuestions.add(config.message);
const answer = MockPrompt.questionAnswers.get(config.message);
if (answer === undefined) {
throw new Error(`No mock answer configured for text question: "${config.message}"`);
}
if (answer === 'CANCEL') {
return await Promise.resolve(Symbol('cancel'));
}
// eslint-disable-next-line @typescript-eslint/no-base-to-string
return String(answer);
});
vi.mocked(confirm).mockImplementation(async (config) => {
MockPrompt.askedQuestions.add(config.message);
const answer = MockPrompt.questionAnswers.get(config.message);
if (answer === undefined) {
throw new Error(`No mock answer configured for confirm question: "${config.message}"`);
}
if (answer === 'CANCEL') {
return await Promise.resolve(Symbol('cancel'));
}
return Boolean(answer);
});
vi.mocked(isCancel).mockImplementation((value) => {
return typeof value === 'symbol' && value.description === 'cancel';
});
}
}
@@ -0,0 +1,41 @@
import fs from 'node:fs/promises';
import type { N8nPackageJson } from '../utils/package';
export interface PackageSetupOptions {
packageJson?: Partial<N8nPackageJson>;
eslintConfig?: string | boolean;
}
const DEFAULT_PACKAGE_CONFIG: N8nPackageJson = {
name: 'test-node',
version: '1.0.0',
n8n: {
nodes: ['dist/nodes/TestNode.node.js'],
strict: true,
},
};
const DEFAULT_ESLINT_CONFIG =
"import { config } from '@n8n/node-cli/eslint';\n\nexport default config;\n";
export async function setupTestPackage(
tmpdir: string,
options: PackageSetupOptions = {},
): Promise<void> {
const packageConfig = {
...DEFAULT_PACKAGE_CONFIG,
...options.packageJson,
n8n: {
...DEFAULT_PACKAGE_CONFIG.n8n,
...options.packageJson?.n8n,
},
};
await fs.writeFile(`${tmpdir}/package.json`, JSON.stringify(packageConfig, null, 2));
if (options.eslintConfig === true) {
await fs.writeFile(`${tmpdir}/eslint.config.mjs`, DEFAULT_ESLINT_CONFIG);
} else if (typeof options.eslintConfig === 'string') {
await fs.writeFile(`${tmpdir}/eslint.config.mjs`, options.eslintConfig);
}
}
@@ -0,0 +1,28 @@
import './matchers';
vi.mock('node:child_process');
vi.mock('@clack/prompts', () => ({
intro: vi.fn(),
outro: vi.fn(),
cancel: vi.fn(),
note: vi.fn(),
log: {
success: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
spinner: vi.fn(() => ({
start: vi.fn(),
stop: vi.fn(),
message: vi.fn(),
})),
confirm: vi.fn(),
text: vi.fn(),
select: vi.fn(),
isCancel: vi.fn(),
}));
vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
throw new Error(`EEXIT: ${code ?? 0}`);
});
@@ -0,0 +1,30 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { test } from 'vitest';
async function createTempDir(): Promise<string> {
const ostmpdir = os.tmpdir();
const tmpdir = path.join(ostmpdir, 'n8n-node-cli-test-');
return await fs.mkdtemp(tmpdir);
}
interface TmpDirFixture {
tmpdir: string;
}
export const tmpdirTest = test.extend<TmpDirFixture>({
tmpdir: async ({ expect: _expect }, use) => {
const directory = await createTempDir();
const originalCwd = process.cwd();
process.chdir(directory);
try {
await use(directory);
} finally {
process.chdir(originalCwd);
await fs.rm(directory, { recursive: true, force: true });
}
},
});
@@ -66,10 +66,10 @@ export async function runCommand(
});
child.on('close', (code, signal) => {
printOutput();
if (code === 0) {
resolve();
} else {
printOutput();
reject(
new ChildProcessError(
`${cmd} exited with code ${code}${signal ? ` (signal: ${signal})` : ''}`,
@@ -0,0 +1,29 @@
import picocolors from 'picocolors';
import { detectPackageManager } from './package-manager';
type ExecCommandType = 'cli' | 'script';
export async function getExecCommand(type: ExecCommandType = 'cli'): Promise<string> {
const packageManager = (await detectPackageManager()) ?? 'npm';
if (type === 'script') {
return packageManager === 'npm' ? 'npm run' : packageManager;
}
return packageManager === 'npm' ? 'npx' : packageManager;
}
export function formatCommand(command: string): string {
return picocolors.cyan(command);
}
export async function suggestCloudSupportCommand(action: 'enable' | 'disable'): Promise<string> {
const execCommand = await getExecCommand('cli');
return formatCommand(`${execCommand} n8n-node cloud-support ${action}`);
}
export async function suggestLintCommand(): Promise<string> {
const execCommand = await getExecCommand('script');
return formatCommand(`${execCommand} lint`);
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { execSync } from 'child_process';
import { execSync } from 'node:child_process';
import { tryReadGitUser } from './git';
vi.mock('child_process');
vi.mock('node:child_process');
describe('git utils', () => {
describe('tryReadGitUser', () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { execSync } from 'child_process';
import { execSync } from 'node:child_process';
import { runCommand } from './child-process';
@@ -1,14 +1,7 @@
import type { Stats } from 'node:fs';
import fs from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { detectPackageManager, detectPackageManagerFromUserAgent } from './package-manager';
// Mock dependencies
vi.mock('node:child_process');
vi.mock('node:fs/promises');
vi.mock('@clack/prompts');
import { tmpdirTest } from '../test-utils/temp-fs';
describe('package manager utils', () => {
const originalEnv = process.env;
@@ -95,98 +88,73 @@ describe('package manager utils', () => {
const result = await detectPackageManager();
expect(result).toBe('pnpm');
expect(vi.mocked(fs).stat).not.toHaveBeenCalled();
});
it('detects npm from package-lock.json when user agent is not available', async () => {
tmpdirTest(
'detects npm from package-lock.json when user agent is not available',
async ({ tmpdir }) => {
delete process.env.npm_config_user_agent;
await fs.writeFile(`${tmpdir}/package-lock.json`, '{}');
const result = await detectPackageManager();
expect(result).toBe('npm');
},
);
tmpdirTest(
'detects yarn from yarn.lock when user agent is not available',
async ({ tmpdir }) => {
delete process.env.npm_config_user_agent;
await fs.writeFile(`${tmpdir}/yarn.lock`, '');
const result = await detectPackageManager();
expect(result).toBe('yarn');
},
);
tmpdirTest(
'detects pnpm from pnpm-lock.yaml when user agent is not available',
async ({ tmpdir }) => {
delete process.env.npm_config_user_agent;
await fs.writeFile(`${tmpdir}/pnpm-lock.yaml`, '');
const result = await detectPackageManager();
expect(result).toBe('pnpm');
},
);
tmpdirTest('prioritizes npm lock file when multiple lock files exist', async ({ tmpdir }) => {
delete process.env.npm_config_user_agent;
vi.mocked(fs).stat.mockImplementation(async (path) => {
if (path === 'package-lock.json') {
const stats = mock<Stats>();
stats.isFile.mockReturnValue(true);
return await Promise.resolve(stats);
}
throw new Error('File not found');
});
const result = await detectPackageManager();
expect(result).toBe('npm');
expect(vi.mocked(fs).stat).toHaveBeenCalledWith('package-lock.json');
});
it('detects yarn from yarn.lock when user agent is not available', async () => {
delete process.env.npm_config_user_agent;
vi.mocked(fs).stat.mockImplementation(async (path) => {
if (path === 'yarn.lock') {
const stats = mock<Stats>();
stats.isFile.mockReturnValue(true);
return await Promise.resolve(stats);
}
throw new Error('File not found');
});
const result = await detectPackageManager();
expect(result).toBe('yarn');
expect(vi.mocked(fs).stat).toHaveBeenCalledWith('package-lock.json');
expect(vi.mocked(fs).stat).toHaveBeenCalledWith('yarn.lock');
});
it('detects pnpm from pnpm-lock.yaml when user agent is not available', async () => {
delete process.env.npm_config_user_agent;
vi.mocked(fs).stat.mockImplementation(async (path) => {
if (path === 'pnpm-lock.yaml') {
const stats = mock<Stats>();
stats.isFile.mockReturnValue(true);
return await Promise.resolve(stats);
}
throw new Error('File not found');
});
const result = await detectPackageManager();
expect(result).toBe('pnpm');
expect(vi.mocked(fs).stat).toHaveBeenCalledWith('package-lock.json');
expect(vi.mocked(fs).stat).toHaveBeenCalledWith('yarn.lock');
expect(vi.mocked(fs).stat).toHaveBeenCalledWith('pnpm-lock.yaml');
});
it('prioritizes npm lock file when multiple lock files exist', async () => {
delete process.env.npm_config_user_agent;
const stats = mock<Stats>();
stats.isFile.mockReturnValue(true);
vi.mocked(fs).stat.mockResolvedValue(stats);
await fs.writeFile(`${tmpdir}/package-lock.json`, '{}');
await fs.writeFile(`${tmpdir}/yarn.lock`, '');
await fs.writeFile(`${tmpdir}/pnpm-lock.yaml`, '');
const result = await detectPackageManager();
expect(result).toBe('npm');
});
it('returns null when no user agent and no lock files exist', async () => {
tmpdirTest('returns null when no user agent and no lock files exist', async () => {
delete process.env.npm_config_user_agent;
vi.mocked(fs).stat.mockRejectedValue(new Error('File not found'));
const result = await detectPackageManager();
expect(result).toBe(null);
});
it('ignores directories that match lock file names', async () => {
tmpdirTest('ignores directories that match lock file names', async ({ tmpdir }) => {
delete process.env.npm_config_user_agent;
vi.mocked(fs).stat.mockImplementation(async (path) => {
if (path === 'package-lock.json') {
const stats = mock<Stats>();
stats.isFile.mockReturnValue(false);
return await Promise.resolve(stats);
}
throw new Error('File not found');
});
await fs.mkdir(`${tmpdir}/package-lock.json`);
await fs.mkdir(`${tmpdir}/yarn.lock`);
await fs.mkdir(`${tmpdir}/pnpm-lock.yaml`);
const result = await detectPackageManager();
+2 -1
View File
@@ -5,12 +5,13 @@ import prettier from 'prettier';
import { writeFileSafe } from './filesystem';
import { jsonParse } from './json';
type N8nPackageJson = {
export type N8nPackageJson = {
name: string;
version: string;
n8n?: {
nodes?: string[];
credentials?: string[];
strict?: boolean;
};
};
@@ -11,3 +11,11 @@ export const validateNodeName = (name: string): string | undefined => {
}
return;
};
export function isNodeErrnoException(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error;
}
export function isEnoentError(error: unknown): boolean {
return isNodeErrnoException(error) && error.code === 'ENOENT';
}
@@ -10,6 +10,7 @@
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.test.ts",
"src/test-utils/**/*",
"src/template/templates/**/template",
"src/template/templates/shared"
]
+7 -1
View File
@@ -1,3 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { globals: true, disableConsoleIntercept: true } });
export default defineConfig({
test: {
globals: true,
disableConsoleIntercept: true,
setupFiles: ['src/test-utils/setup.ts'],
},
});
+20 -18
View File
@@ -1061,6 +1061,9 @@ importers:
typescript:
specifier: 5.9.2
version: 5.9.2
vitest:
specifier: 'catalog:'
version: 3.1.3(@types/debug@4.1.12)(@types/node@20.19.11)(jiti@1.21.7)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(sass@1.89.2)(terser@5.16.1)(tsx@4.19.3)
vitest-mock-extended:
specifier: 'catalog:'
version: 3.1.0(typescript@5.9.2)(vitest@3.1.3(@types/debug@4.1.12)(@types/node@20.19.11)(jiti@1.21.7)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(sass@1.89.2)(terser@5.16.1)(tsx@4.19.3))
@@ -1075,7 +1078,7 @@ importers:
version: 4.3.0
'@getzep/zep-cloud':
specifier: 1.0.12
version: 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(f461b118585bdb288345da9017188aa6))
version: 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(e94cf81b5fa4aa911673e0503f662b2e))
'@getzep/zep-js':
specifier: 0.9.0
version: 0.9.0
@@ -1102,7 +1105,7 @@ importers:
version: 0.3.4(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)
'@langchain/community':
specifier: 'catalog:'
version: 0.3.50(f853e1a1cbd27719f8eb2bfe941d126d)
version: 0.3.50(8ac6ecc2064042e5620199e694862b5d)
'@langchain/core':
specifier: 'catalog:'
version: 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
@@ -1225,7 +1228,7 @@ importers:
version: 23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
langchain:
specifier: 0.3.33
version: 0.3.33(f461b118585bdb288345da9017188aa6)
version: 0.3.33(e94cf81b5fa4aa911673e0503f662b2e)
lodash:
specifier: 'catalog:'
version: 4.17.21
@@ -14351,7 +14354,6 @@ packages:
resolution: {integrity: sha512-gv6vLGcmAOg96/fgo3d9tvA4dJNZL3fMyBqVRrGxQ+Q/o4k9QzbJ3NQF9cOO/71wRodoXhaPgphvMFU68qVAJQ==}
deprecated: |-
You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.
(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)
qrcode.vue@3.3.4:
@@ -19404,7 +19406,7 @@ snapshots:
'@gar/promisify@1.1.3':
optional: true
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(f461b118585bdb288345da9017188aa6))':
'@getzep/zep-cloud@1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(e94cf81b5fa4aa911673e0503f662b2e))':
dependencies:
form-data: 4.0.4
node-fetch: 2.7.0(encoding@0.1.13)
@@ -19413,7 +19415,7 @@ snapshots:
zod: 3.25.67
optionalDependencies:
'@langchain/core': 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
langchain: 0.3.33(f461b118585bdb288345da9017188aa6)
langchain: 0.3.33(e94cf81b5fa4aa911673e0503f662b2e)
transitivePeerDependencies:
- encoding
@@ -19978,7 +19980,7 @@ snapshots:
- aws-crt
- encoding
'@langchain/community@0.3.50(f853e1a1cbd27719f8eb2bfe941d126d)':
'@langchain/community@0.3.50(8ac6ecc2064042e5620199e694862b5d)':
dependencies:
'@browserbasehq/stagehand': 1.9.0(@playwright/test@1.54.2)(bufferutil@4.0.9)(deepmerge@4.3.1)(dotenv@16.6.1)(encoding@0.1.13)(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(utf-8-validate@5.0.10)(zod@3.25.67)
'@ibm-cloud/watsonx-ai': 1.1.2
@@ -19990,7 +19992,7 @@ snapshots:
flat: 5.0.2
ibm-cloud-sdk-core: 5.3.2
js-yaml: 4.1.0
langchain: 0.3.33(f461b118585bdb288345da9017188aa6)
langchain: 0.3.33(e94cf81b5fa4aa911673e0503f662b2e)
langsmith: 0.3.55(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
openai: 5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)
uuid: 10.0.0
@@ -20004,7 +20006,7 @@ snapshots:
'@aws-sdk/credential-provider-node': 3.808.0
'@azure/storage-blob': 12.26.0
'@browserbasehq/sdk': 2.6.0(encoding@0.1.13)
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(f461b118585bdb288345da9017188aa6))
'@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.33(e94cf81b5fa4aa911673e0503f662b2e))
'@getzep/zep-js': 0.9.0
'@google-ai/generativelanguage': 3.4.0(encoding@0.1.13)
'@google-cloud/storage': 7.12.1(encoding@0.1.13)
@@ -22257,7 +22259,7 @@ snapshots:
'@types/docker-modem@3.0.6':
dependencies:
'@types/node': 20.19.10
'@types/node': 20.19.11
'@types/ssh2': 1.11.6
'@types/dockerode@3.3.42':
@@ -22373,7 +22375,7 @@ snapshots:
'@types/jsonfile@6.1.4':
dependencies:
'@types/node': 20.19.10
'@types/node': 20.19.11
optional: true
'@types/jsonpath@0.2.0': {}
@@ -22580,11 +22582,11 @@ snapshots:
'@types/ssh2-streams@0.1.12':
dependencies:
'@types/node': 20.19.10
'@types/node': 20.19.11
'@types/ssh2@0.5.52':
dependencies:
'@types/node': 20.19.10
'@types/node': 20.19.11
'@types/ssh2-streams': 0.1.12
'@types/ssh2@1.11.6':
@@ -27240,7 +27242,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 20.19.11
'@types/tough-cookie': 4.0.5
axios: 1.12.0(debug@4.4.1)
axios: 1.12.0(debug@4.3.6)
camelcase: 6.3.0
debug: 4.4.1(supports-color@8.1.1)
dotenv: 16.6.1
@@ -27250,7 +27252,7 @@ snapshots:
isstream: 0.1.2
jsonwebtoken: 9.0.2
mime-types: 2.1.35
retry-axios: 2.6.0(axios@1.12.0)
retry-axios: 2.6.0(axios@1.12.0(debug@4.4.1))
tough-cookie: 4.1.4
transitivePeerDependencies:
- supports-color
@@ -28512,7 +28514,7 @@ snapshots:
kuler@2.0.0: {}
langchain@0.3.33(f461b118585bdb288345da9017188aa6):
langchain@0.3.33(e94cf81b5fa4aa911673e0503f662b2e):
dependencies:
'@langchain/core': 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))
'@langchain/openai': 0.6.7(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))
@@ -31396,7 +31398,7 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
retry-axios@2.6.0(axios@1.12.0):
retry-axios@2.6.0(axios@1.12.0(debug@4.4.1)):
dependencies:
axios: 1.12.0(debug@4.3.6)
@@ -32942,7 +32944,7 @@ snapshots:
ts-type@3.0.1(ts-toolbelt@9.6.0):
dependencies:
'@types/node': 20.19.10
'@types/node': 20.19.11
ts-toolbelt: 9.6.0
tslib: 2.8.1
typedarray-dts: 1.0.0