feat: Add release and lint scripts to node CLI (#18935)

This commit is contained in:
Elias Meire
2025-09-02 05:48:43 +02:00
committed by GitHub
parent 567ede7531
commit be8061c2a5
24 changed files with 1090 additions and 202 deletions

View File

@@ -1,13 +1,297 @@
# @n8n/create-node
Scaffold a new community n8n node
A powerful scaffolding tool to quickly create custom n8n community nodes with best practices built-in.
## Usage
## 🚀 Quick Start
Create a new n8n node in seconds:
```bash
npm create @n8n/node
# or
pnpm create @n8n/node
# or
yarn create @n8n/node
```
Follow the interactive prompts to configure your node, or specify options directly:
```bash
pnpm create @n8n/node my-awesome-node --template declarative/custom
```
## 📋 Command Line Options
```bash
pnpm create @n8n/node [NAME] [OPTIONS]
```
### Options
| Flag | Description |
|------|-------------|
| `-f, --force` | Overwrite destination folder if it already exists |
| `--skip-install` | Skip automatic dependency installation |
| `--template <template>` | Specify which template to use |
### Available Templates
- **`declarative/custom`** - Start with a minimal declarative node structure
- **`declarative/github-issues`** - GitHub Issues integration example
- **`programmatic/example`** - Full programmatic node with advanced features
## 🎯 Interactive Setup
The CLI will guide you through setting up your node:
```
$ pnpm create @n8n/node
┌ @n8n/create-node
◇ What is your node called?
│ my-awesome-api-node
◇ What kind of node are you building?
│ HTTP API
◇ What template do you want to use?
│ Start from scratch
◇ What's the base URL of the API?
│ https://api.example.com/v1
◇ What type of authentication does your API use?
│ API Key
◇ Files copied ✓
◇ Dependencies installed ✓
◇ Next Steps ─────────────────────────────────────────────────────────────────────╮
│ │
│ cd ./my-awesome-api-node && pnpm run dev │
│ │
│ 📚 Documentation: https://docs.n8n.io/integrations/creating-nodes/ │
│ 💬 Community: https://community.n8n.io │
│ │
├──────────────────────────────────────────────────────────────────────────────────╯
└ Created ./my-awesome-api-node ✨
```
## 🛠️ Development Workflow
### 1. Navigate to your project
```bash
cd ./my-awesome-api-node
```
### 2. Start development server
```bash
pnpm dev
```
This command:
- Starts n8n in development mode on `http://localhost:5678`
- Enables hot reload for your node changes
- Automatically includes your node in the n8n instance
- Links your node to `~/.n8n-node-cli/.n8n/custom` for development
- Watches for file changes and rebuilds automatically
### 3. Test your node
- Open n8n at `http://localhost:5678`
- Create a new workflow
- Find your node in the node panel
- Test parameters and functionality in real-time
## 📦 Generated Project Commands
Your generated project comes with these convenient npm scripts:
### Development
```bash
pnpm dev
# Runs: n8n-node dev
```
### Building
```bash
pnpm build
# Runs: n8n-node build
```
### Linting
```bash
pnpm lint
# Runs: n8n-node lint
pnpm lint:fix
# Runs: n8n-node lint --fix
```
### Publishing
```bash
pnpm run release
# Runs: n8n-node release
```
## 📦 Build & Deploy
### Build for production
```bash
pnpm build
```
Generates:
- Compiled TypeScript code
- Bundled node package
- Optimized assets and icons
- Ready-to-publish package
### Quality checks
```bash
pnpm lint
```
Validates:
- Code style and formatting
- n8n node conventions
- Common integration issues
- Cloud publication readiness
Fix issues automatically:
```bash
pnpm lint:fix
```
### Publish your node
```bash
pnpm run release
```
Runs [release-it](https://github.com/release-it/release-it) to handle the complete release process:
- Ensures working directory is clean
- Verifies you're on the main git branch
- Increments your package version
- Runs build and lint checks
- Updates changelog
- Creates git tag with version bump
- Creates GitHub release with changelog
- Publishes to npm
## 📁 Project Structure
Your generated project includes:
```
my-awesome-api-node/
├── src/
│ ├── nodes/
│ │ └── MyAwesomeApi/
│ │ ├── MyAwesomeApi.node.ts # Main node logic
│ │ └── MyAwesomeApi.node.json # Node metadata
│ └── credentials/
│ └── MyAwesomeApiAuth.credentials.ts
├── package.json
├── tsconfig.json
└── README.md
```
The CLI expects your project to follow this structure for proper building and development.
## ⚙️ Configuration
The CLI reads configuration from your `package.json`:
```json
{
"name": "n8n-nodes-my-awesome-node",
"n8n": {
"n8nNodesApiVersion": 1,
"nodes": [
"dist/nodes/MyAwesomeApi/MyAwesomeApi.node.js"
],
"credentials": [
"dist/credentials/MyAwesomeApiAuth.credentials.js"
]
}
}
```
## 🎨 Node Types
Choose the right template for your use case:
| Template | Best For | Features |
|----------|----------|----------|
| **Declarative** | REST APIs, simple integrations | JSON-based configuration, automatic UI generation |
| **Programmatic** | Complex logic, custom operations | Full TypeScript control, advanced error handling |
## 🐛 Troubleshooting
### Common Issues
**Node not appearing in n8n:**
```bash
# Clear n8n node cli cache and restart
rm -rf ~/.n8n-node-cli/.n8n/custom
pnpm dev
```
**TypeScript errors:**
```bash
# Reinstall dependencies
rm -rf node_modules pnpm-lock.yaml
pnpm install
```
**Build failures:**
```bash
# Check for linting issues first
pnpm lint --fix
pnpm build
```
**Development server issues:**
```bash
# Clear cache and restart development server
rm -rf ~/.n8n-node-cli/.n8n/custom
pnpm dev
```
## 🔧 Advanced Usage
### Using External n8n Instance
If you prefer to use your own n8n installation:
```bash
pnpm dev --external-n8n
```
### Custom User Folder
Specify a custom location for n8n user data:
```bash
pnpm dev --custom-user-folder /path/to/custom/folder
```
## 📚 Resources
- **[Node Development Guide](https://docs.n8n.io/integrations/creating-nodes/)** - Complete documentation
- **[API Reference](https://docs.n8n.io/integrations/creating-nodes/build/reference/)** - Technical specifications
- **[Community Forum](https://community.n8n.io)** - Get help and share your nodes
- **[Node Examples](https://github.com/n8n-io/n8n/tree/master/packages/nodes-base/nodes)** - Official node implementations
- **[@n8n/node-cli](https://www.npmjs.com/package/@n8n/node-cli)** - The underlying CLI tool
## 🤝 Contributing
Found a bug or want to contribute? Check out the [n8n repository](https://github.com/n8n-io/n8n) and join our community!
---
**Happy node building! 🎉**

View File

@@ -1,6 +1,6 @@
{
"name": "@n8n/create-node",
"version": "0.2.0",
"version": "0.3.0",
"description": "Official CLI to create new community nodes for n8n",
"bin": {
"create-n8n-node": "bin/create.js"

View File

@@ -1,47 +1,272 @@
# @n8n/node-cli
Official CLI for developing community nodes for [n8n](https://n8n.io).
Official CLI for developing community nodes for n8n.
## Features
## 🚀 Getting Started
- 🔧 Scaffold new n8n nodes
- 💻 Develop n8n nodes with live preview
## Installation
Run directly via `npx`:
**To create a new node**, run:
```bash
npx n8n-node new
pnpm create @n8n/node
```
Or install globally:
This will generate a project with `pnpm` scripts that use this CLI under the hood.
## 📦 Generated Project Commands
After creating your node with `pnpm create @n8n/node`, you'll use these commands in your project:
### Development
```bash
pnpm dev
# Runs: n8n-node dev
```
### Building
```bash
pnpm build
# Runs: n8n-node build
```
### Linting
```bash
pnpm lint
# Runs: n8n-node lint
pnpm lint:fix
# Runs: n8n-node lint --fix
```
### Publishing
```bash
pnpm run release
# Runs: n8n-node release
```
## 🛠️ CLI Reference
> **Note:** These commands are typically wrapped by `pnpm` scripts in generated projects.
```bash
npm install -g @n8n/node-cli
n8n-node [COMMAND] [OPTIONS]
```
### Commands
#### `n8n-node new`
Create a new node project.
```bash
n8n-node new [NAME] [OPTIONS]
```
**Flags:**
| Flag | Description |
|------|-------------|
| `-f, --force` | Overwrite destination folder if it already exists |
| `--skip-install` | Skip installing dependencies |
| `--template <template>` | Choose template: `declarative/custom`, `declarative/github-issues`, `programmatic/example` |
**Examples:**
```bash
n8n-node new
n8n-node new n8n-nodes-my-app --skip-install
n8n-node new n8n-nodes-my-app --force
n8n-node new n8n-nodes-my-app --template declarative/custom
```
## Commands
> **Note:** This command is used internally by `pnpm create @n8n/node` to provide the interactive scaffolding experience.
## Create a node
#### `n8n-node dev`
Run n8n with your node in development mode with hot reload.
```bash
n8n-node new # Scaffold a new node
n8n-node dev [--external-n8n] [--custom-user-folder <value>]
```
## Build a node
**Flags:**
| Flag | Description |
|------|-------------|
| `--external-n8n` | Run n8n externally instead of in a subprocess |
| `--custom-user-folder <path>` | Folder to use to store user-specific n8n data (default: `~/.n8n-node-cli`) |
This command:
- Starts n8n on `http://localhost:5678` (unless using `--external-n8n`)
- Links your node to n8n's custom nodes directory (`~/.n8n-node-cli/.n8n/custom`)
- Rebuilds on file changes for live preview
- Watches for changes in your `src/` directory
**Examples:**
```bash
# Standard development with built-in n8n
n8n-node dev
# Use external n8n instance
n8n-node dev --external-n8n
# Custom n8n extensions directory
n8n-node dev --custom-user-folder /home/user
```
#### `n8n-node build`
Compile your node and prepare it for distribution.
```bash
n8n-node build # Build your node; should be ran in the root of your custom node
n8n-node build
```
## Develop a node
**Flags:** None
Generates:
- Compiled TypeScript code
- Bundled node package
- Optimized assets and icons
- Ready-to-publish package in `dist/`
#### `n8n-node lint`
Lint the node in the current directory.
```bash
n8n-node dev # Develop your node with hot reloading; should be ran in the root of your custom node
n8n-node lint [--fix]
```
## Related
**Flags:**
| Flag | Description |
|------|-------------|
| `--fix` | Automatically fix problems |
`@n8n/create-node`: Lightweight wrapper to support `npm create @n8n/node`
**Examples:**
```bash
# Check for linting issues
n8n-node lint
# Automatically fix fixable issues
n8n-node lint --fix
```
#### `n8n-node release`
Publish your community node package to npm.
```bash
n8n-node release
```
**Flags:** None
This command handles the complete release process using [release-it](https://github.com/release-it/release-it):
- Builds the node
- Runs linting checks
- Updates changelog
- Creates git tags
- Creates GitHub releases
- Publishes to npm
## 🔄 Development Workflow
The recommended workflow using the scaffolding tool:
1. **Create your node**:
```bash
pnpm create @n8n/node my-awesome-node
cd my-awesome-node
```
2. **Start development**:
```bash
pnpm dev
```
- Starts n8n on `http://localhost:5678`
- Links your node automatically
- Rebuilds on file changes
3. **Test your node** at `http://localhost:5678`
4. **Lint your code**:
```bash
pnpm lint
```
5. **Build for production**:
```bash
pnpm build
```
6. **Publish**:
```bash
pnpm run release
```
## 📁 Project Structure
The CLI expects your project to follow this structure:
```
my-node/
├── src/
│ ├── nodes/
│ │ └── MyNode/
│ │ ├── MyNode.node.ts
│ │ └── MyNode.node.json
│ └── credentials/
├── package.json
└── tsconfig.json
```
## ⚙️ Configuration
The CLI reads configuration from your `package.json`:
```json
{
"name": "n8n-nodes-my-awesome-node",
"n8n": {
"n8nNodesApiVersion": 1,
"nodes": [
"dist/nodes/MyNode/MyNode.node.js"
],
"credentials": [
"dist/credentials/MyNodeAuth.credentials.js"
]
}
}
```
## 🐛 Troubleshooting
### Development server issues
```bash
# Clear n8n custom nodes cache
rm -rf ~/.n8n-node-cli/.n8n/custom
# Restart development server
pnpm dev
```
### Build failures
```bash
# Run linting first
pnpm lint
# Clean build
pnpm build
```
## 📚 Resources
- **[Creating Nodes Guide](https://docs.n8n.io/integrations/creating-nodes/)** - Complete documentation
- **[Node Development Reference](https://docs.n8n.io/integrations/creating-nodes/build/reference/)** - API specifications
- **[Community Forum](https://community.n8n.io)** - Get help and showcase your nodes
- **[@n8n/create-node](https://www.npmjs.com/package/@n8n/create-node)** - Recommended scaffolding tool
## 🤝 Contributing
Found an issue? Contribute to the [n8n repository](https://github.com/n8n-io/n8n) on GitHub.
---
**Happy node development! 🎉**

View File

@@ -1,6 +1,6 @@
{
"name": "@n8n/node-cli",
"version": "0.2.0",
"version": "0.3.0",
"description": "Official CLI for developing community nodes for n8n",
"bin": {
"n8n-node": "bin/n8n-node.mjs"

View File

@@ -1,12 +1,12 @@
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 { runCommand } from '../utils/child-process';
import { ensureN8nPackage } from '../utils/prompts';
export default class Build extends Command {
@@ -44,42 +44,17 @@ export default class Build extends Command {
}
async function runTscBuild(): Promise<void> {
return await new Promise((resolve, reject) => {
const child = spawn('tsc', [], {
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
});
let stderr = '';
let stdout = '';
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
log.error(`${stdout.trim()}\n${stderr.trim()}`);
reject(error);
});
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
log.error(`${stdout.trim()}\n${stderr.trim()}`);
reject(new Error(`tsc exited with code ${code}`));
}
});
return await runCommand('tsc', [], {
context: 'local',
printOutput: ({ stdout, stderr }) => {
log.error(stdout.concat(stderr).toString());
},
});
}
export async function copyStaticFiles() {
const staticFiles = glob.sync(['**/*.{png,svg}', '**/__schema__/**/*.json'], {
ignore: ['dist'],
ignore: ['dist', 'node_modules'],
});
return await Promise.all(

View File

@@ -1,14 +1,17 @@
import { intro, outro, spinner } from '@clack/prompts';
import { Command, Flags } from '@oclif/core';
import os from 'node:os';
import path from 'node:path';
import picocolors from 'picocolors';
import { rimraf } from 'rimraf';
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';
import { copyStaticFiles } from '../build';
import { commands, readPackageName } from './utils';
import { runCommand } from '../../utils/child-process';
export default class Dev extends Command {
static override description = 'Run n8n with the node and rebuild on changes for live preview';
@@ -21,58 +24,70 @@ export default class Dev extends Command {
'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.',
'By default n8n-node dev will run n8n in a sub process. Enable this option if you would like to run n8n elsewhere. Make sure to set N8N_DEV_RELOAD to true in that case.',
}),
'custom-nodes-dir': Flags.directory({
default: path.join(os.homedir(), '.n8n/custom'),
'custom-user-folder': Flags.directory({
default: path.join(os.homedir(), '.n8n-node-cli'),
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.',
'Folder to use to store user-specific n8n data. By default it will use ~/.n8n-node-cli. You probably want to enable this option if you run n8n with a custom N8N_CUSTOM_EXTENSIONS env variable.',
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(Dev);
const packageManager = detectPackageManager() ?? 'npm';
const { isN8nInstalled, runCommand, runPersistentCommand } = commands();
const packageManager = (await detectPackageManager()) ?? 'npm';
const { runPersistentCommand } = commands();
intro(picocolors.inverse(' n8n-node dev '));
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();
const linkingSpinner = spinner();
linkingSpinner.start('Linking custom node to n8n');
await runCommand(packageManager, ['link']);
const customPath = flags['custom-nodes-dir'];
const n8nUserFolder = flags['custom-user-folder'];
const customNodesFolder = path.join(n8nUserFolder, '.n8n', 'custom');
await ensureFolder(customPath);
await ensureFolder(customNodesFolder);
const packageName = await readPackageName();
const invalidNodeNameError = validateNodeName(packageName);
if (invalidNodeNameError) return onCancel(invalidNodeNameError);
await runCommand(packageManager, ['link', packageName], { cwd: customPath });
// Remove existing package.json to avoid conflicts
await rimraf(path.join(customNodesFolder, 'package.json'));
await runCommand(packageManager, ['link', packageName], {
cwd: customNodesFolder,
});
linkingSpinner.stop('Linked custom node to n8n');
outro('✓ Setup complete');
if (!flags['external-n8n']) {
// Run n8n with hot reload enabled
runPersistentCommand('n8n', [], {
cwd: customPath,
env: { N8N_DEV_RELOAD: 'true' },
runPersistentCommand('npx', ['-y', '--quiet', 'n8n'], {
cwd: n8nUserFolder,
env: {
...process.env,
N8N_DEV_RELOAD: 'true',
N8N_RUNNERS_ENABLED: 'true',
DB_SQLITE_POOL_SIZE: '10',
N8N_USER_FOLDER: n8nUserFolder,
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: 'false',
},
name: 'n8n',
color: picocolors.green,
});
}
// Run `tsc --watch` in background
runPersistentCommand('tsc', ['--watch'], {
runPersistentCommand(packageManager, ['exec', '--', 'tsc', '--watch'], {
name: 'build',
color: picocolors.cyan,
});

View File

@@ -22,34 +22,6 @@ export function commands() {
process.on('SIGINT', () => cleanup('SIGINT'));
process.on('SIGTERM', () => cleanup('SIGTERM'));
async function runCommand(
cmd: string,
args: string[],
opts: {
cwd?: string;
env?: NodeJS.ProcessEnv;
} = {},
): Promise<void> {
return await new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd: opts.cwd,
env: { ...process.env, ...opts.env },
stdio: ['inherit', 'pipe', 'pipe'],
});
child.on('error', (error) => {
reject(error);
});
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${cmd} exited with code ${code}`));
});
registerChild(child);
});
}
function runPersistentCommand(
cmd: string,
args: string[],
@@ -101,18 +73,7 @@ export function commands() {
});
}
async function isN8nInstalled(): Promise<boolean> {
try {
await runCommand('n8n', ['--version'], {});
return true;
} catch {
return false;
}
}
return {
isN8nInstalled,
runCommand,
runPersistentCommand,
};
}

View File

@@ -0,0 +1,34 @@
import { Command, Flags } from '@oclif/core';
import { ChildProcessError, runCommand } from '../utils/child-process';
export default class Lint extends Command {
static override description = 'Lint the node in the current directory. Includes auto-fixing.';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {
fix: Flags.boolean({ description: 'Automatically fix problems', default: false }),
};
async run(): Promise<void> {
const { flags } = await this.parse(Lint);
const args = ['.'];
if (flags.fix) {
args.push('--fix');
}
try {
await runCommand('eslint', args, { context: 'local', stdio: 'inherit' });
} catch (error: unknown) {
if (error instanceof ChildProcessError) {
if (error.signal) {
process.kill(process.pid, error.signal);
} else {
process.exit(error.code ?? 0);
}
}
throw error;
}
}
}

View File

@@ -1,4 +1,4 @@
import { confirm, intro, isCancel, note, outro, spinner } from '@clack/prompts';
import { confirm, intro, isCancel, log, note, outro, spinner } from '@clack/prompts';
import { Args, Command, Flags } from '@oclif/core';
import { camelCase } from 'change-case';
import fs from 'node:fs/promises';
@@ -9,9 +9,10 @@ import { declarativeTemplatePrompt, nodeNamePrompt, nodeTypePrompt } from './pro
import { createIntro } from './utils';
import type { TemplateData, TemplateWithRun } from '../../template/core';
import { getTemplate, isTemplateName, isTemplateType, templates } from '../../template/templates';
import { ChildProcessError, runCommand } from '../../utils/child-process';
import { delayAtLeast, folderExists } from '../../utils/filesystem';
import { tryReadGitUser } from '../../utils/git';
import { detectPackageManager, installDependencies } from '../../utils/package-manager';
import { initGit, tryReadGitUser } from '../../utils/git';
import { detectPackageManager } from '../../utils/package-manager';
import { onCancel } from '../../utils/prompts';
import { validateNodeName } from '../../utils/validation';
@@ -80,7 +81,7 @@ export default class New extends Command {
}
const config = (await template.prompts?.()) ?? {};
const packageManager = detectPackageManager() ?? 'npm';
const packageManager = (await detectPackageManager()) ?? 'npm';
const templateData: TemplateData = {
destinationPath: destination,
nodePackageName: nodeName,
@@ -99,22 +100,59 @@ export default class New extends Command {
await delayAtLeast(template.run(templateData), 1000);
copyingSpinner.stop('Files copied');
const gitSpinner = spinner();
gitSpinner.start('Initializing git repository');
try {
await initGit(destination);
gitSpinner.stop('Git repository initialized');
} catch (error: unknown) {
if (error instanceof ChildProcessError) {
gitSpinner.stop(
`Could not initialize git repository: ${error.message}`,
error.code ?? undefined,
);
process.exit(error.code ?? 1);
} else {
throw error;
}
}
if (!flags['skip-install']) {
const installingSpinner = spinner();
installingSpinner.start('Installing dependencies');
try {
await delayAtLeast(installDependencies({ dir: destination, packageManager }), 1000);
await delayAtLeast(
runCommand(packageManager, ['install'], {
cwd: destination,
printOutput: ({ stdout, stderr }) => {
log.error(stdout.concat(stderr).toString());
},
}),
1000,
);
} catch (error: unknown) {
installingSpinner.stop('Could not install dependencies', 1);
return process.exit(1);
if (error instanceof ChildProcessError) {
installingSpinner.stop(
`Could not install dependencies: ${error.message}`,
error.code ?? undefined,
);
process.exit(error.code ?? 1);
} else {
throw error;
}
}
installingSpinner.stop('Dependencies installed');
}
note(
`Need help? Check out the docs: https://docs.n8n.io/integrations/creating-nodes/build/${type}-style-node/`,
`cd ./${nodeName} && ${packageManager} run dev
📚 Documentation: https://docs.n8n.io/integrations/creating-nodes/build/${type}-style-node/
💬 Community: https://community.n8n.io`,
'Next Steps',
);

View File

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

View File

@@ -0,0 +1,22 @@
import { Command } from '@oclif/core';
import { detectPackageManager } from '../utils/package-manager';
export default class Prerelease extends Command {
static override description =
'Only for internal use. Prevent npm publish, instead require npm run release';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {};
static override hidden = true;
async run(): Promise<void> {
await this.parse(Prerelease);
const packageManager = (await detectPackageManager()) ?? 'npm';
if (!process.env.RELEASE_MODE) {
console.log(`Run \`${packageManager} run release\` to publish the package`);
process.exit(1);
}
}
}

View File

@@ -0,0 +1,52 @@
import { Command } from '@oclif/core';
import { ChildProcessError, runCommand } from '../utils/child-process';
import { detectPackageManager } from '../utils/package-manager';
export default class Release extends Command {
static override description = 'Publish your community node package to npm';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {};
async run(): Promise<void> {
await this.parse(Release);
const pm = (await detectPackageManager()) ?? 'npm';
try {
await runCommand(
'release-it',
[
'-n',
'--git.requireBranch main',
'--git.requireCleanWorkingDir',
'--git.requireUpstream',
'--git.requireCommits',
'--git.commit',
'--git.tag',
'--git.push',
'--git.changelog="npx auto-changelog --stdout --unreleased --commit-limit false -u --hide-credit"',
'--github.release',
`--hooks.before:init="${pm} run lint && ${pm} run build"`,
'--hooks.after:bump="npx auto-changelog -p"',
],
{
stdio: 'inherit',
context: 'local',
env: {
RELEASE_MODE: 'true',
},
},
);
} catch (error) {
if (error instanceof ChildProcessError) {
if (error.signal) {
process.kill(process.pid, error.signal);
} else {
process.exit(error.code ?? 0);
}
}
throw error;
}
}
}

View File

@@ -1,9 +1,15 @@
import Build from './commands/build';
import Dev from './commands/dev';
import Lint from './commands/lint';
import New from './commands/new';
import Prerelease from './commands/prerelease';
import Release from './commands/release';
export const commands = {
new: New,
build: Build,
dev: Dev,
prerelease: Prerelease,
release: Release,
lint: Lint,
};

View File

@@ -15,13 +15,15 @@
"type": "git",
"url": ""
},
"scripts": {
"build": "n8n-node build",
"build:watch": "tsc --watch",
"dev": "n8n-node dev",
"lint": "eslint .",
"release": "release-it"
},
"scripts": {
"build": "n8n-node build",
"build:watch": "tsc --watch",
"dev": "n8n-node dev",
"lint": "n8n-node lint",
"lint:fix": "n8n-node lint --fix",
"release": "n8n-node release",
"prepublishOnly": "n8n-node prerelease"
},
"files": [
"dist"
],
@@ -32,11 +34,6 @@
"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",

View File

@@ -15,13 +15,15 @@
"type": "git",
"url": ""
},
"scripts": {
"build": "n8n-node build",
"build:watch": "tsc --watch",
"dev": "n8n-node dev",
"lint": "eslint .",
"release": "release-it"
},
"scripts": {
"build": "n8n-node build",
"build:watch": "tsc --watch",
"dev": "n8n-node dev",
"lint": "n8n-node lint",
"lint:fix": "n8n-node lint --fix",
"release": "n8n-node release",
"prepublishOnly": "n8n-node prerelease"
},
"files": [
"dist"
],
@@ -35,11 +37,6 @@
"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",

View File

@@ -15,13 +15,15 @@
"type": "git",
"url": ""
},
"scripts": {
"build": "n8n-node build",
"build:watch": "tsc --watch",
"dev": "n8n-node dev",
"lint": "eslint .",
"release": "release-it"
},
"scripts": {
"build": "n8n-node build",
"build:watch": "tsc --watch",
"dev": "n8n-node dev",
"lint": "n8n-node lint",
"lint:fix": "n8n-node lint --fix",
"release": "n8n-node release",
"prepublishOnly": "n8n-node prerelease"
},
"files": [
"dist"
],
@@ -32,11 +34,6 @@
"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",

View File

@@ -1 +1,2 @@
dist
node_modules

View File

@@ -0,0 +1,82 @@
import { spawn, type SpawnOptions, type StdioOptions } from 'node:child_process';
import { detectPackageManager } from './package-manager';
export class ChildProcessError extends Error {
constructor(
message: string,
public code: number | null,
public signal: NodeJS.Signals | null,
) {
super(message);
}
}
export async function runCommand(
cmd: string,
args: string[] = [],
opts: {
cwd?: string;
env?: NodeJS.ProcessEnv;
stdio?: StdioOptions;
context?: 'local' | 'global';
printOutput?: (options: { stdout: Buffer[]; stderr: Buffer[] }) => void;
} = {},
): Promise<void> {
const packageManager = (await detectPackageManager()) ?? 'npm';
return await new Promise((resolve, reject) => {
const options: SpawnOptions = {
cwd: opts.cwd,
env: { ...process.env, ...opts.env },
stdio: opts.stdio ?? ['ignore', 'pipe', 'pipe'],
};
const child =
opts.context === 'local'
? spawn(packageManager, ['exec', '--', cmd, ...args], options)
: spawn(cmd, args, options);
const stdoutBuffers: Buffer[] = [];
const stderrBuffers: Buffer[] = [];
child.stdout?.on('data', (data: Buffer) => {
stdoutBuffers.push(data);
});
child.stderr?.on('data', (data: Buffer) => {
stderrBuffers.push(data);
});
function printOutput() {
if (opts.printOutput) {
opts.printOutput({ stdout: stdoutBuffers, stderr: stderrBuffers });
return;
}
for (const buffer of stdoutBuffers) {
process.stdout.write(buffer);
}
for (const buffer of stderrBuffers) {
process.stderr.write(buffer);
}
}
child.on('error', (error) => {
printOutput();
reject(new ChildProcessError(error.message, null, null));
});
child.on('close', (code, signal) => {
if (code === 0) {
resolve();
} else {
printOutput();
reject(
new ChildProcessError(
`${cmd} exited with code ${code}${signal ? ` (signal: ${signal})` : ''}`,
code,
signal,
),
);
}
});
});
}

View File

@@ -1,5 +1,7 @@
import { execSync } from 'child_process';
import { runCommand } from './child-process';
type GitUser = {
name?: string;
email?: string;
@@ -32,3 +34,7 @@ export function tryReadGitUser(): GitUser {
return user;
}
export async function initGit(dir: string): Promise<void> {
await runCommand('git', ['init', '-b', 'main'], { cwd: dir });
}

View File

@@ -0,0 +1,196 @@
import type { Stats } from 'node:fs';
import fs from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { detectPackageManager, detectPackageManagerFromUserAgent } from './package-manager';
// Mock dependencies
vi.mock('node:child_process');
vi.mock('node:fs/promises');
vi.mock('@clack/prompts');
describe('package manager utils', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
describe('detectPackageManagerFromUserAgent', () => {
it('returns pnpm when user agent contains pnpm', () => {
process.env.npm_config_user_agent = 'pnpm/8.6.0 npm/? node/v18.16.0 darwin x64';
const result = detectPackageManagerFromUserAgent();
expect(result).toBe('pnpm');
});
it('returns yarn when user agent contains yarn', () => {
process.env.npm_config_user_agent = 'yarn/1.22.19 npm/? node/v18.16.0 darwin x64';
const result = detectPackageManagerFromUserAgent();
expect(result).toBe('yarn');
});
it('returns npm when user agent contains npm', () => {
process.env.npm_config_user_agent = 'npm/9.5.1 node/v18.16.0 darwin x64 workspaces/false';
const result = detectPackageManagerFromUserAgent();
expect(result).toBe('npm');
});
it('prioritizes pnpm over yarn and npm when multiple are present', () => {
process.env.npm_config_user_agent = 'pnpm/8.6.0 yarn/1.22.19 npm/9.5.1 node/v18.16.0';
const result = detectPackageManagerFromUserAgent();
expect(result).toBe('pnpm');
});
it('prioritizes yarn over npm when both are present but no pnpm', () => {
process.env.npm_config_user_agent = 'yarn/1.22.19 npm/9.5.1 node/v18.16.0';
const result = detectPackageManagerFromUserAgent();
expect(result).toBe('yarn');
});
it('returns null when npm_config_user_agent is not set', () => {
delete process.env.npm_config_user_agent;
const result = detectPackageManagerFromUserAgent();
expect(result).toBe(null);
});
it('returns null when user agent does not contain any package manager', () => {
process.env.npm_config_user_agent = 'node/v18.16.0 darwin x64';
const result = detectPackageManagerFromUserAgent();
expect(result).toBe(null);
});
it('returns null when user agent is empty string', () => {
process.env.npm_config_user_agent = '';
const result = detectPackageManagerFromUserAgent();
expect(result).toBe(null);
});
});
describe('detectPackageManager', () => {
it('returns package manager from user agent when available', async () => {
process.env.npm_config_user_agent = 'pnpm/8.6.0 npm/? node/v18.16.0';
const result = await detectPackageManager();
expect(result).toBe('pnpm');
expect(vi.mocked(fs).stat).not.toHaveBeenCalled();
});
it('detects npm from package-lock.json when user agent is not available', async () => {
delete process.env.npm_config_user_agent;
vi.mocked(fs).stat.mockImplementation(async (path) => {
if (path === 'package-lock.json') {
const stats = mock<Stats>();
stats.isFile.mockReturnValue(true);
return await Promise.resolve(stats);
}
throw new Error('File not found');
});
const result = await detectPackageManager();
expect(result).toBe('npm');
expect(vi.mocked(fs).stat).toHaveBeenCalledWith('package-lock.json');
});
it('detects yarn from yarn.lock when user agent is not available', async () => {
delete process.env.npm_config_user_agent;
vi.mocked(fs).stat.mockImplementation(async (path) => {
if (path === 'yarn.lock') {
const stats = mock<Stats>();
stats.isFile.mockReturnValue(true);
return await Promise.resolve(stats);
}
throw new Error('File not found');
});
const result = await detectPackageManager();
expect(result).toBe('yarn');
expect(vi.mocked(fs).stat).toHaveBeenCalledWith('package-lock.json');
expect(vi.mocked(fs).stat).toHaveBeenCalledWith('yarn.lock');
});
it('detects pnpm from pnpm-lock.yaml when user agent is not available', async () => {
delete process.env.npm_config_user_agent;
vi.mocked(fs).stat.mockImplementation(async (path) => {
if (path === 'pnpm-lock.yaml') {
const stats = mock<Stats>();
stats.isFile.mockReturnValue(true);
return await Promise.resolve(stats);
}
throw new Error('File not found');
});
const result = await detectPackageManager();
expect(result).toBe('pnpm');
expect(vi.mocked(fs).stat).toHaveBeenCalledWith('package-lock.json');
expect(vi.mocked(fs).stat).toHaveBeenCalledWith('yarn.lock');
expect(vi.mocked(fs).stat).toHaveBeenCalledWith('pnpm-lock.yaml');
});
it('prioritizes npm lock file when multiple lock files exist', async () => {
delete process.env.npm_config_user_agent;
const stats = mock<Stats>();
stats.isFile.mockReturnValue(true);
vi.mocked(fs).stat.mockResolvedValue(stats);
const result = await detectPackageManager();
expect(result).toBe('npm');
});
it('returns null when no user agent and no lock files exist', async () => {
delete process.env.npm_config_user_agent;
vi.mocked(fs).stat.mockRejectedValue(new Error('File not found'));
const result = await detectPackageManager();
expect(result).toBe(null);
});
it('ignores directories that match lock file names', async () => {
delete process.env.npm_config_user_agent;
vi.mocked(fs).stat.mockImplementation(async (path) => {
if (path === 'package-lock.json') {
const stats = mock<Stats>();
stats.isFile.mockReturnValue(false);
return await Promise.resolve(stats);
}
throw new Error('File not found');
});
const result = await detectPackageManager();
expect(result).toBe(null);
});
});
});

View File

@@ -1,45 +1,45 @@
import { log } from '@clack/prompts';
import { spawn } from 'node:child_process';
import pico from 'picocolors';
import fs from 'node:fs/promises';
type PackageManager = 'npm' | 'yarn' | 'pnpm';
export function detectPackageManager(): PackageManager | null {
export function detectPackageManagerFromUserAgent(): PackageManager | null {
if ('npm_config_user_agent' in process.env) {
const ua = process.env['npm_config_user_agent'] ?? '';
if (ua.includes('pnpm')) return 'pnpm';
if (ua.includes('yarn')) return 'yarn';
if (ua.includes('npm')) return 'npm';
}
return null;
}
export async function installDependencies({
dir,
packageManager,
}: { dir: string; packageManager: PackageManager }): Promise<void> {
return await new Promise((resolve, reject) => {
const child = spawn(packageManager, ['install'], {
cwd: dir,
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
});
async function detectPackageManagerFromLockFiles(): Promise<PackageManager | null> {
const lockFiles: Record<PackageManager, string> = {
npm: 'package-lock.json',
yarn: 'yarn.lock',
pnpm: 'pnpm-lock.yaml',
};
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);
for (const [pm, lockFile] of Object.entries(lockFiles)) {
try {
const stats = await fs.stat(lockFile);
if (stats.isFile()) {
return pm as PackageManager;
}
});
});
} catch (e) {
// File does not exist
}
}
return null;
}
export async function detectPackageManager(): Promise<PackageManager | null> {
// When used via package.json scripts or `npm/yarn/pnpm create`, we can detect the package manager via the user agent
const fromUserAgent = detectPackageManagerFromUserAgent();
if (fromUserAgent) return fromUserAgent;
// When used directly via `n8n-node` CLI, we can try to detect the package manager via the lock files
const fromLockFiles = await detectPackageManagerFromLockFiles();
if (fromLockFiles) return fromLockFiles;
return null;
}

View File

@@ -49,7 +49,6 @@
],
"devDependencies": {
"@n8n/typescript-config": "workspace:*",
"@parcel/watcher": "^2.5.1",
"@redocly/cli": "^1.28.5",
"@types/aws4": "^1.5.1",
"@types/bcryptjs": "^2.4.2",
@@ -106,6 +105,7 @@
"@n8n_io/ai-assistant-sdk": "catalog:",
"@n8n_io/license-sdk": "2.23.0",
"@rudderstack/rudder-sdk-node": "2.1.4",
"@parcel/watcher": "^2.5.1",
"@sentry/node": "catalog:",
"aws4": "1.11.0",
"axios": "catalog:",

6
pnpm-lock.yaml generated
View File

@@ -1504,6 +1504,9 @@ importers:
'@n8n_io/license-sdk':
specifier: 2.23.0
version: 2.23.0
'@parcel/watcher':
specifier: ^2.5.1
version: 2.5.1
'@rudderstack/rudder-sdk-node':
specifier: 2.1.4
version: 2.1.4(tslib@2.8.1)
@@ -1736,9 +1739,6 @@ importers:
'@n8n/typescript-config':
specifier: workspace:*
version: link:../@n8n/typescript-config
'@parcel/watcher':
specifier: ^2.5.1
version: 2.5.1
'@redocly/cli':
specifier: ^1.28.5
version: 1.28.5(encoding@0.1.13)