feat: Add n8n-node CLI with commands to scaffold and develop nodes (#18090)

This commit is contained in:
Elias Meire
2025-08-15 14:55:39 +02:00
committed by GitHub
parent a1280b6bf4
commit c26104b3ba
93 changed files with 4279 additions and 380 deletions

View File

@@ -12,14 +12,14 @@ Official CLI for developing community nodes for [n8n](https://n8n.io).
Run directly via `npx`:
```bash
npx n8n-node create
npx n8n-node new
```
Or install globally:
```bash
npm install -g @n8n/node-cli
n8n-node create
n8n-node new
```
## Commands
@@ -27,7 +27,19 @@ n8n-node create
## Create a node
```bash
n8n-node create # Scaffold a new node
n8n-node new # Scaffold a new node
```
## Build a node
```bash
n8n-node build # Build your node; should be ran in the root of your custom node
```
## Develop a node
```bash
n8n-node dev # Develop your node with hot reloading; should be ran in the root of your custom node
```
## Related

View File

@@ -1,7 +1,11 @@
import { defineConfig } from 'eslint/config';
import { defineConfig, globalIgnores } from 'eslint/config';
import { nodeConfig } from '@n8n/eslint-config/node';
export default defineConfig(nodeConfig, {
files: ['./src/commands/*.ts'],
rules: { 'import-x/no-default-export': 'off' },
});
export default defineConfig(
globalIgnores(['src/template/templates/**/template', 'src/template/templates/shared']),
nodeConfig,
{
files: ['src/commands/**/*.ts', 'src/modules.d.ts', 'src/configs/eslint.ts'],
rules: { 'import-x/no-default-export': 'off', '@typescript-eslint/naming-convention': 'off' },
},
);

View File

@@ -1,11 +1,16 @@
{
"private": true,
"type": "module",
"name": "@n8n/node-cli",
"version": "0.1.0",
"description": "Official CLI for developing community nodes for n8n",
"bin": {
"n8n-node": "./bin/n8n-node.js"
"n8n-node": "./bin/n8n-node.mjs"
},
"exports": {
"./eslint": {
"types": "./dist/configs/eslint.d.js",
"default": "./dist/configs/eslint.js"
}
},
"files": [
"bin",
@@ -14,16 +19,17 @@
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",
"dev": "tsc -w",
"copy-templates": "./scripts/copy-templates.mjs",
"dev": "tsc -p tsconfig.build.json -w --onCompilationComplete \"pnpm copy-templates\"",
"format": "biome format --write src",
"format:check": "biome ci src",
"lint": "eslint src --quiet",
"lintfix": "eslint src --fix",
"build": "tsc",
"build": "tsc -p tsconfig.build.json && pnpm copy-templates",
"publish:dry": "pnpm run build && pnpm pub --dry-run",
"test": "vitest run",
"test:dev": "vitest --silent=false",
"start": "./bin/n8n-node.js"
"start": "./bin/n8n-node.mjs"
},
"repository": {
"type": "git",
@@ -31,16 +37,34 @@
},
"oclif": {
"bin": "n8n-node",
"commands": "./dist/commands",
"commands": {
"strategy": "explicit",
"target": "./dist/index.js",
"identifier": "commands"
},
"topicSeparator": " "
},
"dependencies": {
"@clack/prompts": "^0.11.0",
"@oclif/core": "^4.5.2",
"prompts": "^2.4.2"
"change-case": "^5.4.4",
"handlebars": "4.7.8",
"picocolors": "catalog:",
"prompts": "^2.4.2",
"ts-morph": "^26.0.0"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@oclif/test": "^4.1.13"
"@oclif/test": "^4.1.13",
"eslint-import-resolver-typescript": "^4.4.3",
"eslint-plugin-import-x": "^4.15.2",
"eslint-plugin-n8n-nodes-base": "1.16.3",
"n8n-workflow": "workspace:*",
"rimraf": "catalog:",
"typescript": "catalog:",
"typescript-eslint": "^8.35.0",
"vitest-mock-extended": "catalog:"
}
}

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env node
import glob from 'fast-glob';
import { cp } from 'node:fs/promises';
import path from 'path';
const templateFiles = glob.sync(['src/template/templates/**/*'], {
cwd: path.resolve(import.meta.dirname, '..'),
ignore: ['**/node_modules', '**/dist'],
dot: true,
});
await Promise.all(
templateFiles.map((template) =>
cp(template, `dist/${template.replace('src/', '')}`, { recursive: true }),
),
);

View File

@@ -0,0 +1,92 @@
import { cancel, intro, log, outro, spinner } from '@clack/prompts';
import { Command } from '@oclif/core';
import { spawn } from 'child_process';
import glob from 'fast-glob';
import { cp, mkdir } from 'node:fs/promises';
import path from 'node:path';
import picocolors from 'picocolors';
import { rimraf } from 'rimraf';
import { ensureN8nPackage } 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(picocolors.inverse(` ${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 new Promise((resolve, reject) => {
const child = spawn('tsc', [], {
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
});
let stderr = '';
let stdout = '';
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
log.error(`${stdout.trim()}\n${stderr.trim()}`);
reject(error);
});
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
log.error(`${stdout.trim()}\n${stderr.trim()}`);
reject(new Error(`tsc exited with code ${code}`));
}
});
});
}
export async function copyStaticFiles() {
const staticFiles = glob.sync(['**/*.{png,svg}', '**/__schema__/**/*.json'], {
ignore: ['dist'],
});
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 });
}),
);
}

View File

@@ -1,8 +0,0 @@
import { runCommand } from '@oclif/test';
describe('n8n-node create', () => {
it('should print correct output', async () => {
const { stdout } = await runCommand('create -f', { root: import.meta.dirname });
expect(stdout).toEqual('hello from commands/create.ts (force=true)\n');
});
});

View File

@@ -1,17 +0,0 @@
import { Command, Flags } from '@oclif/core';
export default class Create extends Command {
static override description = 'Create a new n8n community node';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {
// flag with no value (-f, --force)
force: Flags.boolean({ char: 'f' }),
};
async run(): Promise<void> {
const { flags } = await this.parse(Create);
const force = flags.force;
this.log(`hello from commands/create.ts (force=${force})`);
}
}

View File

@@ -0,0 +1,80 @@
import { Command, Flags } from '@oclif/core';
import os from 'node:os';
import path from 'node:path';
import picocolors from 'picocolors';
import { ensureFolder } from '../../utils/filesystem';
import { detectPackageManager } from '../../utils/package-manager';
import { copyStaticFiles } from '../build';
import { commands, readPackageName } from './utils';
import { ensureN8nPackage, onCancel } from '../../utils/prompts';
import { validateNodeName } from '../../utils/validation';
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-nodes-dir /opt/n8n-extensions',
];
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.',
}),
'custom-nodes-dir': Flags.directory({
default: path.join(os.homedir(), '.n8n/custom'),
description:
'Where to link your custom node. By default it will link to ~/.n8n/custom. You probably want to enable this option if you run n8n with a custom N8N_CUSTOM_EXTENSIONS env variable.',
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(Dev);
const packageManager = detectPackageManager() ?? 'npm';
const { isN8nInstalled, runCommand, runPersistentCommand } = commands();
await ensureN8nPackage('n8n-node dev');
const installed = await isN8nInstalled();
if (!installed && !flags['external-n8n']) {
console.error(
'❌ n8n is not installed or not in PATH. Learn how to install n8n here: https://docs.n8n.io/hosting/installation/npm',
);
process.exit(1);
}
await copyStaticFiles();
await runCommand(packageManager, ['link']);
const customPath = flags['custom-nodes-dir'];
await ensureFolder(customPath);
const packageName = await readPackageName();
const invalidNodeNameError = validateNodeName(packageName);
if (invalidNodeNameError) return onCancel(invalidNodeNameError);
await runCommand(packageManager, ['link', packageName], { cwd: customPath });
if (!flags['external-n8n']) {
// Run n8n with hot reload enabled
runPersistentCommand('n8n', [], {
cwd: customPath,
env: { N8N_DEV_RELOAD: 'true' },
name: 'n8n',
color: picocolors.green,
});
}
// Run `tsc --watch` in background
runPersistentCommand('tsc', ['--watch'], {
name: 'build',
color: picocolors.cyan,
});
}
}

View File

@@ -0,0 +1,123 @@
/* eslint-disable no-control-regex */
import { type ChildProcess, spawn } from 'child_process';
import { jsonParse } from 'n8n-workflow';
import fs from 'node:fs/promises';
import type { Formatter } from 'picocolors/types';
export function commands() {
const childProcesses: ChildProcess[] = [];
function registerChild(child: ChildProcess) {
childProcesses.push(child);
}
function cleanup(signal: 'SIGINT' | 'SIGTERM') {
for (const child of childProcesses) {
child.kill(signal);
}
process.exit();
}
process.on('SIGINT', () => cleanup('SIGINT'));
process.on('SIGTERM', () => cleanup('SIGTERM'));
async function runCommand(
cmd: string,
args: string[],
opts: {
cwd?: string;
env?: NodeJS.ProcessEnv;
} = {},
): Promise<void> {
return await new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd: opts.cwd,
env: { ...process.env, ...opts.env },
stdio: ['inherit', 'pipe', 'pipe'],
});
child.on('error', (error) => {
reject(error);
});
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${cmd} exited with code ${code}`));
});
registerChild(child);
});
}
function runPersistentCommand(
cmd: string,
args: string[],
opts: { cwd?: string; env?: NodeJS.ProcessEnv; name?: string; color?: Formatter } = {},
): void {
const child = spawn(cmd, args, {
cwd: opts.cwd,
env: { ...process.env, ...opts.env },
stdio: ['inherit', 'pipe', 'pipe'],
});
registerChild(child);
function stripClearCodes(input: string): string {
// Remove clear screen/reset ANSI codes
return input
.replace(/\x1Bc/g, '') // Full reset
.replace(/\x1B\[2J/g, '') // Clear screen
.replace(/\x1B\[3J/g, '') // Clear scrollback
.replace(/\x1B\[H/g, '') // Move cursor to top-left
.replace(/\x1B\[0?m/g, ''); // Reset colors
}
const log = (text: string) => {
if (opts.name) {
const rawPrefix = `[${opts.name}]`;
const prefix = opts.color ? opts.color(rawPrefix) : rawPrefix;
console.log(`${prefix} ${text}`);
} else {
console.log(text);
}
};
const handleOutput = (data: Buffer): void => {
data
.toString()
.split('\n')
.map((line) => stripClearCodes(line).trim())
.filter(Boolean)
.forEach((line) => log(line));
};
child.stdout.on('data', handleOutput);
child.stderr.on('data', handleOutput);
child.on('close', (code) => {
console.log(`${opts.name ?? cmd} exited with code ${code}`);
process.exit(code);
});
}
async function isN8nInstalled(): Promise<boolean> {
try {
await runCommand('n8n', ['--version'], {});
return true;
} catch {
return false;
}
}
return {
isN8nInstalled,
runCommand,
runPersistentCommand,
};
}
export async function readPackageName(): Promise<string> {
return await fs
.readFile('package.json', 'utf-8')
.then((packageJson) => jsonParse<{ name: string }>(packageJson).name);
}

View File

@@ -0,0 +1,123 @@
import { confirm, intro, isCancel, 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 picocolors from 'picocolors';
import { declarativeTemplatePrompt, nodeNamePrompt, nodeTypePrompt } from './prompts';
import { createIntro } from './utils';
import type { TemplateData, TemplateWithRun } from '../../template/core';
import { getTemplate, isTemplateName, isTemplateType, templates } from '../../template/templates';
import { delayAtLeast, folderExists } from '../../utils/filesystem';
import { tryReadGitUser } from '../../utils/git';
import { detectPackageManager, installDependencies } 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'] as const,
}),
};
async run(): Promise<void> {
const { flags, args } = await this.parse(New);
const [typeFlag, templateFlag] = flags.template?.split('/') ?? [];
intro(picocolors.inverse(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;
}
const config = (await template.prompts?.()) ?? {};
const packageManager = 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');
if (!flags['skip-install']) {
const installingSpinner = spinner();
installingSpinner.start('Installing dependencies');
try {
await delayAtLeast(installDependencies({ dir: destination, packageManager }), 1000);
} catch (error: unknown) {
installingSpinner.stop('Could not install dependencies', 1);
return process.exit(1);
}
installingSpinner.stop('Dependencies installed');
}
note(
`Need help? Check out the docs: https://docs.n8n.io/integrations/creating-nodes/build/${type}-style-node/`,
'Next Steps',
);
outro(`Created ./${nodeName}`);
}
}

View File

@@ -0,0 +1,48 @@
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: 'What is your node called?',
placeholder: 'n8n-nodes-example',
validate: validateNodeName,
defaultValue: 'n8n-nodes-example',
}),
);
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: 'Other',
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',
}),
);

View File

@@ -0,0 +1,7 @@
import { detectPackageManager } from '../../utils/package-manager';
export const createIntro = () => {
const maybePackageManager = detectPackageManager();
const packageManager = maybePackageManager ?? 'npm';
return maybePackageManager ? ` ${packageManager} create @n8n/node ` : ' n8n-node new ';
};

View File

@@ -0,0 +1,58 @@
/* eslint-disable @typescript-eslint/naming-convention */
import eslint from '@eslint/js';
import { globalIgnores } from 'eslint/config';
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript';
import importPlugin from 'eslint-plugin-import-x';
import n8nNodesPlugin from 'eslint-plugin-n8n-nodes-base';
import tseslint, { type ConfigArray } from 'typescript-eslint';
export const config: ConfigArray = tseslint.config(
globalIgnores(['dist']),
{
files: ['**/*.ts'],
extends: [
eslint.configs.recommended,
tseslint.configs.recommended,
importPlugin.configs['flat/recommended'],
],
rules: {
'prefer-spread': 'off',
},
},
{
plugins: { 'n8n-nodes-base': n8nNodesPlugin },
settings: {
'import-x/resolver-next': [createTypeScriptImportResolver()],
},
},
{
files: ['package.json'],
rules: {
...n8nNodesPlugin.configs.community.rules,
},
languageOptions: {
parser: tseslint.parser,
parserOptions: {
extraFileExtensions: ['.json'],
},
},
},
{
files: ['./credentials/**/*.ts'],
rules: {
...n8nNodesPlugin.configs.credentials.rules,
'n8n-nodes-base/cred-class-field-documentation-url-miscased': 'off',
},
},
{
files: ['./nodes/**/*.ts'],
rules: {
...n8nNodesPlugin.configs.nodes.rules,
'n8n-nodes-base/node-class-description-inputs-wrong-regular-node': 'off',
'n8n-nodes-base/node-class-description-outputs-wrong': 'off',
'n8n-nodes-base/node-param-type-options-max-value-present': 'off',
},
},
);
export default config;

View File

@@ -0,0 +1,9 @@
import Build from './commands/build';
import Dev from './commands/dev';
import New from './commands/new';
export const commands = {
new: New,
build: Build,
dev: Dev,
};

19
packages/@n8n/node-cli/src/modules.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
declare module 'eslint-plugin-n8n-nodes-base' {
import type { ESLint } from 'eslint';
const plugin: ESLint.Plugin & {
configs: {
community: {
rules: Record<string, Linter.RuleEntry>;
};
credentials: {
rules: Record<string, Linter.RuleEntry>;
};
nodes: {
rules: Record<string, Linter.RuleEntry>;
};
};
};
export default plugin;
}

View File

@@ -0,0 +1,111 @@
import * as glob from 'fast-glob';
import handlebars from 'handlebars';
import * as fs from 'node:fs/promises';
import {
copyTemplateFilesToDestination,
templateStaticFiles,
createTemplate,
type TemplateData,
} from './core';
import { copyFolder } from '../utils/filesystem';
vi.mock('node:fs/promises');
vi.mock('fast-glob');
vi.mock('handlebars');
vi.mock('../utils/filesystem');
const mockFs = vi.mocked(fs);
const mockGlob = vi.mocked(glob);
const mockHandlebars = vi.mocked(handlebars);
const mockCopyFolder = vi.mocked(copyFolder);
const baseData: TemplateData = {
destinationPath: '/dest',
nodePackageName: 'MyNode',
packageManager: {
name: 'npm',
installCommand: 'npm ci',
},
config: {},
user: {
name: 'Alice',
email: 'alice@example.com',
},
};
describe('Templates > core', () => {
describe('copyTemplateFilesToDestination', () => {
it('copies template folder with ignore rules', async () => {
const template = {
path: '/template',
name: 'MyTemplate',
description: 'desc',
};
await copyTemplateFilesToDestination(template, baseData);
expect(mockCopyFolder).toHaveBeenCalledWith({
source: '/template',
destination: '/dest',
ignore: ['dist', 'node_modules'],
});
});
});
describe('templateStaticFiles', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders and writes changed content', async () => {
mockGlob.default.mockResolvedValue(['/dest/file.md']);
mockFs.readFile.mockResolvedValue('Hello {{nodePackageName}}');
mockHandlebars.compile.mockReturnValue(() => 'Hello MyNode');
mockFs.writeFile.mockResolvedValue();
await templateStaticFiles(baseData);
expect(mockFs.readFile).toHaveBeenCalledWith('/dest/file.md', 'utf-8');
expect(mockFs.writeFile).toHaveBeenCalledWith('/dest/file.md', 'Hello MyNode');
});
it('skips writing if content unchanged', async () => {
mockGlob.default.mockResolvedValue(['/dest/file.md']);
mockFs.readFile.mockResolvedValue('Hello MyNode');
mockHandlebars.compile.mockReturnValue(() => 'Hello MyNode');
await templateStaticFiles(baseData);
expect(mockFs.writeFile).not.toHaveBeenCalled();
});
});
describe('createTemplate', () => {
it('adds run function that invokes sub-steps and original run', async () => {
const originalRun = vi.fn().mockResolvedValue(undefined);
const template = {
name: 'MyTemplate',
description: '',
path: '/template',
run: originalRun,
};
mockCopyFolder.mockResolvedValue();
mockGlob.default.mockResolvedValue([]);
mockFs.readFile.mockResolvedValue('');
mockHandlebars.compile.mockReturnValue(() => '');
mockFs.writeFile.mockResolvedValue();
const wrapped = createTemplate(template);
await wrapped.run(baseData);
expect(mockCopyFolder).toHaveBeenCalledWith({
source: '/template',
destination: '/dest',
ignore: ['dist', 'node_modules'],
});
expect(originalRun).toHaveBeenCalledWith(baseData);
});
});
});

View File

@@ -0,0 +1,84 @@
import glob from 'fast-glob';
import handlebars from 'handlebars';
import fs from 'node:fs/promises';
import path from 'node:path';
import { copyFolder } from '../utils/filesystem';
export type TemplateData<Config extends object = object> = {
destinationPath: string;
nodePackageName: string;
user?: Partial<{
name: string;
email: string;
}>;
packageManager: {
name: 'npm' | 'yarn' | 'pnpm';
installCommand: string;
};
config: Config;
};
type Require<T, K extends keyof T> = T & { [P in K]-?: T[P] };
export type Template<Config extends object = object> = {
name: string;
description: string;
path: string;
prompts?: () => Promise<Config>;
run?: (data: TemplateData<Config>) => Promise<void>;
};
export type TemplateWithRun<Config extends object = object> = Require<Template<Config>, 'run'>;
export async function copyTemplateFilesToDestination<Config extends object>(
template: Template<Config>,
data: TemplateData,
) {
await copyFolder({
source: template.path,
destination: data.destinationPath,
ignore: ['dist', 'node_modules'],
});
}
export async function copyDefaultTemplateFilesToDestination(data: TemplateData) {
await copyFolder({
source: path.resolve(__dirname, 'templates/shared/default'),
destination: data.destinationPath,
ignore: ['dist', 'node_modules'],
});
}
export async function templateStaticFiles(data: TemplateData) {
const files = await glob('**/*.{md,json,yml}', {
ignore: ['tsconfig.json', 'tsconfig.build.json'],
cwd: data.destinationPath,
absolute: true,
dot: true,
});
await Promise.all(
files.map(async (file) => {
const content = await fs.readFile(file, 'utf-8');
const newContent = handlebars.compile(content, { noEscape: true })(data);
if (newContent !== content) {
await fs.writeFile(file, newContent);
}
}),
);
}
export function createTemplate<Config extends object>(
template: Template<Config>,
): TemplateWithRun<Config> {
return {
...template,
run: async (data) => {
await copyDefaultTemplateFilesToDestination(data);
await copyTemplateFilesToDestination(template, data);
await templateStaticFiles(data);
await template.run?.(data);
},
};
}

View File

@@ -0,0 +1,161 @@
import { camelCase, capitalCase } from 'change-case';
import { ts, SyntaxKind, printNode } from 'ts-morph';
import {
getChildObjectLiteral,
loadSingleSourceFile,
updateStringProperty,
} from '../../../../utils/ast';
export function updateNodeAst({
nodePath,
className,
baseUrl,
}: { nodePath: string; className: string; baseUrl: string }) {
const sourceFile = loadSingleSourceFile(nodePath);
const classDecl = sourceFile.getClasses()[0];
classDecl.rename(className);
const nodeDescriptionObj = classDecl
.getPropertyOrThrow('description')
.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
updateStringProperty({
obj: nodeDescriptionObj,
key: 'displayName',
value: capitalCase(className),
});
updateStringProperty({
obj: nodeDescriptionObj,
key: 'name',
value: camelCase(className),
});
updateStringProperty({
obj: nodeDescriptionObj,
key: 'description',
value: `Interact with the ${capitalCase(className)} API`,
});
const icon = getChildObjectLiteral({ obj: nodeDescriptionObj, key: 'icon' });
updateStringProperty({
obj: icon,
key: 'light',
value: `file:${camelCase(className)}.svg`,
});
updateStringProperty({
obj: icon,
key: 'dark',
value: `file:${camelCase(className)}.dark.svg`,
});
const requestDefaults = getChildObjectLiteral({
obj: nodeDescriptionObj,
key: 'requestDefaults',
});
updateStringProperty({
obj: requestDefaults,
key: 'baseURL',
value: baseUrl,
});
const defaults = getChildObjectLiteral({
obj: nodeDescriptionObj,
key: 'defaults',
});
updateStringProperty({ obj: defaults, key: 'name', value: capitalCase(className) });
return sourceFile;
}
export function updateCredentialAst({
repoName,
baseUrl,
credentialPath,
credentialName,
credentialDisplayName,
credentialClassName,
}: {
repoName: string;
credentialPath: string;
credentialName: string;
credentialDisplayName: string;
credentialClassName: string;
baseUrl: string;
}) {
const sourceFile = loadSingleSourceFile(credentialPath);
const classDecl = sourceFile.getClasses()[0];
classDecl.rename(credentialClassName);
updateStringProperty({
obj: classDecl,
key: 'displayName',
value: credentialDisplayName,
});
updateStringProperty({
obj: classDecl,
key: 'name',
value: credentialName,
});
const docUrlProp = classDecl.getProperty('documentationUrl');
if (docUrlProp) {
const initializer = docUrlProp.getInitializerIfKindOrThrow(SyntaxKind.StringLiteral);
const newUrl = initializer.getLiteralText().replace('/repo', `/${repoName}`);
initializer.setLiteralValue(newUrl);
}
const testProperty = classDecl.getProperty('test');
if (testProperty) {
const testRequest = testProperty
.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression)
.getPropertyOrThrow('request')
.asKindOrThrow(SyntaxKind.PropertyAssignment)
.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
updateStringProperty({
obj: testRequest,
key: 'baseURL',
value: baseUrl,
});
}
return sourceFile;
}
export function addCredentialToNode({
nodePath,
credentialName,
}: { nodePath: string; credentialName: string }) {
const sourceFile = loadSingleSourceFile(nodePath);
const classDecl = sourceFile.getClasses()[0];
const descriptionProp = classDecl
.getPropertyOrThrow('description')
.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
const credentialsProp = descriptionProp.getPropertyOrThrow('credentials');
if (credentialsProp.getKind() === SyntaxKind.PropertyAssignment) {
const initializer = credentialsProp.getFirstDescendantByKindOrThrow(
SyntaxKind.ArrayLiteralExpression,
);
const credentialObject = ts.factory.createObjectLiteralExpression([
ts.factory.createPropertyAssignment(
ts.factory.createIdentifier('name'),
ts.factory.createStringLiteral(credentialName, true),
),
ts.factory.createPropertyAssignment(
ts.factory.createIdentifier('required'),
ts.factory.createTrue(),
),
]);
initializer.addElement(printNode(credentialObject));
}
return sourceFile;
}

View File

@@ -0,0 +1,87 @@
import { select, text } from '@clack/prompts';
import type { CredentialType } from './types';
import { withCancelHandler } from '../../../../utils/prompts';
export const credentialTypePrompt = async () =>
await withCancelHandler(
select<CredentialType>({
message: 'What type of authentication does your API use?',
options: [
{
label: 'API Key',
value: 'apiKey',
hint: 'Send a secret key via headers, query, or body',
},
{
label: 'Bearer Token',
value: 'bearer',
hint: 'Send a token via Authorization header (Authorization: Bearer <token>)',
},
{
label: 'OAuth2',
value: 'oauth2',
hint: 'Use an OAuth 2.0 flow to obtain access tokens on behalf of a user or app',
},
{
label: 'Basic Auth',
value: 'basicAuth',
hint: 'Send username and password encoded in base64 via the Authorization header',
},
{
label: 'Custom',
value: 'custom',
hint: 'Create your own credential logic; an empty credential class will be scaffolded for you',
},
{
label: 'None',
value: 'none',
hint: 'No authentication; no credential class will be generated',
},
],
initialValue: 'apiKey',
}),
);
export const baseUrlPrompt = async () =>
await withCancelHandler(
text({
message: "What's the base URL of the API?",
placeholder: 'https://api.example.com/v2',
defaultValue: 'https://api.example.com/v2',
validate: (value) => {
if (!value) return;
if (!value.startsWith('https://') && !value.startsWith('http://')) {
return 'Base URL must start with http(s)://';
}
try {
new URL(value);
} catch (error) {
return 'Must be a valid URL';
}
return;
},
}),
);
export const oauthFlowPrompt = async () =>
await withCancelHandler(
select<'clientCredentials' | 'authorizationCode'>({
message: 'What OAuth2 flow does your API use?',
options: [
{
label: 'Authorization code',
value: 'authorizationCode',
hint: 'Users log in and approve access (use this if unsure)',
},
{
label: 'Client credentials',
value: 'clientCredentials',
hint: 'Server-to-server auth without user interaction',
},
],
initialValue: 'authorizationCode',
}),
);

View File

@@ -0,0 +1,121 @@
import { camelCase, capitalCase, pascalCase } from 'change-case';
import path from 'node:path';
import { addCredentialToNode, updateCredentialAst, updateNodeAst } from './ast';
import { baseUrlPrompt, credentialTypePrompt, oauthFlowPrompt } from './prompts';
import type { CustomTemplateConfig } from './types';
import {
renameDirectory,
renameFilesInDirectory,
writeFileSafe,
} from '../../../../utils/filesystem';
import {
setNodesPackageJson,
addCredentialPackageJson,
getPackageJsonNodes,
} from '../../../../utils/package';
import { createTemplate, type TemplateData } from '../../../core';
export const customTemplate = createTemplate({
name: 'Start from scratch',
description: 'Blank template with guided setup',
path: path.join(__dirname, 'template'),
prompts: async (): Promise<CustomTemplateConfig> => {
const baseUrl = await baseUrlPrompt();
const credentialType = await credentialTypePrompt();
if (credentialType === 'oauth2') {
const flow = await oauthFlowPrompt();
return { credentialType, baseUrl, flow };
}
return { credentialType, baseUrl };
},
run: async (data) => {
await renameNode(data, 'Example');
await addCredential(data);
},
});
async function renameNode(data: TemplateData<CustomTemplateConfig>, oldNodeName: string) {
const { config, nodePackageName: nodeName, destinationPath } = data;
const newClassName = pascalCase(nodeName.replace('n8n-nodes-', ''));
const oldNodeDir = path.resolve(destinationPath, `nodes/${oldNodeName}`);
await renameFilesInDirectory(oldNodeDir, oldNodeName, newClassName);
const newNodeDir = await renameDirectory(oldNodeDir, newClassName);
const newNodePath = path.resolve(newNodeDir, `${newClassName}.node.ts`);
const newNodeAst = updateNodeAst({
nodePath: newNodePath,
baseUrl: config.baseUrl,
className: newClassName,
});
await writeFileSafe(newNodePath, newNodeAst.getFullText());
const nodes = [`dist/nodes/${newClassName}/${newClassName}.node.js`];
await setNodesPackageJson(destinationPath, nodes);
}
async function addCredential(data: TemplateData<CustomTemplateConfig>) {
const { config, destinationPath, nodePackageName } = data;
if (config.credentialType === 'none') return;
const credentialTemplateName =
config.credentialType === 'oauth2'
? config.credentialType + pascalCase(config.flow)
: config.credentialType;
const credentialTemplatePath = path.resolve(
__dirname,
`../../shared/credentials/${credentialTemplateName}.credentials.ts`,
);
const nodeName = nodePackageName.replace('n8n-nodes', '');
const repoName = nodeName;
const { baseUrl, credentialType } = config;
const credentialClassName =
config.credentialType === 'oauth2'
? pascalCase(`${nodeName}-OAuth2-api`)
: pascalCase(`${nodeName}-api`);
const credentialName = camelCase(
`${nodeName}${credentialType === 'oauth2' ? 'OAuth2Api' : 'Api'}`,
);
const credentialDisplayName = `${capitalCase(nodeName)} ${
credentialType === 'oauth2' ? 'OAuth2 API' : 'API'
}`;
const updatedCredentialAst = updateCredentialAst({
repoName,
baseUrl,
credentialName,
credentialDisplayName,
credentialClassName,
credentialPath: credentialTemplatePath,
});
await writeFileSafe(
path.resolve(destinationPath, `credentials/${credentialClassName}.credentials.ts`),
updatedCredentialAst.getFullText(),
);
await addCredentialPackageJson(
destinationPath,
`dist/credentials/${credentialClassName}.credentials.js`,
);
for (const nodePath of await getPackageJsonNodes(destinationPath)) {
const srcNodePath = path.resolve(
destinationPath,
nodePath.replace(/.js$/, '.ts').replace(/^dist\//, ''),
);
const updatedNodeAst = addCredentialToNode({
nodePath: srcNodePath,
credentialName,
});
await writeFileSafe(srcNodePath, updatedNodeAst.getFullText());
}
}

View File

@@ -0,0 +1,46 @@
# {{nodePackageName}}
This is an n8n community node. It lets you use _app/service name_ in your n8n workflows.
_App/service name_ is _one or two sentences describing the service this node integrates with_.
[n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/reference/license/) workflow automation platform.
[Installation](#installation)
[Operations](#operations)
[Credentials](#credentials)
[Compatibility](#compatibility)
[Usage](#usage)
[Resources](#resources)
[Version history](#version-history)
## Installation
Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community nodes documentation.
## Operations
_List the operations supported by your node._
## Credentials
_If users need to authenticate with the app/service, provide details here. You should include prerequisites (such as signing up with the service), available authentication methods, and how to set them up._
## Compatibility
_State the minimum n8n version, as well as which versions you test against. You can also include any known version incompatibility issues._
## Usage
_This is an optional section. Use it to help users with any difficult or confusing aspects of the node._
_By the time users are looking for community nodes, they probably already know n8n basics. But if you expect new users, you can link to the [Try it out](https://docs.n8n.io/try-it-out/) documentation to help them get started._
## Resources
* [n8n community nodes documentation](https://docs.n8n.io/integrations/#community-nodes)
* _Link to app/service documentation._
## Version history
_This is another optional section. If your node has multiple versions, include a short description of available versions and what changed, as well as any compatibility impact._

View File

@@ -0,0 +1,3 @@
import { config } from '@n8n/node-cli/eslint';
export default config;

View File

@@ -0,0 +1,18 @@
{
"node": "{{nodePackageName}}",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Development", "Developer Tools"],
"resources": {
"credentialDocumentation": [
{
"url": "https://github.com/org/repo?tab=readme-ov-file#credentials"
}
],
"primaryDocumentation": [
{
"url": "https://github.com/org/repo?tab=readme-ov-file"
}
]
}
}

View File

@@ -0,0 +1,50 @@
import { NodeConnectionType, type INodeType, type INodeTypeDescription } from 'n8n-workflow';
import { userDescription } from './resources/user';
import { companyDescription } from './resources/company';
export class Example implements INodeType {
description: INodeTypeDescription = {
displayName: 'Example',
name: 'example',
icon: { light: 'file:example.svg', dark: 'file:example.dark.svg' },
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Interact with the Example API',
defaults: {
name: 'Example',
},
usableAsTool: true,
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
credentials: [],
requestDefaults: {
baseURL: 'https://api.example.com',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'User',
value: 'user',
},
{
name: 'Company',
value: 'company',
},
],
default: 'user',
},
...userDescription,
...companyDescription,
],
};
}

View File

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

After

Width:  |  Height:  |  Size: 698 B

View File

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

After

Width:  |  Height:  |  Size: 696 B

View File

@@ -0,0 +1,61 @@
import type { INodeProperties } from 'n8n-workflow';
const showOnlyForCompanyGetMany = {
operation: ['getAll'],
resource: ['company'],
};
export const companyGetManyDescription: INodeProperties[] = [
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
...showOnlyForCompanyGetMany,
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 100,
},
default: 50,
routing: {
send: {
type: 'query',
property: 'limit',
},
output: {
maxResults: '={{$value}}',
},
},
description: 'Max number of results to return',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: showOnlyForCompanyGetMany,
},
default: false,
description: 'Whether to return all results or only up to a given limit',
routing: {
send: {
paginate: '={{ $value }}',
},
operations: {
pagination: {
type: 'offset',
properties: {
limitParameter: 'limit',
offsetParameter: 'offset',
pageSize: 100,
type: 'query',
},
},
},
},
},
];

View File

@@ -0,0 +1,34 @@
import type { INodeProperties } from 'n8n-workflow';
import { companyGetManyDescription } from './getAll';
const showOnlyForCompanies = {
resource: ['company'],
};
export const companyDescription: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: showOnlyForCompanies,
},
options: [
{
name: 'Get Many',
value: 'getAll',
action: 'Get companies',
description: 'Get companies',
routing: {
request: {
method: 'GET',
url: '/companies',
},
},
},
],
default: 'getAll',
},
...companyGetManyDescription,
];

View File

@@ -0,0 +1,26 @@
import type { INodeProperties } from 'n8n-workflow';
const showOnlyForUserCreate = {
operation: ['create'],
resource: ['user'],
};
export const userCreateDescription: INodeProperties[] = [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
required: true,
displayOptions: {
show: showOnlyForUserCreate,
},
description: 'The name of the user',
routing: {
send: {
type: 'body',
property: 'name',
},
},
},
];

View File

@@ -0,0 +1,17 @@
import type { INodeProperties } from 'n8n-workflow';
const showOnlyForUserGet = {
operation: ['get'],
resource: ['user'],
};
export const userGetDescription: INodeProperties[] = [
{
displayName: 'User ID',
name: 'userId',
type: 'string',
displayOptions: { show: showOnlyForUserGet },
default: '',
description: "The user's ID to retrieve",
},
];

View File

@@ -0,0 +1,60 @@
import type { INodeProperties } from 'n8n-workflow';
import { userCreateDescription } from './create';
import { userGetDescription } from './get';
const showOnlyForUsers = {
resource: ['user'],
};
export const userDescription: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: showOnlyForUsers,
},
options: [
{
name: 'Get Many',
value: 'getAll',
action: 'Get users',
description: 'Get many users',
routing: {
request: {
method: 'GET',
url: '/users',
},
},
},
{
name: 'Get',
value: 'get',
action: 'Get a user',
description: 'Get the data of a single user',
routing: {
request: {
method: 'GET',
url: '=/users/{{$parameter.userId}}',
},
},
},
{
name: 'Create',
value: 'create',
action: 'Create a new user',
description: 'Create a new user',
routing: {
request: {
method: 'POST',
url: '/users',
},
},
},
],
default: 'getAll',
},
...userGetDescription,
...userCreateDescription,
];

View File

@@ -0,0 +1,50 @@
{
"name": "{{nodePackageName}}",
"version": "0.1.0",
"description": "n8n community node to work with the Example API",
"license": "MIT",
"homepage": "https://example.com",
"keywords": [
"n8n-community-node-package"
],
"author": {
"name": "{{user.name}}",
"email": "{{user.email}}"
},
"repository": {
"type": "git",
"url": ""
},
"scripts": {
"build": "n8n-node build",
"build:watch": "tsc --watch",
"dev": "n8n-node dev",
"lint": "eslint .",
"release": "release-it"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"credentials": [],
"nodes": [
"dist/nodes/Example/Example.node.js"
]
},
"release-it": {
"hooks": {
"before:init": "{{packageManager.name}} run lint && {{packageManager.name}} run build"
}
},
"devDependencies": {
"@n8n/node-cli": "*",
"eslint": "9.32.0",
"prettier": "3.6.2",
"release-it": "^19.0.4",
"typescript": "5.9.2"
},
"peerDependencies": {
"n8n-workflow": "*"
}
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"strict": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2019",
"lib": ["es2019", "es2020", "es2022.error"],
"removeComments": true,
"useUnknownInCatchVariables": false,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"preserveConstEnums": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"incremental": true,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true,
"outDir": "./dist/"
},
"include": ["credentials/**/*", "nodes/**/*", "nodes/**/*.json", "package.json"]
}

View File

@@ -0,0 +1,8 @@
export type CustomTemplateConfig =
| {
credentialType: 'apiKey' | 'bearer' | 'basicAuth' | 'custom' | 'none';
baseUrl: string;
}
| { credentialType: 'oauth2'; baseUrl: string; flow: string };
export type CredentialType = CustomTemplateConfig['credentialType'];

View File

@@ -0,0 +1,9 @@
import path from 'node:path';
import { createTemplate } from '../../../core';
export const githubIssuesTemplate = createTemplate({
name: 'GitHub Issues API',
description: 'Demo node with multiple operations and credentials',
path: path.join(__dirname, 'template'),
});

View File

@@ -0,0 +1,73 @@
# {{nodePackageName}}
This is an n8n community node. It lets you use GitHub Issues in your n8n workflows.
[n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/reference/license/) workflow automation platform.
[Installation](#installation)
[Operations](#operations)
[Credentials](#credentials)
[Compatibility](#compatibility)
[Usage](#usage)
[Resources](#resources)
## Installation
Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community nodes documentation.
## Operations
- Issues
- Get an issue
- Get many issues in a repository
- Create a new issue
- Issue Comments
- Get many issue comments
## Credentials
You can use either access token or OAuth2 to use this node.
### Access token
1. Open your GitHub profile [Settings](https://github.com/settings/profile).
2. In the left navigation, select [Developer settings](https://github.com/settings/apps).
3. In the left navigation, under Personal access tokens, select Tokens (classic).
4. Select Generate new token > Generate new token (classic).
5. Enter a descriptive name for your token in the Note field, like n8n integration.
6. Select the Expiration you'd like for the token, or select No expiration.
7. Select Scopes for your token. For most of the n8n GitHub nodes, add the `repo` scope.
- A token without assigned scopes can only access public information.
8. Select Generate token.
9. Copy the token.
Refer to [Creating a personal access token (classic)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) for more information. Refer to Scopes for OAuth apps for more information on GitHub scopes.
![Generated Access token in GitHub](https://docs.github.com/assets/cb-17251/mw-1440/images/help/settings/personal-access-tokens.webp)
### OAuth2
If you're self-hosting n8n, create a new GitHub [OAuth app](https://docs.github.com/en/apps/oauth-apps):
1. Open your GitHub profile [Settings](https://github.com/settings/profile).
2. In the left navigation, select [Developer settings](https://github.com/settings/apps).
3. In the left navigation, select OAuth apps.
4. Select New OAuth App.
- If you haven't created an app before, you may see Register a new application instead. Select it.
5. Enter an Application name, like n8n integration.
6. Enter the Homepage URL for your app's website.
7. If you'd like, add the optional Application description, which GitHub displays to end-users.
8. From n8n, copy the OAuth Redirect URL and paste it into the GitHub Authorization callback URL.
9. Select Register application.
10. Copy the Client ID and Client Secret this generates and add them to your n8n credential.
Refer to the [GitHub Authorizing OAuth apps documentation](https://docs.github.com/en/apps/oauth-apps/using-oauth-apps/authorizing-oauth-apps) for more information on the authorization process.
## Compatibility
Compatible with n8n@1.60.0 or later
## Resources
* [n8n community nodes documentation](https://docs.n8n.io/integrations/#community-nodes)
* [GitHub API docs](https://docs.github.com/en/rest/issues)

View File

@@ -0,0 +1,45 @@
import type {
IAuthenticateGeneric,
Icon,
ICredentialTestRequest,
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class GithubIssuesApi implements ICredentialType {
name = 'githubIssuesApi';
displayName = 'GitHub Issues API';
icon: Icon = { light: 'file:../icons/github.svg', dark: 'file:../icons/github.dark.svg' };
documentationUrl =
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#deleting-a-personal-access-token';
properties: INodeProperties[] = [
{
displayName: 'Access Token',
name: 'accessToken',
type: 'string',
typeOptions: { password: true },
default: '',
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
Authorization: '=token {{$credentials?.accessToken}}',
},
},
};
test: ICredentialTestRequest = {
request: {
baseURL: 'https://api.github.com',
url: '/user',
method: 'GET',
},
};
}

View File

@@ -0,0 +1,54 @@
import type { Icon, ICredentialType, INodeProperties } from 'n8n-workflow';
export class GithubIssuesOAuth2Api implements ICredentialType {
name = 'githubIssuesOAuth2Api';
extends = ['oAuth2Api'];
displayName = 'GitHub Issues OAuth2 API';
icon: Icon = { light: 'file:../icons/github.svg', dark: 'file:../icons/github.dark.svg' };
documentationUrl = 'https://docs.github.com/en/apps/oauth-apps';
properties: INodeProperties[] = [
{
displayName: 'Grant Type',
name: 'grantType',
type: 'hidden',
default: 'authorizationCode',
},
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden',
default: 'https://github.com/login/oauth/authorize',
required: true,
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden',
default: 'https://github.com/login/oauth/access_token',
required: true,
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden',
default: 'repo',
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden',
default: '',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden',
default: 'header',
},
];
}

View File

@@ -0,0 +1,3 @@
import { config } from '@n8n/node-cli/eslint';
export default config;

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,18 @@
{
"node": "{{nodePackageName}}",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Development", "Developer Tools"],
"resources": {
"credentialDocumentation": [
{
"url": "https://github.com/org/repo?tab=readme-ov-file#credentials"
}
],
"primaryDocumentation": [
{
"url": "https://github.com/org/repo?tab=readme-ov-file"
}
]
}
}

View File

@@ -0,0 +1,96 @@
import { NodeConnectionType, type INodeType, type INodeTypeDescription } from 'n8n-workflow';
import { issueDescription } from './resources/issue';
import { issueCommentDescription } from './resources/issueComment';
import { getRepositories } from './listSearch/getRepositories';
import { getUsers } from './listSearch/getUsers';
import { getIssues } from './listSearch/getIssues';
export class GithubIssues implements INodeType {
description: INodeTypeDescription = {
displayName: 'GitHub Issues',
name: 'githubIssues',
icon: { light: 'file:../../icons/github.svg', dark: 'file:../../icons/github.dark.svg' },
group: ['input'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume issues from the GitHub API',
defaults: {
name: 'GitHub Issues',
},
usableAsTool: true,
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
credentials: [
{
name: 'githubIssuesApi',
required: true,
displayOptions: {
show: {
authentication: ['accessToken'],
},
},
},
{
name: 'githubIssuesOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: ['oAuth2'],
},
},
},
],
requestDefaults: {
baseURL: 'https://api.github.com',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
properties: [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'Access Token',
value: 'accessToken',
},
{
name: 'OAuth2',
value: 'oAuth2',
},
],
default: 'accessToken',
},
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Issue',
value: 'issue',
},
{
name: 'Issue Comment',
value: 'issueComment',
},
],
default: 'issue',
},
...issueDescription,
...issueCommentDescription,
],
};
methods = {
listSearch: {
getRepositories,
getUsers,
getIssues,
},
};
}

View File

@@ -0,0 +1,49 @@
import type {
ILoadOptionsFunctions,
INodeListSearchResult,
INodeListSearchItems,
} from 'n8n-workflow';
import { githubApiRequest } from '../shared/transport';
type IssueSearchItem = {
number: number;
title: string;
html_url: string;
};
type IssueSearchResponse = {
items: IssueSearchItem[];
total_count: number;
};
export async function getIssues(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const page = paginationToken ? +paginationToken : 1;
const per_page = 100;
let responseData: IssueSearchResponse = {
items: [],
total_count: 0,
};
const owner = this.getNodeParameter('owner', '', { extractValue: true });
const repository = this.getNodeParameter('repository', '', { extractValue: true });
const filters = [filter, `repo:${owner}/${repository}`];
responseData = await githubApiRequest.call(this, 'GET', '/search/issues', {
q: filters.filter(Boolean).join(' '),
page,
per_page,
});
const results: INodeListSearchItems[] = responseData.items.map((item: IssueSearchItem) => ({
name: item.title,
value: item.number,
url: item.html_url,
}));
const nextPaginationToken = page * per_page < responseData.total_count ? page + 1 : undefined;
return { results, paginationToken: nextPaginationToken };
}

View File

@@ -0,0 +1,50 @@
import type {
ILoadOptionsFunctions,
INodeListSearchItems,
INodeListSearchResult,
} from 'n8n-workflow';
import { githubApiRequest } from '../shared/transport';
type RepositorySearchItem = {
name: string;
html_url: string;
};
type RepositorySearchResponse = {
items: RepositorySearchItem[];
total_count: number;
};
export async function getRepositories(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const owner = this.getCurrentNodeParameter('owner', { extractValue: true });
const page = paginationToken ? +paginationToken : 1;
const per_page = 100;
const q = `${filter ?? ''} user:${owner} fork:true`;
let responseData: RepositorySearchResponse = {
items: [],
total_count: 0,
};
try {
responseData = await githubApiRequest.call(this, 'GET', '/search/repositories', {
q,
page,
per_page,
});
} catch {
// will fail if the owner does not have any repositories
}
const results: INodeListSearchItems[] = responseData.items.map((item: RepositorySearchItem) => ({
name: item.name,
value: item.name,
url: item.html_url,
}));
const nextPaginationToken = page * per_page < responseData.total_count ? page + 1 : undefined;
return { results, paginationToken: nextPaginationToken };
}

View File

@@ -0,0 +1,49 @@
import type {
ILoadOptionsFunctions,
INodeListSearchResult,
INodeListSearchItems,
} from 'n8n-workflow';
import { githubApiRequest } from '../shared/transport';
type UserSearchItem = {
login: string;
html_url: string;
};
type UserSearchResponse = {
items: UserSearchItem[];
total_count: number;
};
export async function getUsers(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const page = paginationToken ? +paginationToken : 1;
const per_page = 100;
let responseData: UserSearchResponse = {
items: [],
total_count: 0,
};
try {
responseData = await githubApiRequest.call(this, 'GET', '/search/users', {
q: filter,
page,
per_page,
});
} catch {
// will fail if the owner does not have any users
}
const results: INodeListSearchItems[] = responseData.items.map((item: UserSearchItem) => ({
name: item.login,
value: item.login,
url: item.html_url,
}));
const nextPaginationToken = page * per_page < responseData.total_count ? page + 1 : undefined;
return { results, paginationToken: nextPaginationToken };
}

View File

@@ -0,0 +1,74 @@
import type { INodeProperties } from 'n8n-workflow';
const showOnlyForIssueCreate = {
operation: ['create'],
resource: ['issue'],
};
export const issueCreateDescription: INodeProperties[] = [
{
displayName: 'Title',
name: 'title',
type: 'string',
default: '',
required: true,
displayOptions: {
show: showOnlyForIssueCreate,
},
description: 'The title of the issue',
routing: {
send: {
type: 'body',
property: 'title',
},
},
},
{
displayName: 'Body',
name: 'body',
type: 'string',
typeOptions: {
rows: 5,
},
default: '',
displayOptions: {
show: showOnlyForIssueCreate,
},
description: 'The body of the issue',
routing: {
send: {
type: 'body',
property: 'body',
},
},
},
{
displayName: 'Labels',
name: 'labels',
type: 'collection',
typeOptions: {
multipleValues: true,
multipleValueButtonText: 'Add Label',
},
displayOptions: {
show: showOnlyForIssueCreate,
},
default: { label: '' },
options: [
{
displayName: 'Label',
name: 'label',
type: 'string',
default: '',
description: 'Label to add to issue',
},
],
routing: {
send: {
type: 'body',
property: 'labels',
value: '={{$value.map((data) => data.label)}}',
},
},
},
];

View File

@@ -0,0 +1,14 @@
import type { INodeProperties } from 'n8n-workflow';
import { issueSelect } from '../../shared/descriptions';
const showOnlyForIssueGet = {
operation: ['get'],
resource: ['issue'],
};
export const issueGetDescription: INodeProperties[] = [
{
...issueSelect,
displayOptions: { show: showOnlyForIssueGet },
},
];

View File

@@ -0,0 +1,124 @@
import type { INodeProperties } from 'n8n-workflow';
import { parseLinkHeader } from '../../shared/utils';
const showOnlyForIssueGetMany = {
operation: ['getAll'],
resource: ['issue'],
};
export const issueGetManyDescription: INodeProperties[] = [
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
...showOnlyForIssueGetMany,
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 100,
},
default: 50,
routing: {
send: {
type: 'query',
property: 'per_page',
},
output: {
maxResults: '={{$value}}',
},
},
description: 'Max number of results to return',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: showOnlyForIssueGetMany,
},
default: false,
description: 'Whether to return all results or only up to a given limit',
routing: {
send: {
paginate: '={{ $value }}',
type: 'query',
property: 'per_page',
value: '100',
},
operations: {
pagination: {
type: 'generic',
properties: {
continue: `={{ !!(${parseLinkHeader.toString()})($response.headers?.link).next }}`,
request: {
url: `={{ (${parseLinkHeader.toString()})($response.headers?.link)?.next ?? $request.url }}`,
},
},
},
},
},
},
{
displayName: 'Filters',
name: 'filters',
type: 'collection',
typeOptions: {
multipleValueButtonText: 'Add Filter',
},
displayOptions: {
show: showOnlyForIssueGetMany,
},
default: {},
options: [
{
displayName: 'Updated Since',
name: 'since',
type: 'dateTime',
default: '',
description: 'Return only issues updated at or after this time',
routing: {
request: {
qs: {
since: '={{$value}}',
},
},
},
},
{
displayName: 'State',
name: 'state',
type: 'options',
options: [
{
name: 'All',
value: 'all',
description: 'Returns issues with any state',
},
{
name: 'Closed',
value: 'closed',
description: 'Return issues with "closed" state',
},
{
name: 'Open',
value: 'open',
description: 'Return issues with "open" state',
},
],
default: 'open',
description: 'The issue state to filter on',
routing: {
request: {
qs: {
state: '={{$value}}',
},
},
},
},
],
},
];

View File

@@ -0,0 +1,75 @@
import type { INodeProperties } from 'n8n-workflow';
import { repoNameSelect, repoOwnerSelect } from '../../shared/descriptions';
import { issueGetManyDescription } from './getAll';
import { issueGetDescription } from './get';
import { issueCreateDescription } from './create';
const showOnlyForIssues = {
resource: ['issue'],
};
export const issueDescription: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: showOnlyForIssues,
},
options: [
{
name: 'Get Many',
value: 'getAll',
action: 'Get issues in a repository',
description: 'Get many issues in a repository',
routing: {
request: {
method: 'GET',
url: '=/repos/{{$parameter.owner}}/{{$parameter.repository}}/issues',
},
},
},
{
name: 'Get',
value: 'get',
action: 'Get an issue',
description: 'Get the data of a single issue',
routing: {
request: {
method: 'GET',
url: '=/repos/{{$parameter.owner}}/{{$parameter.repository}}/issues/{{$parameter.issue}}',
},
},
},
{
name: 'Create',
value: 'create',
action: 'Create a new issue',
description: 'Create a new issue',
routing: {
request: {
method: 'POST',
url: '=/repos/{{$parameter.owner}}/{{$parameter.repository}}/issues',
},
},
},
],
default: 'getAll',
},
{
...repoOwnerSelect,
displayOptions: {
show: showOnlyForIssues,
},
},
{
...repoNameSelect,
displayOptions: {
show: showOnlyForIssues,
},
},
...issueGetManyDescription,
...issueGetDescription,
...issueCreateDescription,
];

View File

@@ -0,0 +1,65 @@
import type { INodeProperties } from 'n8n-workflow';
import { parseLinkHeader } from '../../shared/utils';
const showOnlyForIssueCommentGetMany = {
operation: ['getAll'],
resource: ['issueComment'],
};
export const issueCommentGetManyDescription: INodeProperties[] = [
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
...showOnlyForIssueCommentGetMany,
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 100,
},
default: 50,
routing: {
send: {
type: 'query',
property: 'per_page',
},
output: {
maxResults: '={{$value}}',
},
},
description: 'Max number of results to return',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: showOnlyForIssueCommentGetMany,
},
default: false,
description: 'Whether to return all results or only up to a given limit',
routing: {
send: {
paginate: '={{ $value }}',
type: 'query',
property: 'per_page',
value: '100',
},
operations: {
pagination: {
type: 'generic',
properties: {
continue: `={{ !!(${parseLinkHeader.toString()})($response.headers?.link).next }}`,
request: {
url: `={{ (${parseLinkHeader.toString()})($response.headers?.link)?.next ?? $request.url }}`,
},
},
},
},
},
},
];

View File

@@ -0,0 +1,47 @@
import type { INodeProperties } from 'n8n-workflow';
import { repoNameSelect, repoOwnerSelect } from '../../shared/descriptions';
import { issueCommentGetManyDescription } from './getAll';
const showOnlyForIssueComments = {
resource: ['issueComment'],
};
export const issueCommentDescription: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: showOnlyForIssueComments,
},
options: [
{
name: 'Get Many',
value: 'getAll',
action: 'Get issue comments',
description: 'Get issue comments',
routing: {
request: {
method: 'GET',
url: '=/repos/{{$parameter.owner}}/{{$parameter.repository}}/issues/comments',
},
},
},
],
default: 'getAll',
},
{
...repoOwnerSelect,
displayOptions: {
show: showOnlyForIssueComments,
},
},
{
...repoNameSelect,
displayOptions: {
show: showOnlyForIssueComments,
},
},
...issueCommentGetManyDescription,
];

View File

@@ -0,0 +1,151 @@
import type { INodeProperties } from 'n8n-workflow';
export const repoOwnerSelect: INodeProperties = {
displayName: 'Repository Owner',
name: 'owner',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
modes: [
{
displayName: 'Repository Owner',
name: 'list',
type: 'list',
placeholder: 'Select an owner...',
typeOptions: {
searchListMethod: 'getUsers',
searchable: true,
searchFilterRequired: false,
},
},
{
displayName: 'Link',
name: 'url',
type: 'string',
placeholder: 'e.g. https://github.com/n8n-io',
extractValue: {
type: 'regex',
regex: 'https:\\/\\/github.com\\/([-_0-9a-zA-Z]+)',
},
validation: [
{
type: 'regex',
properties: {
regex: 'https:\\/\\/github.com\\/([-_0-9a-zA-Z]+)(?:.*)',
errorMessage: 'Not a valid GitHub URL',
},
},
],
},
{
displayName: 'By Name',
name: 'name',
type: 'string',
placeholder: 'e.g. n8n-io',
validation: [
{
type: 'regex',
properties: {
regex: '[-_a-zA-Z0-9]+',
errorMessage: 'Not a valid GitHub Owner Name',
},
},
],
url: '=https://github.com/{{$value}}',
},
],
};
export const repoNameSelect: INodeProperties = {
displayName: 'Repository Name',
name: 'repository',
type: 'resourceLocator',
default: {
mode: 'list',
value: '',
},
required: true,
modes: [
{
displayName: 'Repository Name',
name: 'list',
type: 'list',
placeholder: 'Select an Repository...',
typeOptions: {
searchListMethod: 'getRepositories',
searchable: true,
},
},
{
displayName: 'Link',
name: 'url',
type: 'string',
placeholder: 'e.g. https://github.com/n8n-io/n8n',
extractValue: {
type: 'regex',
regex: 'https:\\/\\/github.com\\/(?:[-_0-9a-zA-Z]+)\\/([-_.0-9a-zA-Z]+)',
},
validation: [
{
type: 'regex',
properties: {
regex: 'https:\\/\\/github.com\\/(?:[-_0-9a-zA-Z]+)\\/([-_.0-9a-zA-Z]+)(?:.*)',
errorMessage: 'Not a valid GitHub Repository URL',
},
},
],
},
{
displayName: 'By Name',
name: 'name',
type: 'string',
placeholder: 'e.g. n8n',
validation: [
{
type: 'regex',
properties: {
regex: '[-_.0-9a-zA-Z]+',
errorMessage: 'Not a valid GitHub Repository Name',
},
},
],
url: '=https://github.com/{{$parameter["owner"]}}/{{$value}}',
},
],
displayOptions: {
hide: {
resource: ['user', 'organization'],
operation: ['getRepositories'],
},
},
};
export const issueSelect: INodeProperties = {
displayName: 'Issue',
name: 'issue',
type: 'resourceLocator',
default: {
mode: 'list',
value: '',
},
required: true,
modes: [
{
displayName: 'Issue',
name: 'list',
type: 'list',
placeholder: 'Select an Issue...',
typeOptions: {
searchListMethod: 'getIssues',
searchable: true,
},
},
{
displayName: 'By ID',
name: 'name',
type: 'string',
placeholder: 'e.g. 123',
url: '=https://github.com/{{$parameter.owner}}/{{$parameter.repository}}/issues/{{$value}}',
},
],
};

View File

@@ -0,0 +1,32 @@
import type {
IHookFunctions,
IExecuteFunctions,
IExecuteSingleFunctions,
ILoadOptionsFunctions,
IHttpRequestMethods,
IDataObject,
IHttpRequestOptions,
} from 'n8n-workflow';
export async function githubApiRequest(
this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
resource: string,
qs: IDataObject = {},
body: IDataObject | undefined = undefined,
) {
const authenticationMethod = this.getNodeParameter('authentication', 0);
const options: IHttpRequestOptions = {
method: method,
qs,
body,
url: `https://api.github.com${resource}`,
json: true,
};
const credentialType =
authenticationMethod === 'accessToken' ? 'githubIssuesApi' : 'githubIssuesOAuth2Api';
return this.helpers.httpRequestWithAuthentication.call(this, credentialType, options);
}

View File

@@ -0,0 +1,14 @@
export function parseLinkHeader(header?: string): { [rel: string]: string } {
const links: { [rel: string]: string } = {};
for (const part of header?.split(',') ?? []) {
const section = part.trim();
const match = section.match(/^<([^>]+)>\s*;\s*rel="?([^"]+)"?/);
if (match) {
const [, url, rel] = match;
links[rel] = url;
}
}
return links;
}

View File

@@ -0,0 +1,53 @@
{
"name": "{{nodePackageName}}",
"version": "0.1.0",
"description": "n8n community node to work with the GitHub Issues API",
"license": "MIT",
"homepage": "https://example.com",
"keywords": [
"n8n-community-node-package"
],
"author": {
"name": "{{user.name}}",
"email": "{{user.email}}"
},
"repository": {
"type": "git",
"url": ""
},
"scripts": {
"build": "n8n-node build",
"build:watch": "tsc --watch",
"dev": "n8n-node dev",
"lint": "eslint .",
"release": "release-it"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"credentials": [
"dist/credentials/GithubIssuesApi.credentials.js",
"dist/credentials/GithubIssuesOAuth2Api.credentials.js"
],
"nodes": [
"dist/nodes/GithubIssues/GithubIssues.node.js"
]
},
"release-it": {
"hooks": {
"before:init": "{{packageManager.name}} run lint && {{packageManager.name}} run build"
}
},
"devDependencies": {
"@n8n/node-cli": "*",
"eslint": "9.32.0",
"prettier": "3.6.2",
"release-it": "^19.0.4",
"typescript": "5.9.2"
},
"peerDependencies": {
"n8n-workflow": "*"
}
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"strict": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2019",
"lib": ["es2019", "es2020", "es2022.error"],
"removeComments": true,
"useUnknownInCatchVariables": false,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"preserveConstEnums": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"incremental": true,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true,
"outDir": "./dist/"
},
"include": ["credentials/**/*", "nodes/**/*", "nodes/**/*.json", "package.json"]
}

View File

@@ -0,0 +1,35 @@
import { customTemplate } from './declarative/custom/template';
import { githubIssuesTemplate } from './declarative/github-issues/template';
import { exampleTemplate } from './programmatic/example/template';
export const templates = {
declarative: {
githubIssues: githubIssuesTemplate,
custom: customTemplate,
},
programmatic: {
example: exampleTemplate,
},
} as const;
export type TemplateMap = typeof templates;
export type TemplateType = keyof TemplateMap;
export type TemplateName<T extends TemplateType> = keyof TemplateMap[T];
export function getTemplate<T extends TemplateType, N extends TemplateName<T>>(
type: T,
name: N,
): TemplateMap[T][N] {
return templates[type][name];
}
export function isTemplateType(val: unknown): val is TemplateType {
return typeof val === 'string' && val in templates;
}
export function isTemplateName<T extends TemplateType>(
type: T,
name: unknown,
): name is TemplateName<T> {
return typeof name === 'string' && name in templates[type];
}

View File

@@ -0,0 +1,9 @@
import path from 'node:path';
import { createTemplate } from '../../../core';
export const exampleTemplate = createTemplate({
name: 'Example',
description: 'Barebones example node',
path: path.join(__dirname, 'template'),
});

View File

@@ -0,0 +1,46 @@
# {{nodePackageName}}
This is an n8n community node. It lets you use _app/service name_ in your n8n workflows.
_App/service name_ is _one or two sentences describing the service this node integrates with_.
[n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/reference/license/) workflow automation platform.
[Installation](#installation)
[Operations](#operations)
[Credentials](#credentials)
[Compatibility](#compatibility)
[Usage](#usage)
[Resources](#resources)
[Version history](#version-history)
## Installation
Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community nodes documentation.
## Operations
_List the operations supported by your node._
## Credentials
_If users need to authenticate with the app/service, provide details here. You should include prerequisites (such as signing up with the service), available authentication methods, and how to set them up._
## Compatibility
_State the minimum n8n version, as well as which versions you test against. You can also include any known version incompatibility issues._
## Usage
_This is an optional section. Use it to help users with any difficult or confusing aspects of the node._
_By the time users are looking for community nodes, they probably already know n8n basics. But if you expect new users, you can link to the [Try it out](https://docs.n8n.io/try-it-out/) documentation to help them get started._
## Resources
* [n8n community nodes documentation](https://docs.n8n.io/integrations/#community-nodes)
* _Link to app/service documentation._
## Version history
_This is another optional section. If your node has multiple versions, include a short description of available versions and what changed, as well as any compatibility impact._

View File

@@ -0,0 +1,3 @@
import { config } from '@n8n/node-cli/eslint';
export default config;

View File

@@ -0,0 +1,18 @@
{
"node": "{{nodePackageName}}",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Development", "Developer Tools"],
"resources": {
"credentialDocumentation": [
{
"url": "https://github.com/org/repo?tab=readme-ov-file#credentials"
}
],
"primaryDocumentation": [
{
"url": "https://github.com/org/repo?tab=readme-ov-file"
}
]
}
}

View File

@@ -0,0 +1,78 @@
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
export class Example implements INodeType {
description: INodeTypeDescription = {
displayName: 'Example',
name: 'example',
icon: { light: 'file:example.svg', dark: 'file:example.dark.svg' },
group: ['input'],
version: 1,
description: 'Basic Example Node',
defaults: {
name: 'Example',
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
usableAsTool: true,
properties: [
// Node properties which the user gets displayed and
// can change on the node.
{
displayName: 'My String',
name: 'myString',
type: 'string',
default: '',
placeholder: 'Placeholder value',
description: 'The description text',
},
],
};
// The function below is responsible for actually doing whatever this node
// is supposed to do. In this case, we're just appending the `myString` property
// with whatever the user has entered.
// You can make async calls and use `await`.
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
let item: INodeExecutionData;
let myString: string;
// Iterates over all input items and add the key "myString" with the
// value the parameter "myString" resolves to.
// (This could be a different value for each item in case it contains an expression)
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
try {
myString = this.getNodeParameter('myString', itemIndex, '') as string;
item = items[itemIndex];
item.json.myString = myString;
} catch (error) {
// This node should never fail but we want to showcase how
// to handle errors.
if (this.continueOnFail()) {
items.push({ json: this.getInputData(itemIndex)[0].json, error, pairedItem: itemIndex });
} else {
// Adding `itemIndex` allows other workflows to handle this error
if (error.context) {
// If the error thrown already contains the context property,
// only append the itemIndex
error.context.itemIndex = itemIndex;
throw error;
}
throw new NodeOperationError(this.getNode(), error, {
itemIndex,
});
}
}
}
return [items];
}
}

View File

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

After

Width:  |  Height:  |  Size: 698 B

View File

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

After

Width:  |  Height:  |  Size: 696 B

View File

@@ -0,0 +1,50 @@
{
"name": "{{nodePackageName}}",
"version": "0.1.0",
"description": "Example n8n community node",
"license": "MIT",
"homepage": "https://example.com",
"keywords": [
"n8n-community-node-package"
],
"author": {
"name": "{{user.name}}",
"email": "{{user.email}}"
},
"repository": {
"type": "git",
"url": ""
},
"scripts": {
"build": "n8n-node build",
"build:watch": "tsc --watch",
"dev": "n8n-node dev",
"lint": "eslint .",
"release": "release-it"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"credentials": [],
"nodes": [
"dist/nodes/Example/Example.node.js"
]
},
"release-it": {
"hooks": {
"before:init": "{{packageManager.name}} run lint && {{packageManager.name}} run build"
}
},
"devDependencies": {
"@n8n/node-cli": "*",
"eslint": "9.32.0",
"prettier": "3.6.2",
"release-it": "^19.0.4",
"typescript": "5.9.2"
},
"peerDependencies": {
"n8n-workflow": "*"
}
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"strict": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2019",
"lib": ["es2019", "es2020", "es2022.error"],
"removeComments": true,
"useUnknownInCatchVariables": false,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"preserveConstEnums": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"incremental": true,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true,
"outDir": "./dist/"
},
"include": ["credentials/**/*", "nodes/**/*", "nodes/**/*.json", "package.json"]
}

View File

@@ -0,0 +1,42 @@
import type {
IAuthenticateGeneric,
ICredentialTestRequest,
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class ExampleApi implements ICredentialType {
name = 'exampleApi';
displayName = 'Example API';
// Link to your community node's README
documentationUrl = 'https://github.com/org/repo?tab=readme-ov-file#credentials';
properties: INodeProperties[] = [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string',
typeOptions: { password: true },
required: true,
default: '',
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
'x-api-key': '={{$credentials.apiKey}}',
},
},
};
test: ICredentialTestRequest = {
request: {
baseURL: 'https://api.example.com/v2',
url: '/v1/user',
},
};
}

View File

@@ -0,0 +1,50 @@
import type {
IAuthenticateGeneric,
ICredentialTestRequest,
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class ExampleApi implements ICredentialType {
name = 'exampleApi';
displayName = 'Example API';
// Link to your community node's README
documentationUrl = 'https://github.com/org/repo?tab=readme-ov-file#credentials';
properties: INodeProperties[] = [
{
displayName: 'Username',
name: 'username',
type: 'string',
default: '',
},
{
displayName: 'Password',
name: 'password',
type: 'string',
typeOptions: {
password: true,
},
default: '',
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
auth: {
username: '={{$credentials.username}}',
password: '={{$credentials.password}}',
},
},
};
test: ICredentialTestRequest = {
request: {
baseURL: 'https://api.example.com/v2',
url: '/v1/user',
},
};
}

View File

@@ -0,0 +1,42 @@
import type {
IAuthenticateGeneric,
ICredentialTestRequest,
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class ExampleApi implements ICredentialType {
name = 'exampleApi';
displayName = 'Example API';
// Link to your community node's README
documentationUrl = 'https://github.com/org/repo?tab=readme-ov-file#credentials';
properties: INodeProperties[] = [
{
displayName: 'Access Token',
name: 'accessToken',
type: 'string',
typeOptions: { password: true },
required: true,
default: '',
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
Authorization: '=Bearer {{$credentials.accessToken}}',
},
},
};
test: ICredentialTestRequest = {
request: {
baseURL: 'https://api.example.com/v2',
url: '/v1/user',
},
};
}

View File

@@ -0,0 +1,48 @@
import type {
IAuthenticateGeneric,
ICredentialTestRequest,
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class ExampleApi implements ICredentialType {
name = 'exampleApi';
displayName = 'Example API';
// Link to your community node's README
documentationUrl = 'https://github.com/org/repo?tab=readme-ov-file#credentials';
properties: INodeProperties[] = [
{
displayName: 'Access Token',
name: 'accessToken',
type: 'string',
typeOptions: { password: true },
required: true,
default: '',
},
];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
body: {
token: '={{$credentials.accessToken}}',
},
qs: {
token: '={{$credentials.accessToken}}',
},
headers: {
Authorization: '=Bearer {{$credentials.accessToken}}',
},
},
};
test: ICredentialTestRequest = {
request: {
baseURL: 'https://api.example.com/v2',
url: '/v1/user',
},
};
}

View File

@@ -0,0 +1,51 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class ExampleOAuth2Api implements ICredentialType {
name = 'exampleOAuth2Api';
extends = ['oAuth2Api'];
displayName = 'Example OAuth2 API';
// Link to your community node's README
documentationUrl = 'https://github.com/org/repo?tab=readme-ov-file#credentials';
properties: INodeProperties[] = [
{
displayName: 'Grant Type',
name: 'grantType',
type: 'hidden',
default: 'authorizationCode',
},
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden',
default: 'https://api.example.com/oauth/authorize',
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden',
default: 'https://api.example.com/oauth/token',
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden',
default: '',
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden',
default: 'users:read users:write companies:read',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden',
default: 'header',
},
];
}

View File

@@ -0,0 +1,45 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class ExampleOAuth2Api implements ICredentialType {
name = 'exampleOAuth2Api';
extends = ['oAuth2Api'];
displayName = 'Example OAuth2 API';
// Link to your community node's README
documentationUrl = 'https://github.com/org/repo?tab=readme-ov-file#credentials';
properties: INodeProperties[] = [
{
displayName: 'Grant Type',
name: 'grantType',
type: 'hidden',
default: 'clientCredentials',
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden',
default: 'https://api.example.com/oauth/token',
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden',
default: '',
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden',
default: 'users:read users:write companies:read',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden',
default: 'body',
},
];
}

View File

@@ -0,0 +1,28 @@
name: CI
on:
pull_request:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: '{{packageManager.name}} {{packageManager.installCommand}}'
- name: Run lint
run: '{{packageManager.name}} run lint'
- name: Run build
run: '{{packageManager.name}} run build'

View File

@@ -0,0 +1 @@
dist

View File

@@ -0,0 +1,51 @@
module.exports = {
/**
* https://prettier.io/docs/en/options.html#semicolons
*/
semi: true,
/**
* https://prettier.io/docs/en/options.html#trailing-commas
*/
trailingComma: 'all',
/**
* https://prettier.io/docs/en/options.html#bracket-spacing
*/
bracketSpacing: true,
/**
* https://prettier.io/docs/en/options.html#tabs
*/
useTabs: true,
/**
* https://prettier.io/docs/en/options.html#tab-width
*/
tabWidth: 2,
/**
* https://prettier.io/docs/en/options.html#arrow-function-parentheses
*/
arrowParens: 'always',
/**
* https://prettier.io/docs/en/options.html#quotes
*/
singleQuote: true,
/**
* https://prettier.io/docs/en/options.html#quote-props
*/
quoteProps: 'as-needed',
/**
* https://prettier.io/docs/en/options.html#end-of-line
*/
endOfLine: 'lf',
/**
* https://prettier.io/docs/en/options.html#print-width
*/
printWidth: 100,
};

View File

@@ -0,0 +1,12 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to running n8n",
"processId": "${command:PickProcess}",
"request": "attach",
"skipFiles": ["<node_internals>/**"],
"type": "node"
}
]
}

View File

@@ -0,0 +1,70 @@
import { Project, SyntaxKind } from 'ts-morph';
import { updateStringProperty, getChildObjectLiteral } from './ast';
describe('TS Morph AST utils', () => {
const createSourceFile = (content: string) => {
const project = new Project();
return project.createSourceFile('test.ts', content);
};
describe('updateStringProperty', () => {
it('should update object literal property', () => {
const content = 'export const config = { name: "oldName", version: "1.0.0" };';
const sourceFile = createSourceFile(content);
const configVar = sourceFile.getVariableDeclarationOrThrow('config');
const configObj = configVar.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
updateStringProperty({ obj: configObj, key: 'name', value: 'newName' });
expect(sourceFile.getFullText()).toContain('name: "newName"');
expect(sourceFile.getFullText()).not.toContain('name: "oldName"');
});
it('should update class property', () => {
const content = 'export class Config { name = "oldName"; version = "1.0.0"; }';
const sourceFile = createSourceFile(content);
const classDecl = sourceFile.getClassOrThrow('Config');
updateStringProperty({ obj: classDecl, key: 'name', value: 'newName' });
expect(sourceFile.getFullText()).toContain('name = "newName"');
});
it('should throw for non-string property', () => {
const content = 'export const config = { name: "test", count: 42 };';
const sourceFile = createSourceFile(content);
const configVar = sourceFile.getVariableDeclarationOrThrow('config');
const configObj = configVar.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
expect(() => updateStringProperty({ obj: configObj, key: 'count', value: 'new' })).toThrow();
});
});
describe('getChildObjectLiteral', () => {
it('should return nested object', () => {
const content = `
export const config = {
database: { host: "localhost", port: "5432" },
cache: { ttl: "3600" }
};`;
const sourceFile = createSourceFile(content);
const configVar = sourceFile.getVariableDeclarationOrThrow('config');
const configObj = configVar.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
const dbObj = getChildObjectLiteral({ obj: configObj, key: 'database' });
expect(dbObj.getProperty('host')).toBeDefined();
expect(dbObj.getProperty('port')).toBeDefined();
});
it('should throw for non-object property', () => {
const content = 'export const config = { name: "test", count: 42 };';
const sourceFile = createSourceFile(content);
const configVar = sourceFile.getVariableDeclarationOrThrow('config');
const configObj = configVar.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
expect(() => getChildObjectLiteral({ obj: configObj, key: 'name' })).toThrow();
});
});
});

View File

@@ -0,0 +1,44 @@
import {
Project,
SyntaxKind,
type ClassDeclaration,
type ObjectLiteralExpression,
type PropertyAssignment,
type PropertyDeclaration,
} from 'ts-morph';
export const loadSingleSourceFile = (path: string) => {
const project = new Project({
skipFileDependencyResolution: true,
});
return project.addSourceFileAtPath(path);
};
const setStringInitializer = (prop: PropertyAssignment | PropertyDeclaration, value: string) => {
prop.getInitializerIfKindOrThrow(SyntaxKind.StringLiteral).setLiteralValue(value);
};
export const updateStringProperty = ({
obj,
key,
value,
}: { obj: ObjectLiteralExpression | ClassDeclaration; key: string; value: string }) => {
const prop = obj.getPropertyOrThrow(key);
if (prop.isKind(SyntaxKind.PropertyAssignment)) {
setStringInitializer(prop.asKindOrThrow(SyntaxKind.PropertyAssignment), value);
} else if (prop.isKind(SyntaxKind.PropertyDeclaration)) {
setStringInitializer(prop.asKindOrThrow(SyntaxKind.PropertyDeclaration), value);
}
};
export const getChildObjectLiteral = ({
obj,
key,
}: { obj: ObjectLiteralExpression; key: string }) => {
return obj
.getPropertyOrThrow(key)
.asKindOrThrow(SyntaxKind.PropertyAssignment)
.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
};

View File

@@ -0,0 +1,232 @@
import type { Dirent, Stats } from 'node:fs';
import fs from 'node:fs/promises';
import { mock } from 'vitest-mock-extended';
import {
folderExists,
copyFolder,
delayAtLeast,
writeFileSafe,
ensureFolder,
renameFilesInDirectory,
renameDirectory,
} from './filesystem';
vi.mock('node:fs/promises');
const mockFs = vi.mocked(fs);
describe('file system utils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('folderExists', () => {
it('should return true for directory', async () => {
mockFs.stat.mockResolvedValue(mock<Stats>({ isDirectory: () => true }));
const result = await folderExists('/test/dir');
expect(result).toBe(true);
expect(mockFs.stat).toHaveBeenCalledWith('/test/dir');
});
it('should return false for file', async () => {
mockFs.stat.mockResolvedValue(mock<Stats>({ isDirectory: () => false }));
const result = await folderExists('/test/file.txt');
expect(result).toBe(false);
});
it('should return false when path does not exist', async () => {
mockFs.stat.mockRejectedValue(new Error('ENOENT'));
const result = await folderExists('/nonexistent');
expect(result).toBe(false);
});
});
describe('copyFolder', () => {
it('should copy folder recursively', async () => {
const rootDirContent = [
mock<Dirent>({ name: 'root.txt', isDirectory: () => false }),
mock<Dirent>({ name: 'subdir', isDirectory: () => true }),
];
const subDirContent = [mock<Dirent>({ name: 'nested.txt', isDirectory: () => false })];
// @ts-expect-error ts does not select correct readdir overload
mockFs.readdir.mockResolvedValueOnce(rootDirContent).mockResolvedValueOnce(subDirContent);
mockFs.mkdir.mockResolvedValue(undefined);
mockFs.copyFile.mockResolvedValue();
await copyFolder({ source: '/src', destination: '/dest' });
expect(mockFs.mkdir).toHaveBeenCalledWith('/dest', { recursive: true });
expect(mockFs.copyFile).toHaveBeenCalledWith('/src/root.txt', '/dest/root.txt');
expect(mockFs.copyFile).toHaveBeenCalledWith(
'/src/subdir/nested.txt',
'/dest/subdir/nested.txt',
);
expect(mockFs.mkdir).toHaveBeenCalledWith('/dest/subdir', { recursive: true });
});
it('should ignore specified files', async () => {
const dirs = [
mock<Dirent>({ name: 'keep.txt', isDirectory: () => false }),
mock<Dirent>({ name: 'ignore.txt', isDirectory: () => false }),
];
// @ts-expect-error ts does not select correct readdir overload
mockFs.readdir.mockResolvedValueOnce(dirs);
mockFs.mkdir.mockResolvedValue(undefined);
mockFs.copyFile.mockResolvedValue();
await copyFolder({
source: '/src',
destination: '/dest',
ignore: ['ignore.txt'],
});
expect(mockFs.copyFile).toHaveBeenCalledWith('/src/keep.txt', '/dest/keep.txt');
expect(mockFs.copyFile).not.toHaveBeenCalledWith('/src/ignore.txt', '/dest/ignore.txt');
});
});
describe('delayAtLeast', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should resolve after minimum delay', async () => {
const promise = Promise.resolve('result');
const resultPromise = delayAtLeast(promise, 100);
vi.advanceTimersByTime(90);
vi.runAllTicks();
let resolved = false;
void resultPromise.then(() => {
resolved = true;
});
vi.runAllTicks();
expect(resolved).toBe(false);
vi.advanceTimersByTime(10);
vi.runAllTicks();
const result = await resultPromise;
expect(result).toBe('result');
});
it('should not add delay if promise takes longer', async () => {
const promise = new Promise((resolve) => {
setTimeout(() => resolve('slow'), 200);
});
const resultPromise = delayAtLeast(promise, 100);
vi.advanceTimersByTime(100);
vi.runAllTicks();
let resolved = false;
void resultPromise.then(() => {
resolved = true;
});
vi.runAllTicks();
expect(resolved).toBe(false);
vi.advanceTimersByTime(100);
vi.runAllTicks();
const result = await resultPromise;
expect(result).toBe('slow');
});
});
describe('writeFileSafe', () => {
it('should create directory and write file', async () => {
mockFs.mkdir.mockResolvedValue(undefined);
mockFs.writeFile.mockResolvedValue();
await writeFileSafe('/path/to/file.txt', 'content');
expect(mockFs.mkdir).toHaveBeenCalledWith('/path/to', { recursive: true });
expect(mockFs.writeFile).toHaveBeenCalledWith('/path/to/file.txt', 'content');
});
it('should handle Uint8Array content', async () => {
mockFs.mkdir.mockResolvedValue(undefined);
mockFs.writeFile.mockResolvedValue();
const buffer = new Uint8Array([1, 2, 3]);
await writeFileSafe('/file.bin', buffer);
expect(mockFs.writeFile).toHaveBeenCalledWith('/file.bin', buffer);
});
});
describe('ensureFolder', () => {
it('should create directory recursively', async () => {
mockFs.mkdir.mockResolvedValue(undefined);
await ensureFolder('/deep/nested/dir');
expect(mockFs.mkdir).toHaveBeenCalledWith('/deep/nested/dir', { recursive: true });
});
});
describe('renameFilesInDirectory', () => {
it('should rename files with old name', async () => {
// @ts-expect-error ts does not select correct readdir overload
mockFs.readdir.mockResolvedValue(['oldName.svg', 'keep.txt', 'oldNameFile.js']);
mockFs.rename.mockResolvedValue();
await renameFilesInDirectory('/dir', 'oldName', 'newName');
expect(mockFs.rename).toHaveBeenCalledWith('/dir/oldName.svg', '/dir/newName.svg');
expect(mockFs.rename).toHaveBeenCalledWith('/dir/oldNameFile.js', '/dir/newNameFile.js');
expect(mockFs.rename).not.toHaveBeenCalledWith('/dir/keep.txt', expect.any(String));
});
it('should rename files with camelCase variants', async () => {
// @ts-expect-error ts does not select correct readdir overload
mockFs.readdir.mockResolvedValue(['myOldName.svg', 'MyOldName.node.js']);
mockFs.rename.mockResolvedValue();
await renameFilesInDirectory('/dir', 'MyOldName', 'MyNewName');
expect(mockFs.rename).toHaveBeenCalledTimes(2);
expect(mockFs.rename).toHaveBeenCalledWith('/dir/myOldName.svg', '/dir/myNewName.svg');
expect(mockFs.rename).toHaveBeenCalledWith(
'/dir/MyOldName.node.js',
'/dir/MyNewName.node.js',
);
});
});
describe('renameDirectory', () => {
it('should rename directory and return new path', async () => {
mockFs.rename.mockResolvedValue();
const result = await renameDirectory('/parent/oldDir', 'newDir');
expect(mockFs.rename).toHaveBeenCalledWith('/parent/oldDir', '/parent/newDir');
expect(result).toBe('/parent/newDir');
});
it('should handle root directory', async () => {
mockFs.rename.mockResolvedValue();
const result = await renameDirectory('/oldDir', 'newDir');
expect(result).toBe('/newDir');
});
});
});

View File

@@ -0,0 +1,89 @@
import { camelCase } from 'change-case';
import fs from 'node:fs/promises';
import path from 'node:path';
export async function folderExists(dir: string) {
try {
const stat = await fs.stat(dir);
return stat.isDirectory();
} catch (error: unknown) {
return false;
}
}
export async function copyFolder({
source: source,
destination,
ignore = [],
}: { source: string; destination: string; ignore?: string[] }): Promise<void> {
const ignoreSet = new Set(ignore);
async function walkAndCopy(currentSrc: string, currentDest: string): Promise<void> {
const entries = await fs.readdir(currentSrc, { withFileTypes: true });
await Promise.all(
entries.map(async (entry) => {
if (ignoreSet.has(entry.name)) return;
const srcPath = path.join(currentSrc, entry.name);
const destPath = path.join(currentDest, entry.name);
if (entry.isDirectory()) {
await fs.mkdir(destPath, { recursive: true });
await walkAndCopy(srcPath, destPath);
} else {
await fs.copyFile(srcPath, destPath);
}
}),
);
}
await fs.mkdir(destination, { recursive: true });
await walkAndCopy(source, destination);
}
export async function delayAtLeast<T>(promise: Promise<T>, minMs: number): Promise<T> {
const delayPromise = new Promise((res) => setTimeout(res, minMs));
const [result] = await Promise.all([promise, delayPromise]);
return result;
}
export async function writeFileSafe(
filePath: string,
contents: string | Uint8Array,
): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, contents);
}
export async function ensureFolder(dir: string) {
return await fs.mkdir(dir, { recursive: true });
}
export async function renameFilesInDirectory(
dirPath: string,
oldName: string,
newName: string,
): Promise<void> {
const files = await fs.readdir(dirPath);
for (const file of files) {
const oldPath = path.resolve(dirPath, file);
const oldFileName = path.basename(oldPath);
const newFileName = oldFileName
.replace(oldName, newName)
.replace(camelCase(oldName), camelCase(newName));
if (newFileName !== oldFileName) {
const newPath = path.resolve(dirPath, newFileName);
await fs.rename(oldPath, newPath);
}
}
}
export async function renameDirectory(oldDirPath: string, newDirName: string): Promise<string> {
const parentDir = path.dirname(oldDirPath);
const newDirPath = path.resolve(parentDir, newDirName);
await fs.rename(oldDirPath, newDirPath);
return newDirPath;
}

View File

@@ -0,0 +1,60 @@
import { execSync } from 'child_process';
import { tryReadGitUser } from './git';
vi.mock('child_process');
describe('git utils', () => {
describe('tryReadGitUser', () => {
const mockedExecSync = vi.mocked(execSync);
beforeEach(() => {
vi.clearAllMocks();
});
it('returns name and email if git config is set', () => {
mockedExecSync
.mockReturnValueOnce(Buffer.from('Alice\n'))
.mockReturnValueOnce(Buffer.from('alice@example.com\n'));
const user = tryReadGitUser();
expect(user).toEqual({ name: 'Alice', email: 'alice@example.com' });
expect(mockedExecSync).toHaveBeenCalledWith('git config --get user.name', {
stdio: ['pipe', 'pipe', 'ignore'],
});
expect(mockedExecSync).toHaveBeenCalledWith('git config --get user.email', {
stdio: ['pipe', 'pipe', 'ignore'],
});
});
it('handles missing git name', () => {
mockedExecSync
.mockImplementationOnce(() => {
throw new Error('no name');
})
.mockReturnValueOnce(Buffer.from('alice@example.com\n'));
const user = tryReadGitUser();
expect(user).toEqual({ name: '', email: 'alice@example.com' });
});
it('handles missing git email', () => {
mockedExecSync.mockReturnValueOnce(Buffer.from('Alice\n')).mockImplementationOnce(() => {
throw new Error('no email');
});
const user = tryReadGitUser();
expect(user).toEqual({ name: 'Alice', email: '' });
});
it('returns empty user if nothing is configured', () => {
mockedExecSync.mockImplementation(() => {
throw new Error('no config');
});
const user = tryReadGitUser();
expect(user).toEqual({ name: '', email: '' });
});
});
});

View File

@@ -0,0 +1,34 @@
import { execSync } from 'child_process';
type GitUser = {
name?: string;
email?: string;
};
export function tryReadGitUser(): GitUser {
const user: GitUser = { name: '', email: '' };
try {
const name = execSync('git config --get user.name', {
stdio: ['pipe', 'pipe', 'ignore'],
})
.toString()
.trim();
if (name) user.name = name;
} catch {
// ignore
}
try {
const email = execSync('git config --get user.email', {
stdio: ['pipe', 'pipe', 'ignore'],
})
.toString()
.trim();
if (email) user.email = email;
} catch {
// ignore
}
return user;
}

View File

@@ -0,0 +1,45 @@
import { log } from '@clack/prompts';
import { spawn } from 'node:child_process';
import pico from 'picocolors';
type PackageManager = 'npm' | 'yarn' | 'pnpm';
export function detectPackageManager(): PackageManager | null {
if ('npm_config_user_agent' in process.env) {
const ua = process.env['npm_config_user_agent'] ?? '';
if (ua.includes('pnpm')) return 'pnpm';
if (ua.includes('yarn')) return 'yarn';
if (ua.includes('npm')) return 'npm';
}
return null;
}
export async function installDependencies({
dir,
packageManager,
}: { dir: string; packageManager: PackageManager }): Promise<void> {
return await new Promise((resolve, reject) => {
const child = spawn(packageManager, ['install'], {
cwd: dir,
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
});
const output: Buffer[] = [];
child.stdout.on('data', (chunk: Buffer) => output.push(chunk));
child.stderr.on('data', (chunk: Buffer) => output.push(chunk));
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
const error = new Error(`${packageManager} install exited with code ${code}`);
log.error(`${pico.bold(pico.red(error.message))}
${output.map((item) => item.toString()).join('\n')}`);
reject(error);
}
});
});
}

View File

@@ -0,0 +1,64 @@
import { jsonParse } from 'n8n-workflow';
import fs from 'node:fs/promises';
import path from 'node:path';
import prettier from 'prettier';
import { writeFileSafe } from './filesystem';
type N8nPackageJson = {
name: string;
version: string;
n8n?: {
nodes?: string[];
credentials?: string[];
};
};
export async function updatePackageJson(
dirPath: string,
updater: (packageJson: N8nPackageJson) => N8nPackageJson,
) {
const packageJsonPath = path.resolve(dirPath, 'package.json');
const packageJson = jsonParse<N8nPackageJson>(await fs.readFile(packageJsonPath, 'utf-8'));
const updatedPackageJson = updater(packageJson);
await writeFileSafe(
packageJsonPath,
await prettier.format(JSON.stringify(updatedPackageJson), { parser: 'json' }),
);
}
export async function getPackageJson(dirPath: string) {
const packageJsonPath = path.resolve(dirPath, 'package.json');
const packageJson = jsonParse<N8nPackageJson>(await fs.readFile(packageJsonPath, 'utf-8'));
return packageJson;
}
export async function isN8nNodePackage(dirPath = process.cwd()) {
const packageJson = await getPackageJson(dirPath).catch(() => null);
return Array.isArray(packageJson?.n8n?.nodes);
}
export async function getPackageJsonNodes(dirPath: string) {
const packageJson = await getPackageJson(dirPath);
return packageJson.n8n?.nodes ?? [];
}
export async function setNodesPackageJson(dirPath: string, nodes: string[]) {
await updatePackageJson(dirPath, (packageJson) => {
packageJson['n8n'] ??= {};
packageJson['n8n'].nodes = nodes;
return packageJson;
});
}
export async function addCredentialPackageJson(dirPath: string, credential: string) {
await updatePackageJson(dirPath, (packageJson) => {
packageJson['n8n'] ??= {};
packageJson['n8n'].credentials ??= [];
packageJson['n8n'].credentials.push(credential);
return packageJson;
});
}

View File

@@ -0,0 +1,33 @@
import { cancel, isCancel, log } from '@clack/prompts';
import { isN8nNodePackage } from './package';
export async function withCancelHandler<T>(prompt: Promise<symbol | T>): Promise<T> {
const result = await prompt;
if (isCancel(result)) return onCancel();
return result;
}
export const onCancel = (message = 'Cancelled', code = 0) => {
cancel(message);
process.exit(code);
};
export async function ensureN8nPackage(commandName: string) {
const isN8nNode = await isN8nNodePackage();
if (!isN8nNode) {
log.error(`Make sure you are in the root directory of your node package and your package.json contains the "n8n" field
For example:
{
"name": "n8n-nodes-my-app",
"version": "0.1.0",
"n8n": {
"nodes": ["dist/nodes/MyApp.node.js"]
}
}
`);
onCancel(`${commandName} can only be run in an n8n node package`, 1);
process.exit(1);
}
}

View File

@@ -0,0 +1,13 @@
export const validateNodeName = (name: string): string | undefined => {
if (!name) return;
// 1. Matches '@org/n8n-nodes-anything'
const regexScoped = /^@([a-z0-9]+(?:-[a-z0-9]+)*)\/n8n-nodes-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
// 2. Matches 'n8n-nodes-anything'
const regexUnscoped = /^n8n-nodes-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
if (!regexScoped.test(name) && !regexUnscoped.test(name)) {
return 'Node name should be in the format @org/n8n-nodes-example or n8n-nodes-example';
}
return;
};

View File

@@ -0,0 +1,16 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.test.ts",
"src/template/templates/**/template",
"src/template/templates/shared"
]
}

View File

@@ -1,5 +1,5 @@
{
"extends": "@n8n/typescript-config/modern/tsconfig.json",
"extends": ["@n8n/typescript-config/tsconfig.common.json"],
"compilerOptions": {
"baseUrl": ".",
"rootDir": "src",
@@ -7,5 +7,6 @@
"types": ["vite/client", "vitest/globals"],
"isolatedModules": true
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts"],
"exclude": ["src/template/templates/**/template", "src/template/templates/shared"]
}

View File

@@ -1,4 +1,3 @@
import { vitestConfig } from '@n8n/vitest-config/node';
import { defineConfig, mergeConfig } from 'vitest/config';
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { globals: true, disableConsoleIntercept: true } });

502
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@ catalog:
nanoid: 3.3.8
picocolors: 1.0.1
reflect-metadata: 0.2.2
rimraf: ^6.0.1
rimraf: 6.0.1
tsup: ^8.5.0
tsx: ^4.19.3
uuid: 10.0.0