From c26104b3ba4f4fbd191e89b5ecd5903fdb7636fe Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Fri, 15 Aug 2025 14:55:39 +0200 Subject: [PATCH] feat: Add n8n-node CLI with commands to scaffold and develop nodes (#18090) --- packages/@n8n/node-cli/README.md | 18 +- .../bin/{n8n-node.js => n8n-node.mjs} | 0 packages/@n8n/node-cli/eslint.config.mjs | 14 +- packages/@n8n/node-cli/package.json | 40 +- .../@n8n/node-cli/scripts/copy-templates.mjs | 17 + packages/@n8n/node-cli/src/commands/build.ts | 92 ++++ .../@n8n/node-cli/src/commands/create.test.ts | 8 - packages/@n8n/node-cli/src/commands/create.ts | 17 - .../@n8n/node-cli/src/commands/dev/index.ts | 80 +++ .../@n8n/node-cli/src/commands/dev/utils.ts | 123 +++++ .../@n8n/node-cli/src/commands/new/index.ts | 123 +++++ .../@n8n/node-cli/src/commands/new/prompts.ts | 48 ++ .../@n8n/node-cli/src/commands/new/utils.ts | 7 + packages/@n8n/node-cli/src/configs/eslint.ts | 58 ++ packages/@n8n/node-cli/src/index.ts | 9 + packages/@n8n/node-cli/src/modules.d.ts | 19 + .../@n8n/node-cli/src/template/core.test.ts | 111 ++++ packages/@n8n/node-cli/src/template/core.ts | 84 +++ .../templates/declarative/custom/ast.ts | 161 ++++++ .../templates/declarative/custom/prompts.ts | 87 +++ .../templates/declarative/custom/template.ts | 121 +++++ .../declarative/custom/template/README.md | 46 ++ .../custom/template/eslint.config.mjs | 3 + .../template/nodes/Example/Example.node.json | 18 + .../template/nodes/Example/Example.node.ts | 50 ++ .../template/nodes/Example/example.dark.svg | 13 + .../custom/template/nodes/Example/example.svg | 13 + .../nodes/Example/resources/company/getAll.ts | 61 +++ .../nodes/Example/resources/company/index.ts | 34 ++ .../nodes/Example/resources/user/create.ts | 26 + .../nodes/Example/resources/user/get.ts | 17 + .../nodes/Example/resources/user/index.ts | 60 +++ .../declarative/custom/template/package.json | 50 ++ .../declarative/custom/template/tsconfig.json | 25 + .../templates/declarative/custom/types.ts | 8 + .../declarative/github-issues/template.ts | 9 + .../github-issues/template/README.md | 73 +++ .../GithubIssuesApi.credentials.ts | 45 ++ .../GithubIssuesOAuth2Api.credentials.ts | 54 ++ .../github-issues/template/eslint.config.mjs | 3 + .../template/icons/github.dark.svg | 3 + .../github-issues/template/icons/github.svg | 3 + .../nodes/GithubIssues/GithubIssues.node.json | 18 + .../nodes/GithubIssues/GithubIssues.node.ts | 96 ++++ .../GithubIssues/listSearch/getIssues.ts | 49 ++ .../listSearch/getRepositories.ts | 50 ++ .../nodes/GithubIssues/listSearch/getUsers.ts | 49 ++ .../GithubIssues/resources/issue/create.ts | 74 +++ .../nodes/GithubIssues/resources/issue/get.ts | 14 + .../GithubIssues/resources/issue/getAll.ts | 124 +++++ .../GithubIssues/resources/issue/index.ts | 75 +++ .../resources/issueComment/getAll.ts | 65 +++ .../resources/issueComment/index.ts | 47 ++ .../nodes/GithubIssues/shared/descriptions.ts | 151 ++++++ .../nodes/GithubIssues/shared/transport.ts | 32 ++ .../nodes/GithubIssues/shared/utils.ts | 14 + .../github-issues/template/package.json | 53 ++ .../github-issues/template/tsconfig.json | 25 + .../node-cli/src/template/templates/index.ts | 35 ++ .../programmatic/example/template.ts | 9 + .../programmatic/example/template/README.md | 46 ++ .../example/template/eslint.config.mjs | 3 + .../template/nodes/Example/Example.node.json | 18 + .../template/nodes/Example/Example.node.ts | 78 +++ .../template/nodes/Example/example.dark.svg | 13 + .../template/nodes/Example/example.svg | 13 + .../example/template/package.json | 50 ++ .../example/template/tsconfig.json | 25 + .../shared/credentials/apiKey.credentials.ts | 42 ++ .../credentials/basicAuth.credentials.ts | 50 ++ .../shared/credentials/bearer.credentials.ts | 42 ++ .../shared/credentials/custom.credentials.ts | 48 ++ .../oauth2AuthorizationCode.credentials.ts | 51 ++ .../oauth2ClientCredentials.credentials.ts | 45 ++ .../shared/default/.github/workflows/ci.yml | 28 + .../templates/shared/default/.gitignore | 1 + .../templates/shared/default/.prettierrc.js | 51 ++ .../shared/default/.vscode/launch.json | 12 + packages/@n8n/node-cli/src/utils/ast.test.ts | 70 +++ packages/@n8n/node-cli/src/utils/ast.ts | 44 ++ .../node-cli/src/utils/filesystem.test.ts | 232 ++++++++ .../@n8n/node-cli/src/utils/filesystem.ts | 89 ++++ packages/@n8n/node-cli/src/utils/git.test.ts | 60 +++ packages/@n8n/node-cli/src/utils/git.ts | 34 ++ .../node-cli/src/utils/package-manager.ts | 45 ++ packages/@n8n/node-cli/src/utils/package.ts | 64 +++ packages/@n8n/node-cli/src/utils/prompts.ts | 33 ++ .../@n8n/node-cli/src/utils/validation.ts | 13 + packages/@n8n/node-cli/tsconfig.build.json | 16 + packages/@n8n/node-cli/tsconfig.json | 5 +- packages/@n8n/node-cli/vite.config.ts | 3 +- pnpm-lock.yaml | 502 ++++++------------ pnpm-workspace.yaml | 2 +- 93 files changed, 4279 insertions(+), 380 deletions(-) rename packages/@n8n/node-cli/bin/{n8n-node.js => n8n-node.mjs} (100%) create mode 100755 packages/@n8n/node-cli/scripts/copy-templates.mjs create mode 100644 packages/@n8n/node-cli/src/commands/build.ts delete mode 100644 packages/@n8n/node-cli/src/commands/create.test.ts delete mode 100644 packages/@n8n/node-cli/src/commands/create.ts create mode 100644 packages/@n8n/node-cli/src/commands/dev/index.ts create mode 100644 packages/@n8n/node-cli/src/commands/dev/utils.ts create mode 100644 packages/@n8n/node-cli/src/commands/new/index.ts create mode 100644 packages/@n8n/node-cli/src/commands/new/prompts.ts create mode 100644 packages/@n8n/node-cli/src/commands/new/utils.ts create mode 100644 packages/@n8n/node-cli/src/configs/eslint.ts create mode 100644 packages/@n8n/node-cli/src/index.ts create mode 100644 packages/@n8n/node-cli/src/modules.d.ts create mode 100644 packages/@n8n/node-cli/src/template/core.test.ts create mode 100644 packages/@n8n/node-cli/src/template/core.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/ast.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/prompts.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/template.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/template/README.md create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/template/eslint.config.mjs create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/Example.node.json create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/Example.node.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/example.dark.svg create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/example.svg create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/company/getAll.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/company/index.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/user/create.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/user/get.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/user/index.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/template/package.json create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/template/tsconfig.json create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/custom/types.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/README.md create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/credentials/GithubIssuesApi.credentials.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/credentials/GithubIssuesOAuth2Api.credentials.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/eslint.config.mjs create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/icons/github.dark.svg create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/icons/github.svg create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/GithubIssues.node.json create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/GithubIssues.node.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/listSearch/getIssues.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/listSearch/getRepositories.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/listSearch/getUsers.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issue/create.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issue/get.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issue/getAll.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issue/index.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issueComment/getAll.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issueComment/index.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/shared/descriptions.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/shared/transport.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/shared/utils.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/package.json create mode 100644 packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/tsconfig.json create mode 100644 packages/@n8n/node-cli/src/template/templates/index.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/programmatic/example/template.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/programmatic/example/template/README.md create mode 100644 packages/@n8n/node-cli/src/template/templates/programmatic/example/template/eslint.config.mjs create mode 100644 packages/@n8n/node-cli/src/template/templates/programmatic/example/template/nodes/Example/Example.node.json create mode 100644 packages/@n8n/node-cli/src/template/templates/programmatic/example/template/nodes/Example/Example.node.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/programmatic/example/template/nodes/Example/example.dark.svg create mode 100644 packages/@n8n/node-cli/src/template/templates/programmatic/example/template/nodes/Example/example.svg create mode 100644 packages/@n8n/node-cli/src/template/templates/programmatic/example/template/package.json create mode 100644 packages/@n8n/node-cli/src/template/templates/programmatic/example/template/tsconfig.json create mode 100644 packages/@n8n/node-cli/src/template/templates/shared/credentials/apiKey.credentials.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/shared/credentials/basicAuth.credentials.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/shared/credentials/bearer.credentials.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/shared/credentials/custom.credentials.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/shared/credentials/oauth2AuthorizationCode.credentials.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/shared/credentials/oauth2ClientCredentials.credentials.ts create mode 100644 packages/@n8n/node-cli/src/template/templates/shared/default/.github/workflows/ci.yml create mode 100644 packages/@n8n/node-cli/src/template/templates/shared/default/.gitignore create mode 100644 packages/@n8n/node-cli/src/template/templates/shared/default/.prettierrc.js create mode 100644 packages/@n8n/node-cli/src/template/templates/shared/default/.vscode/launch.json create mode 100644 packages/@n8n/node-cli/src/utils/ast.test.ts create mode 100644 packages/@n8n/node-cli/src/utils/ast.ts create mode 100644 packages/@n8n/node-cli/src/utils/filesystem.test.ts create mode 100644 packages/@n8n/node-cli/src/utils/filesystem.ts create mode 100644 packages/@n8n/node-cli/src/utils/git.test.ts create mode 100644 packages/@n8n/node-cli/src/utils/git.ts create mode 100644 packages/@n8n/node-cli/src/utils/package-manager.ts create mode 100644 packages/@n8n/node-cli/src/utils/package.ts create mode 100644 packages/@n8n/node-cli/src/utils/prompts.ts create mode 100644 packages/@n8n/node-cli/src/utils/validation.ts create mode 100644 packages/@n8n/node-cli/tsconfig.build.json diff --git a/packages/@n8n/node-cli/README.md b/packages/@n8n/node-cli/README.md index 7bfce9cfcb..99c51336ee 100644 --- a/packages/@n8n/node-cli/README.md +++ b/packages/@n8n/node-cli/README.md @@ -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 diff --git a/packages/@n8n/node-cli/bin/n8n-node.js b/packages/@n8n/node-cli/bin/n8n-node.mjs similarity index 100% rename from packages/@n8n/node-cli/bin/n8n-node.js rename to packages/@n8n/node-cli/bin/n8n-node.mjs diff --git a/packages/@n8n/node-cli/eslint.config.mjs b/packages/@n8n/node-cli/eslint.config.mjs index 378bbb0b8f..29e2af067b 100644 --- a/packages/@n8n/node-cli/eslint.config.mjs +++ b/packages/@n8n/node-cli/eslint.config.mjs @@ -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' }, + }, +); diff --git a/packages/@n8n/node-cli/package.json b/packages/@n8n/node-cli/package.json index 647fb474b0..763a1ef6ac 100644 --- a/packages/@n8n/node-cli/package.json +++ b/packages/@n8n/node-cli/package.json @@ -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:" } } diff --git a/packages/@n8n/node-cli/scripts/copy-templates.mjs b/packages/@n8n/node-cli/scripts/copy-templates.mjs new file mode 100755 index 0000000000..60cb846181 --- /dev/null +++ b/packages/@n8n/node-cli/scripts/copy-templates.mjs @@ -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 }), + ), +); diff --git a/packages/@n8n/node-cli/src/commands/build.ts b/packages/@n8n/node-cli/src/commands/build.ts new file mode 100644 index 0000000000..f47e608c0d --- /dev/null +++ b/packages/@n8n/node-cli/src/commands/build.ts @@ -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 { + 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 { + 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 }); + }), + ); +} diff --git a/packages/@n8n/node-cli/src/commands/create.test.ts b/packages/@n8n/node-cli/src/commands/create.test.ts deleted file mode 100644 index 0a68c80120..0000000000 --- a/packages/@n8n/node-cli/src/commands/create.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/packages/@n8n/node-cli/src/commands/create.ts b/packages/@n8n/node-cli/src/commands/create.ts deleted file mode 100644 index 64710cc077..0000000000 --- a/packages/@n8n/node-cli/src/commands/create.ts +++ /dev/null @@ -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 { - const { flags } = await this.parse(Create); - - const force = flags.force; - this.log(`hello from commands/create.ts (force=${force})`); - } -} diff --git a/packages/@n8n/node-cli/src/commands/dev/index.ts b/packages/@n8n/node-cli/src/commands/dev/index.ts new file mode 100644 index 0000000000..1d01a0839e --- /dev/null +++ b/packages/@n8n/node-cli/src/commands/dev/index.ts @@ -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 { + 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, + }); + } +} diff --git a/packages/@n8n/node-cli/src/commands/dev/utils.ts b/packages/@n8n/node-cli/src/commands/dev/utils.ts new file mode 100644 index 0000000000..f2040c8578 --- /dev/null +++ b/packages/@n8n/node-cli/src/commands/dev/utils.ts @@ -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 { + 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 { + try { + await runCommand('n8n', ['--version'], {}); + return true; + } catch { + return false; + } + } + + return { + isN8nInstalled, + runCommand, + runPersistentCommand, + }; +} + +export async function readPackageName(): Promise { + return await fs + .readFile('package.json', 'utf-8') + .then((packageJson) => jsonParse<{ name: string }>(packageJson).name); +} diff --git a/packages/@n8n/node-cli/src/commands/new/index.ts b/packages/@n8n/node-cli/src/commands/new/index.ts new file mode 100644 index 0000000000..c680083a77 --- /dev/null +++ b/packages/@n8n/node-cli/src/commands/new/index.ts @@ -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 { + 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} ✨`); + } +} diff --git a/packages/@n8n/node-cli/src/commands/new/prompts.ts b/packages/@n8n/node-cli/src/commands/new/prompts.ts new file mode 100644 index 0000000000..18ef9dcccb --- /dev/null +++ b/packages/@n8n/node-cli/src/commands/new/prompts.ts @@ -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({ + 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', + }), + ); diff --git a/packages/@n8n/node-cli/src/commands/new/utils.ts b/packages/@n8n/node-cli/src/commands/new/utils.ts new file mode 100644 index 0000000000..06a1469dbf --- /dev/null +++ b/packages/@n8n/node-cli/src/commands/new/utils.ts @@ -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 '; +}; diff --git a/packages/@n8n/node-cli/src/configs/eslint.ts b/packages/@n8n/node-cli/src/configs/eslint.ts new file mode 100644 index 0000000000..2773361e3e --- /dev/null +++ b/packages/@n8n/node-cli/src/configs/eslint.ts @@ -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; diff --git a/packages/@n8n/node-cli/src/index.ts b/packages/@n8n/node-cli/src/index.ts new file mode 100644 index 0000000000..9693552492 --- /dev/null +++ b/packages/@n8n/node-cli/src/index.ts @@ -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, +}; diff --git a/packages/@n8n/node-cli/src/modules.d.ts b/packages/@n8n/node-cli/src/modules.d.ts new file mode 100644 index 0000000000..ec2ebe9746 --- /dev/null +++ b/packages/@n8n/node-cli/src/modules.d.ts @@ -0,0 +1,19 @@ +declare module 'eslint-plugin-n8n-nodes-base' { + import type { ESLint } from 'eslint'; + + const plugin: ESLint.Plugin & { + configs: { + community: { + rules: Record; + }; + credentials: { + rules: Record; + }; + nodes: { + rules: Record; + }; + }; + }; + + export default plugin; +} diff --git a/packages/@n8n/node-cli/src/template/core.test.ts b/packages/@n8n/node-cli/src/template/core.test.ts new file mode 100644 index 0000000000..d1620f8381 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/core.test.ts @@ -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); + }); + }); +}); diff --git a/packages/@n8n/node-cli/src/template/core.ts b/packages/@n8n/node-cli/src/template/core.ts new file mode 100644 index 0000000000..517ae722c8 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/core.ts @@ -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 = { + destinationPath: string; + nodePackageName: string; + user?: Partial<{ + name: string; + email: string; + }>; + packageManager: { + name: 'npm' | 'yarn' | 'pnpm'; + installCommand: string; + }; + config: Config; +}; + +type Require = T & { [P in K]-?: T[P] }; +export type Template = { + name: string; + description: string; + path: string; + prompts?: () => Promise; + run?: (data: TemplateData) => Promise; +}; + +export type TemplateWithRun = Require, 'run'>; + +export async function copyTemplateFilesToDestination( + template: Template, + 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( + template: Template, +): TemplateWithRun { + return { + ...template, + run: async (data) => { + await copyDefaultTemplateFilesToDestination(data); + await copyTemplateFilesToDestination(template, data); + await templateStaticFiles(data); + await template.run?.(data); + }, + }; +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/ast.ts b/packages/@n8n/node-cli/src/template/templates/declarative/custom/ast.ts new file mode 100644 index 0000000000..36c25728dc --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/ast.ts @@ -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; +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/prompts.ts b/packages/@n8n/node-cli/src/template/templates/declarative/custom/prompts.ts new file mode 100644 index 0000000000..1a5091bb7f --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/prompts.ts @@ -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({ + 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 )', + }, + { + 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', + }), + ); diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template.ts b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template.ts new file mode 100644 index 0000000000..9038afda51 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template.ts @@ -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 => { + 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, 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) { + 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()); + } +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/README.md b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/README.md new file mode 100644 index 0000000000..7ec673f109 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/README.md @@ -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._ diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/eslint.config.mjs b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/eslint.config.mjs new file mode 100644 index 0000000000..ad811a0baf --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/eslint.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@n8n/node-cli/eslint'; + +export default config; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/Example.node.json b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/Example.node.json new file mode 100644 index 0000000000..8543a62486 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/Example.node.json @@ -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" + } + ] + } +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/Example.node.ts b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/Example.node.ts new file mode 100644 index 0000000000..c618ec6afc --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/Example.node.ts @@ -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, + ], + }; +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/example.dark.svg b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/example.dark.svg new file mode 100644 index 0000000000..c07cb1064b --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/example.dark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/example.svg b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/example.svg new file mode 100644 index 0000000000..703e1fe69c --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/example.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/company/getAll.ts b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/company/getAll.ts new file mode 100644 index 0000000000..d67638ac9e --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/company/getAll.ts @@ -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', + }, + }, + }, + }, + }, +]; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/company/index.ts b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/company/index.ts new file mode 100644 index 0000000000..53267ed4ee --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/company/index.ts @@ -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, +]; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/user/create.ts b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/user/create.ts new file mode 100644 index 0000000000..7cfcf152ab --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/user/create.ts @@ -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', + }, + }, + }, +]; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/user/get.ts b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/user/get.ts new file mode 100644 index 0000000000..e73681fc27 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/user/get.ts @@ -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", + }, +]; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/user/index.ts b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/user/index.ts new file mode 100644 index 0000000000..954130ea8a --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/nodes/Example/resources/user/index.ts @@ -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, +]; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/package.json b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/package.json new file mode 100644 index 0000000000..1411c4cd7e --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/package.json @@ -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": "*" + } +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/tsconfig.json b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/tsconfig.json new file mode 100644 index 0000000000..b73660f375 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/template/tsconfig.json @@ -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"] +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/custom/types.ts b/packages/@n8n/node-cli/src/template/templates/declarative/custom/types.ts new file mode 100644 index 0000000000..fa9903ca80 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/custom/types.ts @@ -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']; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template.ts b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template.ts new file mode 100644 index 0000000000..3326999c95 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template.ts @@ -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'), +}); diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/README.md b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/README.md new file mode 100644 index 0000000000..8ad7b343bf --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/README.md @@ -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) diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/credentials/GithubIssuesApi.credentials.ts b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/credentials/GithubIssuesApi.credentials.ts new file mode 100644 index 0000000000..f8b267e10d --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/credentials/GithubIssuesApi.credentials.ts @@ -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', + }, + }; +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/credentials/GithubIssuesOAuth2Api.credentials.ts b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/credentials/GithubIssuesOAuth2Api.credentials.ts new file mode 100644 index 0000000000..0eb98fc522 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/credentials/GithubIssuesOAuth2Api.credentials.ts @@ -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', + }, + ]; +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/eslint.config.mjs b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/eslint.config.mjs new file mode 100644 index 0000000000..ad811a0baf --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/eslint.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@n8n/node-cli/eslint'; + +export default config; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/icons/github.dark.svg b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/icons/github.dark.svg new file mode 100644 index 0000000000..0366b08a3d --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/icons/github.dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/icons/github.svg b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/icons/github.svg new file mode 100644 index 0000000000..fe1ac05178 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/icons/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/GithubIssues.node.json b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/GithubIssues.node.json new file mode 100644 index 0000000000..8543a62486 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/GithubIssues.node.json @@ -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" + } + ] + } +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/GithubIssues.node.ts b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/GithubIssues.node.ts new file mode 100644 index 0000000000..b2c5114b5e --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/GithubIssues.node.ts @@ -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, + }, + }; +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/listSearch/getIssues.ts b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/listSearch/getIssues.ts new file mode 100644 index 0000000000..f340b03221 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/listSearch/getIssues.ts @@ -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 { + 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 }; +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/listSearch/getRepositories.ts b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/listSearch/getRepositories.ts new file mode 100644 index 0000000000..9f5a6b1e4c --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/listSearch/getRepositories.ts @@ -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 { + 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 }; +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/listSearch/getUsers.ts b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/listSearch/getUsers.ts new file mode 100644 index 0000000000..d8e0853a48 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/listSearch/getUsers.ts @@ -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 { + 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 }; +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issue/create.ts b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issue/create.ts new file mode 100644 index 0000000000..4c7fd7e061 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issue/create.ts @@ -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)}}', + }, + }, + }, +]; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issue/get.ts b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issue/get.ts new file mode 100644 index 0000000000..7dc7181747 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issue/get.ts @@ -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 }, + }, +]; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issue/getAll.ts b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issue/getAll.ts new file mode 100644 index 0000000000..b5b5fed57d --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issue/getAll.ts @@ -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}}', + }, + }, + }, + }, + ], + }, +]; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issue/index.ts b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issue/index.ts new file mode 100644 index 0000000000..6c915d2482 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issue/index.ts @@ -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, +]; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issueComment/getAll.ts b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issueComment/getAll.ts new file mode 100644 index 0000000000..53b2057231 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issueComment/getAll.ts @@ -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 }}`, + }, + }, + }, + }, + }, + }, +]; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issueComment/index.ts b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issueComment/index.ts new file mode 100644 index 0000000000..886f7c2c54 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/resources/issueComment/index.ts @@ -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, +]; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/shared/descriptions.ts b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/shared/descriptions.ts new file mode 100644 index 0000000000..aaeaaa8ee0 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/shared/descriptions.ts @@ -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}}', + }, + ], +}; diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/shared/transport.ts b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/shared/transport.ts new file mode 100644 index 0000000000..3555ee04fd --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/shared/transport.ts @@ -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); +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/shared/utils.ts b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/shared/utils.ts new file mode 100644 index 0000000000..2b91882df7 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/nodes/GithubIssues/shared/utils.ts @@ -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; +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/package.json b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/package.json new file mode 100644 index 0000000000..a5cdb0d749 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/package.json @@ -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": "*" + } +} diff --git a/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/tsconfig.json b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/tsconfig.json new file mode 100644 index 0000000000..b73660f375 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/declarative/github-issues/template/tsconfig.json @@ -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"] +} diff --git a/packages/@n8n/node-cli/src/template/templates/index.ts b/packages/@n8n/node-cli/src/template/templates/index.ts new file mode 100644 index 0000000000..075d6f5bf0 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/index.ts @@ -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 = keyof TemplateMap[T]; + +export function getTemplate>( + 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( + type: T, + name: unknown, +): name is TemplateName { + return typeof name === 'string' && name in templates[type]; +} diff --git a/packages/@n8n/node-cli/src/template/templates/programmatic/example/template.ts b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template.ts new file mode 100644 index 0000000000..f666d82dd2 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template.ts @@ -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'), +}); diff --git a/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/README.md b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/README.md new file mode 100644 index 0000000000..7ec673f109 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/README.md @@ -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._ diff --git a/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/eslint.config.mjs b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/eslint.config.mjs new file mode 100644 index 0000000000..ad811a0baf --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/eslint.config.mjs @@ -0,0 +1,3 @@ +import { config } from '@n8n/node-cli/eslint'; + +export default config; diff --git a/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/nodes/Example/Example.node.json b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/nodes/Example/Example.node.json new file mode 100644 index 0000000000..8543a62486 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/nodes/Example/Example.node.json @@ -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" + } + ] + } +} diff --git a/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/nodes/Example/Example.node.ts b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/nodes/Example/Example.node.ts new file mode 100644 index 0000000000..38102a94e9 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/nodes/Example/Example.node.ts @@ -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 { + 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]; + } +} diff --git a/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/nodes/Example/example.dark.svg b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/nodes/Example/example.dark.svg new file mode 100644 index 0000000000..c07cb1064b --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/nodes/Example/example.dark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/nodes/Example/example.svg b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/nodes/Example/example.svg new file mode 100644 index 0000000000..703e1fe69c --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/nodes/Example/example.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/package.json b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/package.json new file mode 100644 index 0000000000..3e886ef13d --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/package.json @@ -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": "*" + } +} diff --git a/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/tsconfig.json b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/tsconfig.json new file mode 100644 index 0000000000..b73660f375 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/programmatic/example/template/tsconfig.json @@ -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"] +} diff --git a/packages/@n8n/node-cli/src/template/templates/shared/credentials/apiKey.credentials.ts b/packages/@n8n/node-cli/src/template/templates/shared/credentials/apiKey.credentials.ts new file mode 100644 index 0000000000..c518b27d0a --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/shared/credentials/apiKey.credentials.ts @@ -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', + }, + }; +} diff --git a/packages/@n8n/node-cli/src/template/templates/shared/credentials/basicAuth.credentials.ts b/packages/@n8n/node-cli/src/template/templates/shared/credentials/basicAuth.credentials.ts new file mode 100644 index 0000000000..28692fa3bd --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/shared/credentials/basicAuth.credentials.ts @@ -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', + }, + }; +} diff --git a/packages/@n8n/node-cli/src/template/templates/shared/credentials/bearer.credentials.ts b/packages/@n8n/node-cli/src/template/templates/shared/credentials/bearer.credentials.ts new file mode 100644 index 0000000000..b18bfa04ed --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/shared/credentials/bearer.credentials.ts @@ -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', + }, + }; +} diff --git a/packages/@n8n/node-cli/src/template/templates/shared/credentials/custom.credentials.ts b/packages/@n8n/node-cli/src/template/templates/shared/credentials/custom.credentials.ts new file mode 100644 index 0000000000..358faa9513 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/shared/credentials/custom.credentials.ts @@ -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', + }, + }; +} diff --git a/packages/@n8n/node-cli/src/template/templates/shared/credentials/oauth2AuthorizationCode.credentials.ts b/packages/@n8n/node-cli/src/template/templates/shared/credentials/oauth2AuthorizationCode.credentials.ts new file mode 100644 index 0000000000..cbea5ec808 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/shared/credentials/oauth2AuthorizationCode.credentials.ts @@ -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', + }, + ]; +} diff --git a/packages/@n8n/node-cli/src/template/templates/shared/credentials/oauth2ClientCredentials.credentials.ts b/packages/@n8n/node-cli/src/template/templates/shared/credentials/oauth2ClientCredentials.credentials.ts new file mode 100644 index 0000000000..25baaa55af --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/shared/credentials/oauth2ClientCredentials.credentials.ts @@ -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', + }, + ]; +} diff --git a/packages/@n8n/node-cli/src/template/templates/shared/default/.github/workflows/ci.yml b/packages/@n8n/node-cli/src/template/templates/shared/default/.github/workflows/ci.yml new file mode 100644 index 0000000000..a86f744362 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/shared/default/.github/workflows/ci.yml @@ -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' diff --git a/packages/@n8n/node-cli/src/template/templates/shared/default/.gitignore b/packages/@n8n/node-cli/src/template/templates/shared/default/.gitignore new file mode 100644 index 0000000000..1521c8b765 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/shared/default/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/@n8n/node-cli/src/template/templates/shared/default/.prettierrc.js b/packages/@n8n/node-cli/src/template/templates/shared/default/.prettierrc.js new file mode 100644 index 0000000000..ebf28d8091 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/shared/default/.prettierrc.js @@ -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, +}; diff --git a/packages/@n8n/node-cli/src/template/templates/shared/default/.vscode/launch.json b/packages/@n8n/node-cli/src/template/templates/shared/default/.vscode/launch.json new file mode 100644 index 0000000000..ed1a625b79 --- /dev/null +++ b/packages/@n8n/node-cli/src/template/templates/shared/default/.vscode/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to running n8n", + "processId": "${command:PickProcess}", + "request": "attach", + "skipFiles": ["/**"], + "type": "node" + } + ] +} diff --git a/packages/@n8n/node-cli/src/utils/ast.test.ts b/packages/@n8n/node-cli/src/utils/ast.test.ts new file mode 100644 index 0000000000..23c3a456b8 --- /dev/null +++ b/packages/@n8n/node-cli/src/utils/ast.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/@n8n/node-cli/src/utils/ast.ts b/packages/@n8n/node-cli/src/utils/ast.ts new file mode 100644 index 0000000000..7f0bf46657 --- /dev/null +++ b/packages/@n8n/node-cli/src/utils/ast.ts @@ -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); +}; diff --git a/packages/@n8n/node-cli/src/utils/filesystem.test.ts b/packages/@n8n/node-cli/src/utils/filesystem.test.ts new file mode 100644 index 0000000000..eecadefe80 --- /dev/null +++ b/packages/@n8n/node-cli/src/utils/filesystem.test.ts @@ -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({ 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({ 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({ name: 'root.txt', isDirectory: () => false }), + mock({ name: 'subdir', isDirectory: () => true }), + ]; + + const subDirContent = [mock({ 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({ name: 'keep.txt', isDirectory: () => false }), + mock({ 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'); + }); + }); +}); diff --git a/packages/@n8n/node-cli/src/utils/filesystem.ts b/packages/@n8n/node-cli/src/utils/filesystem.ts new file mode 100644 index 0000000000..967d35380a --- /dev/null +++ b/packages/@n8n/node-cli/src/utils/filesystem.ts @@ -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 { + const ignoreSet = new Set(ignore); + + async function walkAndCopy(currentSrc: string, currentDest: string): Promise { + 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(promise: Promise, minMs: number): Promise { + 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 { + 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 { + 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 { + const parentDir = path.dirname(oldDirPath); + const newDirPath = path.resolve(parentDir, newDirName); + await fs.rename(oldDirPath, newDirPath); + return newDirPath; +} diff --git a/packages/@n8n/node-cli/src/utils/git.test.ts b/packages/@n8n/node-cli/src/utils/git.test.ts new file mode 100644 index 0000000000..e9c9c33f2b --- /dev/null +++ b/packages/@n8n/node-cli/src/utils/git.test.ts @@ -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: '' }); + }); + }); +}); diff --git a/packages/@n8n/node-cli/src/utils/git.ts b/packages/@n8n/node-cli/src/utils/git.ts new file mode 100644 index 0000000000..326faef07c --- /dev/null +++ b/packages/@n8n/node-cli/src/utils/git.ts @@ -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; +} diff --git a/packages/@n8n/node-cli/src/utils/package-manager.ts b/packages/@n8n/node-cli/src/utils/package-manager.ts new file mode 100644 index 0000000000..8d68576b04 --- /dev/null +++ b/packages/@n8n/node-cli/src/utils/package-manager.ts @@ -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 { + 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); + } + }); + }); +} diff --git a/packages/@n8n/node-cli/src/utils/package.ts b/packages/@n8n/node-cli/src/utils/package.ts new file mode 100644 index 0000000000..3a2567393f --- /dev/null +++ b/packages/@n8n/node-cli/src/utils/package.ts @@ -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(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(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; + }); +} diff --git a/packages/@n8n/node-cli/src/utils/prompts.ts b/packages/@n8n/node-cli/src/utils/prompts.ts new file mode 100644 index 0000000000..eb5591ea7e --- /dev/null +++ b/packages/@n8n/node-cli/src/utils/prompts.ts @@ -0,0 +1,33 @@ +import { cancel, isCancel, log } from '@clack/prompts'; + +import { isN8nNodePackage } from './package'; + +export async function withCancelHandler(prompt: Promise): Promise { + 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); + } +} diff --git a/packages/@n8n/node-cli/src/utils/validation.ts b/packages/@n8n/node-cli/src/utils/validation.ts new file mode 100644 index 0000000000..95bf5ead7a --- /dev/null +++ b/packages/@n8n/node-cli/src/utils/validation.ts @@ -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; +}; diff --git a/packages/@n8n/node-cli/tsconfig.build.json b/packages/@n8n/node-cli/tsconfig.build.json new file mode 100644 index 0000000000..9bb94c6060 --- /dev/null +++ b/packages/@n8n/node-cli/tsconfig.build.json @@ -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" + ] +} diff --git a/packages/@n8n/node-cli/tsconfig.json b/packages/@n8n/node-cli/tsconfig.json index 1fc9b18700..d660821f4f 100644 --- a/packages/@n8n/node-cli/tsconfig.json +++ b/packages/@n8n/node-cli/tsconfig.json @@ -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"] } diff --git a/packages/@n8n/node-cli/vite.config.ts b/packages/@n8n/node-cli/vite.config.ts index 90ab02b077..9a88a62d2b 100644 --- a/packages/@n8n/node-cli/vite.config.ts +++ b/packages/@n8n/node-cli/vite.config.ts @@ -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 } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e7bee8bb6..8e78dd2803 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,7 +97,7 @@ catalogs: specifier: 0.2.2 version: 0.2.2 rimraf: - specifier: ^6.0.1 + specifier: 6.0.1 version: 6.0.1 tsup: specifier: ^8.5.0 @@ -156,7 +156,7 @@ catalogs: version: 2.4.3 highlight.js: specifier: ^11.8.0 - version: 11.9.0 + version: 11.11.1 pinia: specifier: ^2.2.4 version: 2.2.4 @@ -169,6 +169,9 @@ catalogs: vue: specifier: ^3.5.13 version: 3.5.13 + vue-i18n: + specifier: ^11.1.2 + version: 11.1.10 vue-markdown-render: specifier: ^2.2.1 version: 2.2.1 @@ -918,13 +921,31 @@ importers: packages/@n8n/node-cli: dependencies: + '@clack/prompts': + specifier: ^0.11.0 + version: 0.11.0 '@oclif/core': specifier: ^4.5.2 version: 4.5.2 + change-case: + specifier: ^5.4.4 + version: 5.4.4 + handlebars: + specifier: 4.7.8 + version: 4.7.8 + picocolors: + specifier: 'catalog:' + version: 1.0.1 prompts: specifier: ^2.4.2 version: 2.4.2 + ts-morph: + specifier: ^26.0.0 + version: 26.0.0 devDependencies: + '@eslint/js': + specifier: ^9.29.0 + version: 9.29.0 '@n8n/typescript-config': specifier: workspace:* version: link:../typescript-config @@ -934,6 +955,30 @@ importers: '@oclif/test': specifier: ^4.1.13 version: 4.1.13(@oclif/core@4.5.2) + eslint-import-resolver-typescript: + specifier: ^4.4.3 + version: 4.4.3(eslint-plugin-import-x@4.15.2(@typescript-eslint/utils@8.35.0(eslint@9.29.0(jiti@1.21.7))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.29.0(jiti@1.21.7)))(eslint-plugin-import@2.32.0)(eslint@9.29.0(jiti@1.21.7)) + eslint-plugin-import-x: + specifier: ^4.15.2 + version: 4.15.2(@typescript-eslint/utils@8.35.0(eslint@9.29.0(jiti@1.21.7))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.29.0(jiti@1.21.7)) + eslint-plugin-n8n-nodes-base: + specifier: 1.16.3 + version: 1.16.3(eslint@9.29.0(jiti@1.21.7))(typescript@5.9.2) + n8n-workflow: + specifier: workspace:* + version: link:../../workflow + rimraf: + specifier: 'catalog:' + version: 6.0.1 + typescript: + specifier: 5.9.2 + version: 5.9.2 + typescript-eslint: + specifier: ^8.35.0 + version: 8.35.0(eslint@9.29.0(jiti@1.21.7))(typescript@5.9.2) + vitest-mock-extended: + specifier: 'catalog:' + version: 3.1.0(typescript@5.9.2)(vitest@3.1.3(@types/debug@4.1.12)(@types/node@20.19.10)(jiti@1.21.7)(jsdom@23.0.1)(sass@1.89.2)(terser@5.16.1)(tsx@4.19.3)) packages/@n8n/nodes-langchain: dependencies: @@ -945,7 +990,7 @@ importers: version: 4.3.0 '@getzep/zep-cloud': specifier: 1.0.12 - version: 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.30(e7c2f10ddf33088da1e6affdf0fc6c0a)) + version: 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.30(316b19288832115574731e049dc7676a)) '@getzep/zep-js': specifier: 0.9.0 version: 0.9.0 @@ -972,7 +1017,7 @@ importers: version: 0.3.4(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13) '@langchain/community': specifier: 'catalog:' - version: 0.3.50(7d9026709e640c92cdf2ea22646a0399) + version: 0.3.50(ccee17333f80550b1303d83de2b6f79a) '@langchain/core': specifier: 'catalog:' version: 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)) @@ -1089,7 +1134,7 @@ importers: version: 23.0.1 langchain: specifier: 0.3.30 - version: 0.3.30(e7c2f10ddf33088da1e6affdf0fc6c0a) + version: 0.3.30(316b19288832115574731e049dc7676a) lodash: specifier: 'catalog:' version: 4.17.21 @@ -1944,7 +1989,7 @@ importers: version: 10.11.0(vue@3.5.13(typescript@5.9.2)) highlight.js: specifier: catalog:frontend - version: 11.9.0 + version: 11.11.1 markdown-it-link-attributes: specifier: ^4.0.1 version: 4.0.1 @@ -2528,7 +2573,7 @@ importers: version: 3.2.7 highlight.js: specifier: catalog:frontend - version: 11.9.0 + version: 11.11.1 humanize-duration: specifier: ^3.27.2 version: 3.27.3 @@ -4222,6 +4267,12 @@ packages: peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 + '@clack/core@0.5.0': + resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} + + '@clack/prompts@0.11.0': + resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@codemirror/autocomplete@6.16.0': resolution: {integrity: sha512-P/LeCTtZHRTCU4xQsa89vSKWecYv1ZqwzOd5topheGRf+qtacFgBeIMQi3eL8Kt/BUNvxUWkx+5qP2jlGoARrg==} peerDependencies: @@ -6306,201 +6357,101 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.44.0': - resolution: {integrity: sha512-xEiEE5oDW6tK4jXCAyliuntGR+amEMO7HLtdSshVuhFnKTYoeYMyXQK7pLouAJJj5KHdwdn87bfHAR2nSdNAUA==} - cpu: [arm] - os: [android] - '@rollup/rollup-android-arm-eabi@4.46.2': resolution: {integrity: sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.44.0': - resolution: {integrity: sha512-uNSk/TgvMbskcHxXYHzqwiyBlJ/lGcv8DaUfcnNwict8ba9GTTNxfn3/FAoFZYgkaXXAdrAA+SLyKplyi349Jw==} - cpu: [arm64] - os: [android] - '@rollup/rollup-android-arm64@4.46.2': resolution: {integrity: sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.44.0': - resolution: {integrity: sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==} - cpu: [arm64] - os: [darwin] - '@rollup/rollup-darwin-arm64@4.46.2': resolution: {integrity: sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.44.0': - resolution: {integrity: sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ==} - cpu: [x64] - os: [darwin] - '@rollup/rollup-darwin-x64@4.46.2': resolution: {integrity: sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.44.0': - resolution: {integrity: sha512-u5AZzdQJYJXByB8giQ+r4VyfZP+walV+xHWdaFx/1VxsOn6eWJhK2Vl2eElvDJFKQBo/hcYIBg/jaKS8ZmKeNQ==} - cpu: [arm64] - os: [freebsd] - '@rollup/rollup-freebsd-arm64@4.46.2': resolution: {integrity: sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.44.0': - resolution: {integrity: sha512-qC0kS48c/s3EtdArkimctY7h3nHicQeEUdjJzYVJYR3ct3kWSafmn6jkNCA8InbUdge6PVx6keqjk5lVGJf99g==} - cpu: [x64] - os: [freebsd] - '@rollup/rollup-freebsd-x64@4.46.2': resolution: {integrity: sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.44.0': - resolution: {integrity: sha512-x+e/Z9H0RAWckn4V2OZZl6EmV0L2diuX3QB0uM1r6BvhUIv6xBPL5mrAX2E3e8N8rEHVPwFfz/ETUbV4oW9+lQ==} - cpu: [arm] - os: [linux] - '@rollup/rollup-linux-arm-gnueabihf@4.46.2': resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.44.0': - resolution: {integrity: sha512-1exwiBFf4PU/8HvI8s80icyCcnAIB86MCBdst51fwFmH5dyeoWVPVgmQPcKrMtBQ0W5pAs7jBCWuRXgEpRzSCg==} - cpu: [arm] - os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.46.2': resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.44.0': - resolution: {integrity: sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==} - cpu: [arm64] - os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.46.2': resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.44.0': - resolution: {integrity: sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==} - cpu: [arm64] - os: [linux] - '@rollup/rollup-linux-arm64-musl@4.46.2': resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loongarch64-gnu@4.44.0': - resolution: {integrity: sha512-xw+FTGcov/ejdusVOqKgMGW3c4+AgqrfvzWEVXcNP6zq2ue+lsYUgJ+5Rtn/OTJf7e2CbgTFvzLW2j0YAtj0Gg==} - cpu: [loong64] - os: [linux] - '@rollup/rollup-linux-loongarch64-gnu@4.46.2': resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-powerpc64le-gnu@4.44.0': - resolution: {integrity: sha512-bKGibTr9IdF0zr21kMvkZT4K6NV+jjRnBoVMt2uNMG0BYWm3qOVmYnXKzx7UhwrviKnmK46IKMByMgvpdQlyJQ==} - cpu: [ppc64] - os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.46.2': resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.44.0': - resolution: {integrity: sha512-vV3cL48U5kDaKZtXrti12YRa7TyxgKAIDoYdqSIOMOFBXqFj2XbChHAtXquEn2+n78ciFgr4KIqEbydEGPxXgA==} - cpu: [riscv64] - os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.46.2': resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.44.0': - resolution: {integrity: sha512-TDKO8KlHJuvTEdfw5YYFBjhFts2TR0VpZsnLLSYmB7AaohJhM8ctDSdDnUGq77hUh4m/djRafw+9zQpkOanE2Q==} - cpu: [riscv64] - os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.46.2': resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.44.0': - resolution: {integrity: sha512-8541GEyktXaw4lvnGp9m84KENcxInhAt6vPWJ9RodsB/iGjHoMB2Pp5MVBCiKIRxrxzJhGCxmNzdu+oDQ7kwRA==} - cpu: [s390x] - os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.46.2': resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.44.0': - resolution: {integrity: sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==} - cpu: [x64] - os: [linux] - '@rollup/rollup-linux-x64-gnu@4.46.2': resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.44.0': - resolution: {integrity: sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==} - cpu: [x64] - os: [linux] - '@rollup/rollup-linux-x64-musl@4.46.2': resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==} cpu: [x64] os: [linux] - '@rollup/rollup-win32-arm64-msvc@4.44.0': - resolution: {integrity: sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==} - cpu: [arm64] - os: [win32] - '@rollup/rollup-win32-arm64-msvc@4.46.2': resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.44.0': - resolution: {integrity: sha512-3XJ0NQtMAXTWFW8FqZKcw3gOQwBtVWP/u8TpHP3CRPXD7Pd6s8lLdH3sHWh8vqKCyyiI8xW5ltJScQmBU9j7WA==} - cpu: [ia32] - os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.46.2': resolution: {integrity: sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.44.0': - resolution: {integrity: sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==} - cpu: [x64] - os: [win32] - '@rollup/rollup-win32-x64-msvc@4.46.2': resolution: {integrity: sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==} cpu: [x64] @@ -7228,6 +7179,9 @@ packages: resolution: {integrity: sha512-w071DSzP94YfN6XiWhOxnLpYT3uqtxJBDYdh6Jdjzt+Ce6DNspJsPQgpC7rbts/B8tEkq0LHoYuIF/O5Jh5rPg==} engines: {node: '>=18'} + '@ts-morph/common@0.27.0': + resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@tsconfig/node10@1.0.11': resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} @@ -7294,9 +7248,6 @@ packages: '@types/compression@1.7.5': resolution: {integrity: sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==} - '@types/connect@3.4.36': - resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==} - '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -8526,9 +8477,6 @@ packages: peerDependencies: axios: 0.x || 1.x - axios@1.10.0: - resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==} - axios@1.11.0: resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} @@ -9086,6 +9034,9 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + codemirror-lang-html-n8n@1.0.0: resolution: {integrity: sha512-ofNP6VTDGJ5rue+kTCZlDZdF1PnE0sl2cAkfrsCAd5MlBgDmqTwuFJIkTI6KXOJXs0ucdTYH6QLhy9BSW7EaOQ==} @@ -9918,10 +9869,6 @@ packages: resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==} engines: {node: '>=12'} - dotenv@16.5.0: - resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} - engines: {node: '>=12'} - dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -10668,15 +10615,6 @@ packages: debug: optional: true - follow-redirects@1.15.9: - resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -11116,10 +11054,6 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} - highlight.js@11.9.0: - resolution: {integrity: sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==} - engines: {node: '>=12.0.0'} - hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} @@ -14108,9 +14042,6 @@ packages: engines: {node: '>= 0.10'} hasBin: true - psl@1.15.0: - resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} - psl@1.9.0: resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} @@ -14555,11 +14486,6 @@ packages: rndm@1.2.0: resolution: {integrity: sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==} - rollup@4.44.0: - resolution: {integrity: sha512-qHcdEzLCiktQIfwBq420pn2dP+30uzqYxv9ETm91wdt2R9AFcWfjNAmje4NWlnCIQ5RMTzVf0ZyisOKqHR6RwA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - rollup@4.46.2: resolution: {integrity: sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -15585,6 +15511,9 @@ packages: ts-map@1.0.3: resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==} + ts-morph@26.0.0: + resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} + ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -16727,11 +16656,6 @@ packages: peerDependencies: zod: ^3.23.3 - zod-to-json-schema@3.24.5: - resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} - peerDependencies: - zod: ^3.24.1 - zod-to-json-schema@3.24.6: resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==} peerDependencies: @@ -16763,8 +16687,8 @@ snapshots: '@ampproject/remapping@2.3.0': dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 '@antfu/install-pkg@0.1.1': dependencies: @@ -18693,6 +18617,17 @@ snapshots: - '@chromatic-com/playwright' - react + '@clack/core@0.5.0': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.11.0': + dependencies: + '@clack/core': 0.5.0 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@codemirror/autocomplete@6.16.0(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.26.3)(@lezer/common@1.1.0)': dependencies: '@codemirror/language': 6.10.1 @@ -18846,7 +18781,7 @@ snapshots: '@currents/commit-info': 1.0.1-beta.0 async-retry: 1.3.3 axios: 1.11.0(debug@4.4.1) - axios-retry: 4.5.0(axios@1.11.0) + axios-retry: 4.5.0(axios@1.11.0(debug@4.4.1)) c12: 1.11.2(magicast@0.3.5) chalk: 4.1.2 commander: 12.1.0 @@ -19159,7 +19094,7 @@ snapshots: '@gar/promisify@1.1.3': optional: true - '@getzep/zep-cloud@1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.30(e7c2f10ddf33088da1e6affdf0fc6c0a))': + '@getzep/zep-cloud@1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.30(316b19288832115574731e049dc7676a))': dependencies: form-data: 4.0.4 node-fetch: 2.7.0(encoding@0.1.13) @@ -19168,7 +19103,7 @@ snapshots: zod: 3.25.67 optionalDependencies: '@langchain/core': 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)) - langchain: 0.3.30(e7c2f10ddf33088da1e6affdf0fc6c0a) + langchain: 0.3.30(316b19288832115574731e049dc7676a) transitivePeerDependencies: - encoding @@ -19440,7 +19375,7 @@ snapshots: '@jest/console@29.6.2': dependencies: '@jest/types': 29.6.1 - '@types/node': 20.19.1 + '@types/node': 20.19.10 chalk: 4.1.2 jest-message-util: 29.6.2 jest-util: 29.6.2 @@ -19560,7 +19495,7 @@ snapshots: '@jest/transform': 29.6.2 '@jest/types': 29.6.1 '@jridgewell/trace-mapping': 0.3.25 - '@types/node': 20.19.1 + '@types/node': 20.19.10 chalk: 4.1.2 collect-v8-coverage: 1.0.1 exit: 0.1.2 @@ -19595,7 +19530,7 @@ snapshots: '@jest/source-map@29.6.0': dependencies: - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.30 callsites: 3.1.0 graceful-fs: 4.2.11 @@ -19739,7 +19674,7 @@ snapshots: - aws-crt - encoding - '@langchain/community@0.3.50(7d9026709e640c92cdf2ea22646a0399)': + '@langchain/community@0.3.50(ccee17333f80550b1303d83de2b6f79a)': dependencies: '@browserbasehq/stagehand': 1.9.0(@playwright/test@1.54.2)(deepmerge@4.3.1)(dotenv@16.6.1)(encoding@0.1.13)(openai@5.12.2(ws@8.18.3)(zod@3.25.67))(zod@3.25.67) '@ibm-cloud/watsonx-ai': 1.1.2 @@ -19751,7 +19686,7 @@ snapshots: flat: 5.0.2 ibm-cloud-sdk-core: 5.3.2 js-yaml: 4.1.0 - langchain: 0.3.30(e7c2f10ddf33088da1e6affdf0fc6c0a) + langchain: 0.3.30(316b19288832115574731e049dc7676a) langsmith: 0.3.55(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)) openai: 5.12.2(ws@8.18.3)(zod@3.25.67) uuid: 10.0.0 @@ -19765,7 +19700,7 @@ snapshots: '@aws-sdk/credential-provider-node': 3.808.0 '@azure/storage-blob': 12.26.0 '@browserbasehq/sdk': 2.6.0(encoding@0.1.13) - '@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.30(e7c2f10ddf33088da1e6affdf0fc6c0a)) + '@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.30(316b19288832115574731e049dc7676a)) '@getzep/zep-js': 0.9.0 '@google-ai/generativelanguage': 2.6.0(encoding@0.1.13) '@google-cloud/storage': 7.12.1(encoding@0.1.13) @@ -20075,7 +20010,7 @@ snapshots: pkce-challenge: 5.0.0(patch_hash=651e785d0b7bbf5be9210e1e895c39a16dc3ce8a5a3843b4819565fb6e175b90) raw-body: 3.0.0 zod: 3.25.67 - zod-to-json-schema: 3.24.5(zod@3.25.67) + zod-to-json-schema: 3.24.6(zod@3.25.67) transitivePeerDependencies: - supports-color @@ -20125,7 +20060,7 @@ snapshots: '@n8n/localtunnel@3.0.0': dependencies: - axios: 1.10.0(debug@4.3.6) + axios: 1.11.0(debug@4.3.6) debug: 4.3.6 transitivePeerDependencies: - supports-color @@ -20153,7 +20088,7 @@ snapshots: chalk: 4.1.2 dayjs: 1.11.10 debug: 4.4.1(supports-color@8.1.1) - dotenv: 16.5.0 + dotenv: 16.6.1 glob: 10.4.5 mkdirp: 2.1.3 reflect-metadata: 0.2.2 @@ -20185,7 +20120,7 @@ snapshots: chalk: 4.1.2 dayjs: 1.11.10 debug: 4.4.1(supports-color@8.1.1) - dotenv: 16.5.0 + dotenv: 16.6.1 glob: 10.4.5 mkdirp: 2.1.3 reflect-metadata: 0.2.2 @@ -20877,123 +20812,63 @@ snapshots: optionalDependencies: rollup: 4.46.2 - '@rollup/rollup-android-arm-eabi@4.44.0': - optional: true - '@rollup/rollup-android-arm-eabi@4.46.2': optional: true - '@rollup/rollup-android-arm64@4.44.0': - optional: true - '@rollup/rollup-android-arm64@4.46.2': optional: true - '@rollup/rollup-darwin-arm64@4.44.0': - optional: true - '@rollup/rollup-darwin-arm64@4.46.2': optional: true - '@rollup/rollup-darwin-x64@4.44.0': - optional: true - '@rollup/rollup-darwin-x64@4.46.2': optional: true - '@rollup/rollup-freebsd-arm64@4.44.0': - optional: true - '@rollup/rollup-freebsd-arm64@4.46.2': optional: true - '@rollup/rollup-freebsd-x64@4.44.0': - optional: true - '@rollup/rollup-freebsd-x64@4.46.2': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.44.0': - optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.46.2': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.44.0': - optional: true - '@rollup/rollup-linux-arm-musleabihf@4.46.2': optional: true - '@rollup/rollup-linux-arm64-gnu@4.44.0': - optional: true - '@rollup/rollup-linux-arm64-gnu@4.46.2': optional: true - '@rollup/rollup-linux-arm64-musl@4.44.0': - optional: true - '@rollup/rollup-linux-arm64-musl@4.46.2': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.44.0': - optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.46.2': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.44.0': - optional: true - '@rollup/rollup-linux-ppc64-gnu@4.46.2': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.44.0': - optional: true - '@rollup/rollup-linux-riscv64-gnu@4.46.2': optional: true - '@rollup/rollup-linux-riscv64-musl@4.44.0': - optional: true - '@rollup/rollup-linux-riscv64-musl@4.46.2': optional: true - '@rollup/rollup-linux-s390x-gnu@4.44.0': - optional: true - '@rollup/rollup-linux-s390x-gnu@4.46.2': optional: true - '@rollup/rollup-linux-x64-gnu@4.44.0': - optional: true - '@rollup/rollup-linux-x64-gnu@4.46.2': optional: true - '@rollup/rollup-linux-x64-musl@4.44.0': - optional: true - '@rollup/rollup-linux-x64-musl@4.46.2': optional: true - '@rollup/rollup-win32-arm64-msvc@4.44.0': - optional: true - '@rollup/rollup-win32-arm64-msvc@4.46.2': optional: true - '@rollup/rollup-win32-ia32-msvc@4.44.0': - optional: true - '@rollup/rollup-win32-ia32-msvc@4.46.2': optional: true - '@rollup/rollup-win32-x64-msvc@4.44.0': - optional: true - '@rollup/rollup-win32-x64-msvc@4.46.2': optional: true @@ -22039,6 +21914,12 @@ snapshots: '@ts-graphviz/ast': 2.0.7 '@ts-graphviz/common': 2.1.5 + '@ts-morph/common@0.27.0': + dependencies: + fast-glob: 3.3.3 + minimatch: 10.0.3 + path-browserify: 1.0.1 + '@tsconfig/node10@1.0.11': optional: true @@ -22095,7 +21976,7 @@ snapshots: '@types/basic-auth@1.1.3': dependencies: - '@types/node': 20.19.1 + '@types/node': 20.19.10 '@types/bcryptjs@2.4.2': {} @@ -22103,8 +21984,8 @@ snapshots: '@types/body-parser@1.19.2': dependencies: - '@types/connect': 3.4.36 - '@types/node': 20.19.1 + '@types/connect': 3.4.38 + '@types/node': 20.19.10 '@types/caseless@0.12.5': {} @@ -22120,13 +22001,9 @@ snapshots: dependencies: '@types/express': 5.0.1 - '@types/connect@3.4.36': - dependencies: - '@types/node': 20.19.1 - '@types/connect@3.4.38': dependencies: - '@types/node': 20.19.1 + '@types/node': 20.19.10 '@types/convict@6.1.1': dependencies: @@ -22146,7 +22023,7 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 20.19.1 + '@types/node': 20.19.10 '@types/ssh2': 1.11.6 '@types/dockerode@3.3.42': @@ -22166,7 +22043,7 @@ snapshots: '@types/express-serve-static-core@5.0.6(patch_hash=d602248fcd302cf5a794d1e85a411633ba9635ea5d566d6f2e0429c7ae0fa3eb)': dependencies: - '@types/node': 20.19.1 + '@types/node': 20.19.10 '@types/qs': 6.9.15 '@types/range-parser': 1.2.4 '@types/send': 0.17.4 @@ -22206,7 +22083,7 @@ snapshots: '@types/graceful-fs@4.1.6': dependencies: - '@types/node': 20.19.1 + '@types/node': 20.19.10 '@types/html-to-text@9.0.4': {} @@ -22270,7 +22147,7 @@ snapshots: '@types/jsonwebtoken@9.0.9': dependencies: '@types/ms': 2.1.0 - '@types/node': 20.19.1 + '@types/node': 20.19.10 '@types/k6@0.52.0': {} @@ -22338,11 +22215,11 @@ snapshots: '@types/mysql@2.15.26': dependencies: - '@types/node': 20.19.1 + '@types/node': 20.19.10 '@types/node-fetch@2.6.12': dependencies: - '@types/node': 20.19.1 + '@types/node': 20.19.10 form-data: 4.0.4 '@types/node-fetch@2.6.13': @@ -22378,7 +22255,7 @@ snapshots: '@types/pg@8.6.1': dependencies: - '@types/node': 20.19.1 + '@types/node': 20.19.10 pg-protocol: 1.6.1 pg-types: 2.2.0 @@ -22415,7 +22292,7 @@ snapshots: '@types/readable-stream@4.0.10': dependencies: - '@types/node': 20.19.1 + '@types/node': 20.19.10 safe-buffer: 5.1.2 '@types/replacestream@4.0.1': {} @@ -22423,7 +22300,7 @@ snapshots: '@types/request@2.48.12': dependencies: '@types/caseless': 0.12.5 - '@types/node': 20.19.1 + '@types/node': 20.19.10 '@types/tough-cookie': 4.0.5 form-data: 4.0.4 @@ -22444,12 +22321,12 @@ snapshots: '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 - '@types/node': 20.19.1 + '@types/node': 20.19.10 '@types/serve-static@1.15.0': dependencies: '@types/mime': 3.0.1 - '@types/node': 20.19.1 + '@types/node': 20.19.10 '@types/shelljs@0.8.11': dependencies: @@ -22470,11 +22347,11 @@ snapshots: '@types/ssh2-streams@0.1.12': dependencies: - '@types/node': 20.19.1 + '@types/node': 20.19.10 '@types/ssh2@0.5.52': dependencies: - '@types/node': 20.19.1 + '@types/node': 20.19.10 '@types/ssh2-streams': 0.1.12 '@types/ssh2@1.11.6': @@ -22513,7 +22390,7 @@ snapshots: '@types/tedious@4.0.14': dependencies: - '@types/node': 20.19.1 + '@types/node': 20.19.10 '@types/tedious@4.0.9': dependencies: @@ -22567,7 +22444,7 @@ snapshots: '@types/xml2js@0.4.14': dependencies: - '@types/node': 20.19.1 + '@types/node': 20.19.10 '@types/yamljs@0.2.31': {} @@ -22579,7 +22456,7 @@ snapshots: '@types/yauzl@2.10.0': dependencies: - '@types/node': 20.19.1 + '@types/node': 20.19.10 optional: true '@typescript-eslint/eslint-plugin@8.35.0(@typescript-eslint/parser@8.35.0(eslint@9.29.0(jiti@1.21.7))(typescript@5.9.2))(eslint@9.29.0(jiti@1.21.7))(typescript@5.9.2)': @@ -23525,7 +23402,7 @@ snapshots: ast-v8-to-istanbul@0.3.3: dependencies: - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.30 estree-walker: 3.0.3 js-tokens: 9.0.1 @@ -23577,14 +23454,14 @@ snapshots: axe-core@4.7.2: {} - axios-retry@4.5.0(axios@1.10.0): + axios-retry@4.5.0(axios@1.11.0(debug@4.4.1)): dependencies: - axios: 1.10.0 + axios: 1.11.0(debug@4.4.1) is-retry-allowed: 2.2.0 axios-retry@4.5.0(axios@1.11.0): dependencies: - axios: 1.11.0(debug@4.4.1) + axios: 1.11.0(debug@4.3.6) is-retry-allowed: 2.2.0 axios-retry@4.5.0(axios@1.8.3): @@ -23592,17 +23469,9 @@ snapshots: axios: 1.8.3 is-retry-allowed: 2.2.0 - axios@1.10.0: + axios@1.11.0(debug@4.3.6): dependencies: - follow-redirects: 1.15.9(debug@4.4.1) - form-data: 4.0.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - axios@1.10.0(debug@4.3.6): - dependencies: - follow-redirects: 1.15.9(debug@4.3.6) + follow-redirects: 1.15.11(debug@4.3.6) form-data: 4.0.4 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -23626,7 +23495,7 @@ snapshots: axios@1.8.3: dependencies: - follow-redirects: 1.15.9(debug@4.4.1) + follow-redirects: 1.15.11(debug@4.3.6) form-data: 4.0.4 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -23957,8 +23826,8 @@ snapshots: bundlemon@3.1.0(typescript@5.9.2): dependencies: - axios: 1.10.0 - axios-retry: 4.5.0(axios@1.10.0) + axios: 1.11.0(debug@4.3.6) + axios-retry: 4.5.0(axios@1.11.0) brotli-size: 4.0.0 bundlemon-utils: 2.0.1 bytes: 3.1.2 @@ -24307,6 +24176,8 @@ snapshots: co@4.6.0: {} + code-block-writer@13.0.3: {} + codemirror-lang-html-n8n@1.0.0: dependencies: '@codemirror/autocomplete': 6.16.0(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.26.3)(@lezer/common@1.1.0) @@ -25244,8 +25115,6 @@ snapshots: dotenv@16.3.1: {} - dotenv@16.5.0: {} - dotenv@16.6.1: {} dotenv@8.6.0: {} @@ -25998,7 +25867,7 @@ snapshots: expect@29.6.2: dependencies: '@jest/expect-utils': 29.6.2 - '@types/node': 20.19.1 + '@types/node': 20.19.10 jest-get-type: 29.4.3 jest-matcher-utils: 29.6.2 jest-message-util: 29.6.2 @@ -26276,7 +26145,7 @@ snapshots: dependencies: magic-string: 0.30.17 mlly: 1.7.4 - rollup: 4.44.0 + rollup: 4.46.2 flat-cache@4.0.1: dependencies: @@ -26297,6 +26166,10 @@ snapshots: fn.name@1.1.0: {} + follow-redirects@1.15.11(debug@4.3.6): + optionalDependencies: + debug: 4.3.6 + follow-redirects@1.15.11(debug@4.4.0): optionalDependencies: debug: 4.4.0 @@ -26305,14 +26178,6 @@ snapshots: optionalDependencies: debug: 4.4.1(supports-color@8.1.1) - follow-redirects@1.15.9(debug@4.3.6): - optionalDependencies: - debug: 4.3.6 - - follow-redirects@1.15.9(debug@4.4.1): - optionalDependencies: - debug: 4.4.1(supports-color@8.1.1) - for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -26850,8 +26715,6 @@ snapshots: highlight.js@11.11.1: {} - highlight.js@11.9.0: {} - hmac-drbg@1.0.1: dependencies: hash.js: 1.1.7 @@ -26980,7 +26843,7 @@ snapshots: http-proxy@1.18.1(debug@4.4.1): dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.15.9(debug@4.4.1) + follow-redirects: 1.15.11(debug@4.4.1) requires-port: 1.0.0 transitivePeerDependencies: - debug @@ -27043,7 +26906,7 @@ snapshots: isstream: 0.1.2 jsonwebtoken: 9.0.2 mime-types: 2.1.35 - retry-axios: 2.6.0(axios@1.11.0(debug@4.4.1)) + retry-axios: 2.6.0(axios@1.11.0) tough-cookie: 4.1.4 transitivePeerDependencies: - supports-color @@ -27112,7 +26975,7 @@ snapshots: infisical-node@1.3.0: dependencies: - axios: 1.10.0 + axios: 1.11.0(debug@4.3.6) dotenv: 16.3.1 tweetnacl: 1.0.3 tweetnacl-util: 0.15.1 @@ -27489,7 +27352,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.30 debug: 4.4.1(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: @@ -27534,7 +27397,7 @@ snapshots: '@jest/expect': 29.6.2 '@jest/test-result': 29.6.2 '@jest/types': 29.6.1 - '@types/node': 20.19.1 + '@types/node': 20.19.10 chalk: 4.1.2 co: 4.6.0 dedent: 1.3.0 @@ -27764,7 +27627,7 @@ snapshots: '@jest/environment': 29.6.2 '@jest/fake-timers': 29.6.2 '@jest/types': 29.6.1 - '@types/node': 20.19.1 + '@types/node': 20.19.10 jest-mock: 29.6.2 jest-util: 29.6.2 @@ -27776,7 +27639,7 @@ snapshots: dependencies: '@jest/types': 29.6.1 '@types/graceful-fs': 4.1.6 - '@types/node': 20.19.1 + '@types/node': 20.19.10 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -27888,7 +27751,7 @@ snapshots: '@jest/test-result': 29.6.2 '@jest/transform': 29.6.2 '@jest/types': 29.6.1 - '@types/node': 20.19.1 + '@types/node': 20.19.10 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -27916,7 +27779,7 @@ snapshots: '@jest/test-result': 29.6.2 '@jest/transform': 29.6.2 '@jest/types': 29.6.1 - '@types/node': 20.19.1 + '@types/node': 20.19.10 chalk: 4.1.2 cjs-module-lexer: 1.2.2 collect-v8-coverage: 1.0.1 @@ -27990,7 +27853,7 @@ snapshots: dependencies: '@jest/test-result': 29.6.2 '@jest/types': 29.6.1 - '@types/node': 20.19.1 + '@types/node': 20.19.10 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -27999,7 +27862,7 @@ snapshots: jest-worker@29.6.2: dependencies: - '@types/node': 20.19.1 + '@types/node': 20.19.10 jest-util: 29.6.2 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -28296,7 +28159,7 @@ snapshots: kuler@2.0.0: {} - langchain@0.3.30(e7c2f10ddf33088da1e6affdf0fc6c0a): + langchain@0.3.30(316b19288832115574731e049dc7676a): dependencies: '@langchain/core': 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)) '@langchain/openai': 0.6.7(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(ws@8.18.3) @@ -28319,7 +28182,7 @@ snapshots: '@langchain/groq': 0.2.3(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13) '@langchain/mistralai': 0.2.1(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(zod@3.25.67) '@langchain/ollama': 0.2.3(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67))) - axios: 1.11.0(debug@4.4.1) + axios: 1.11.0(debug@4.3.6) cheerio: 1.0.0 handlebars: 4.7.8 transitivePeerDependencies: @@ -30371,7 +30234,7 @@ snapshots: posthog-node@3.2.1: dependencies: - axios: 1.10.0 + axios: 1.11.0(debug@4.3.6) rusha: 0.8.14 transitivePeerDependencies: - debug @@ -30543,10 +30406,6 @@ snapshots: dependencies: event-stream: 3.3.4 - psl@1.15.0: - dependencies: - punycode: 2.3.1 - psl@1.9.0: {} pstree.remy@1.1.8: {} @@ -31052,9 +30911,9 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - retry-axios@2.6.0(axios@1.11.0(debug@4.4.1)): + retry-axios@2.6.0(axios@1.11.0): dependencies: - axios: 1.11.0(debug@4.4.1) + axios: 1.11.0(debug@4.3.6) retry-request@7.0.2(encoding@0.1.13): dependencies: @@ -31113,32 +30972,6 @@ snapshots: rndm@1.2.0: {} - rollup@4.44.0: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.44.0 - '@rollup/rollup-android-arm64': 4.44.0 - '@rollup/rollup-darwin-arm64': 4.44.0 - '@rollup/rollup-darwin-x64': 4.44.0 - '@rollup/rollup-freebsd-arm64': 4.44.0 - '@rollup/rollup-freebsd-x64': 4.44.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.44.0 - '@rollup/rollup-linux-arm-musleabihf': 4.44.0 - '@rollup/rollup-linux-arm64-gnu': 4.44.0 - '@rollup/rollup-linux-arm64-musl': 4.44.0 - '@rollup/rollup-linux-loongarch64-gnu': 4.44.0 - '@rollup/rollup-linux-powerpc64le-gnu': 4.44.0 - '@rollup/rollup-linux-riscv64-gnu': 4.44.0 - '@rollup/rollup-linux-riscv64-musl': 4.44.0 - '@rollup/rollup-linux-s390x-gnu': 4.44.0 - '@rollup/rollup-linux-x64-gnu': 4.44.0 - '@rollup/rollup-linux-x64-musl': 4.44.0 - '@rollup/rollup-win32-arm64-msvc': 4.44.0 - '@rollup/rollup-win32-ia32-msvc': 4.44.0 - '@rollup/rollup-win32-x64-msvc': 4.44.0 - fsevents: 2.3.3 - rollup@4.46.2: dependencies: '@types/estree': 1.0.8 @@ -31585,7 +31418,7 @@ snapshots: asn1.js: 5.4.1 asn1.js-rfc2560: 5.0.1(asn1.js@5.4.1) asn1.js-rfc5280: 3.0.0 - axios: 1.10.0 + axios: 1.11.0(debug@4.3.6) big-integer: 1.6.52 bignumber.js: 9.1.2 binascii: 0.0.2 @@ -32037,7 +31870,7 @@ snapshots: sucrase@3.35.0: dependencies: - '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/gen-mapping': 0.3.13 commander: 4.1.1 glob: 10.4.5 lines-and-columns: 1.2.4 @@ -32234,7 +32067,7 @@ snapshots: terser@5.16.1: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 + acorn: 8.14.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -32375,7 +32208,7 @@ snapshots: tough-cookie@4.1.4: dependencies: - psl: 1.15.0 + psl: 1.9.0 punycode: 2.3.1 universalify: 0.2.0 url-parse: 1.5.10 @@ -32477,6 +32310,11 @@ snapshots: ts-map@1.0.3: {} + ts-morph@26.0.0: + dependencies: + '@ts-morph/common': 0.27.0 + code-block-writer: 13.0.3 + ts-node@10.9.2(@types/node@20.17.57)(typescript@5.9.2): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -32485,7 +32323,7 @@ snapshots: '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 '@types/node': 20.17.57 - acorn: 8.15.0 + acorn: 8.14.0 acorn-walk: 8.3.4 arg: 4.1.3 create-require: 1.1.1 @@ -32504,7 +32342,7 @@ snapshots: '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 '@types/node': 20.19.10 - acorn: 8.15.0 + acorn: 8.14.0 acorn-walk: 8.3.4 arg: 4.1.3 create-require: 1.1.1 @@ -32568,7 +32406,7 @@ snapshots: picocolors: 1.1.1 postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.19.3) resolve-from: 5.0.0 - rollup: 4.44.0 + rollup: 4.46.2 source-map: 0.8.0-beta.0 sucrase: 3.35.0 tinyexec: 0.3.2 @@ -32972,7 +32810,7 @@ snapshots: v8-to-istanbul@9.1.0: dependencies: - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.30 '@types/istanbul-lib-coverage': 2.0.4 convert-source-map: 1.9.0 @@ -33052,10 +32890,10 @@ snapshots: vite@6.3.5(@types/node@20.19.10)(jiti@1.21.7)(sass@1.89.2)(terser@5.16.1)(tsx@4.19.3): dependencies: esbuild: 0.25.9 - fdir: 6.4.6(picomatch@4.0.2) - picomatch: 4.0.2 + fdir: 6.4.6(picomatch@4.0.3) + picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.44.0 + rollup: 4.46.2 tinyglobby: 0.2.14 optionalDependencies: '@types/node': 20.19.10 @@ -33715,10 +33553,6 @@ snapshots: dependencies: zod: 3.25.67 - zod-to-json-schema@3.24.5(zod@3.25.67): - dependencies: - zod: 3.25.67 - zod-to-json-schema@3.24.6(zod@3.25.67): dependencies: zod: 3.25.67 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ded786aa1b..b82257f392 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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