first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user