From b9249dd9ca54d46f29a67aac23f9a0b98dbd286c Mon Sep 17 00:00:00 2001 From: n3wk Date: Mon, 24 Aug 2026 19:06:08 +0200 Subject: [PATCH] Initial release: generic CLI agent nodes for Nextcloud Node-RED --- .dockerignore | 30 +++++ CHANGELOG.md | 18 +++ LICENSE | 21 +++ README.md | 148 +++++++++++++++++++++ bootstrap-install.sh | 52 ++++++++ flows/example-flows.json | 266 ++++++++++++++++++++++++++++++++++++++ integrate-into-nodered.sh | 119 +++++++++++++++++ nodes/agent-config.html | 56 ++++++++ nodes/agent-config.js | 20 +++ nodes/agent-exec.html | 70 ++++++++++ nodes/agent-exec.js | 133 +++++++++++++++++++ nodes/agent-file.html | 79 +++++++++++ nodes/agent-file.js | 133 +++++++++++++++++++ nodes/agent-process.html | 83 ++++++++++++ nodes/agent-process.js | 180 ++++++++++++++++++++++++++ package.json | 29 +++++ 16 files changed, 1437 insertions(+) create mode 100644 .dockerignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bootstrap-install.sh create mode 100644 flows/example-flows.json create mode 100644 integrate-into-nodered.sh create mode 100644 nodes/agent-config.html create mode 100644 nodes/agent-config.js create mode 100644 nodes/agent-exec.html create mode 100644 nodes/agent-exec.js create mode 100644 nodes/agent-file.html create mode 100644 nodes/agent-file.js create mode 100644 nodes/agent-process.html create mode 100644 nodes/agent-process.js create mode 100644 package.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..609a49a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +# Built artifacts +node_modules/ +npm-debug.log* + +# IDE +.vscode/ +.idea/ +*.swp + +# OS +.DS_Store +Thumbs.db + +# Git +.git/ +.gitignore + +# Test +coverage/ +.nyc_output/ + +# Logs +*.log + +# Local config +.env +.env.local + +# Docker +docker-compose.override.yml \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6c50a50 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2024-08-24 + +### Added +- Initial release +- `agent-config` - Configuration node for CLI agent +- `agent-exec` - Execute agent commands (run, version, help, etc.) +- `agent-file` - Read/write/list files in agent workspace +- `agent-process` - Manage persistent agent processes +- Integration script for existing `nextcloud-node-red` Docker setup +- Bootstrap installer for VM-agnostic deployment +- Example flows demonstrating Nextcloud + agent integration \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e3ca803 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 hannesahlin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbd8549 --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# node-red-contrib-nextcloud-agent + +Node-RED nodes for **generic CLI agent** integration with Nextcloud. + +Works alongside your existing [`nextcloud-nodered-ocs-api`](https://kode.hannesahlin.se/n3wk/nextcloud-nodered-ocs-api) and [`nextcloud-collabora-nodered`](https://kode.hannesahlin.se/n3wk/nextcloud-collabora-nodered) setup. + +## Nodes + +| Node | Category | Description | +|------|----------|-------------| +| `agent-config` | config | Shared configuration (CLI path, workspace, API endpoint) | +| `agent-exec` | agent | Execute agent CLI commands (`run`, `version`, `help`, etc.) | +| `agent-file` | agent | Read/write/list files in the agent workspace | +| `agent-process` | agent | Manage persistent agent processes | + +## Quick Install + +```bash +# Clone this repo +git clone https://kode.hannesahlin.se/n3wk/nextcloud-nodered-agent.git +cd nextcloud-nodered-agent + +# Integrate into your existing nextcloud-node-red +NODERED_DIR=/path/to/nextcloud-node-red ./integrate-into-nodered.sh + +# Rebuild and restart +cd /path/to/nextcloud-node-red +docker compose build --no-cache +docker compose up -d +``` + +## Bootstrap Install (VM-agnostic) + +```bash +curl -fsSL https://kode.hannesahlin.se/n3wk/nextcloud-nodered-agent/raw/branch/main/bootstrap-install.sh | sudo -u ncdeploy bash +``` + +This auto-detects your `nextcloud-node-red` installation and patches it. + +## Configuration + +In Node-RED, add an **agent config** node: + +- **CLI Command**: `agent` (install your agent in the Docker container) +- **Workspace Directory**: `/data/agent-workspace` (shared Docker volume) +- **API Endpoint**: `http://localhost:3000` (if agent runs in server mode) +- **Default Arguments**: `--verbose` (space-separated defaults) +- **Max Concurrent Processes**: `3` + +**Important**: You must edit the Dockerfile after integration to install YOUR specific agent CLI. + +## Example Flows + +Import `flows/example-flows.json` for: + +1. **Run Task** - Use `agent-exec` to execute a one-off task +2. **Persistent Process** - Start a process, send follow-up inputs, check status +3. **Nextcloud + Agent** - List Nextcloud files → process with agent → save back + +## Docker Requirements + +The integration script adds to your `nextcloud-node-red`: +- **You must add agent CLI installation** to Dockerfile (script adds placeholder) +- `agent-workspace` Docker volume mounted at `/data/agent-workspace` +- Nodes copied to `/opt/nextcloud-nodes/node-red-contrib-nextcloud-agent` and installed at container start + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Nextcloud (https://cloud.example.com) │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ nodered_embed app → iframe → Node-RED :1880 │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ agent nodes │ │ │ +│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ │ +│ │ │ │ exec │ │ file │ │ process │ │ │ │ +│ │ │ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │ │ │ +│ │ │ │ │ │ │ │ │ +│ │ │ ▼ ▼ ▼ │ │ │ +│ │ │ ┌─────────────────────────────────────────┐ │ │ │ +│ │ │ │ Your CLI Agent (in container) │ │ │ │ +│ │ │ │ Workspace: /data/agent-workspace │ │ │ │ +│ │ │ └─────────────────────────────────────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Usage Examples + +### Execute Task with agent-exec + +```javascript +// Inject node +msg.prompt = "Process all files in /data/input and generate summary"; +return msg; +``` + +Connect to `agent-exec` with command `run`. + +### Persistent Process + +```javascript +// Start process +msg.action = "start"; +msg.processId = "my-processor"; +msg.payload = "Watch /data/input for new files and process them"; +return msg; + +// Send follow-up +msg.action = "send"; +msg.processId = "my-processor"; +msg.payload = "Also generate CSV export"; +return msg; +``` + +### Read/Write Files in Workspace + +```javascript +// Write agent output to file +msg.filePath = "results/output.txt"; +msg.payload = msg.payload; // from agent-exec +msg.operation = "write"; +return msg; +``` + +## Integration with Nextcloud Nodes + +Combine with your existing `file-operations`, `ocs-api`, `collectives` nodes: + +1. **Read from Nextcloud** → `file-operations` (list/download) +2. **Process with agent** → `agent-exec` (transform, analyze, generate) +3. **Write to workspace** → `agent-file` (save intermediate) +4. **Upload to Nextcloud** → `file-operations` (upload) + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| `agent: command not found` | Edit Dockerfile to install your agent CLI, then rebuild | +| Permission denied on workspace | Ensure `agent-workspace` volume has correct permissions | +| Process won't start | Check `docker logs nextcloud-node-red` for agent errors | +| Nodes not appearing | Restart Node-RED container; check `/data/node_modules/node-red-contrib-nextcloud-agent` | + +## License + +MIT — see [LICENSE](LICENSE) \ No newline at end of file diff --git a/bootstrap-install.sh b/bootstrap-install.sh new file mode 100644 index 0000000..444d8d8 --- /dev/null +++ b/bootstrap-install.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Bootstrap installer for node-red-contrib-nextcloud-agent +# Clones repo and integrates into existing nextcloud-node-red + +REPO_URL="https://kode.hannesahlin.se/n3wk/nextcloud-nodered-agent.git" +INSTALL_DIR="${INSTALL_DIR:-/opt/node-red-contrib-nextcloud-agent}" +NODERED_DIR="${NODERED_DIR:-}" + +echo "=== Nextcloud Agent Node-RED Bootstrap Installer ===" + +# Auto-detect nextcloud-node-red directory if not set +if [[ -z "$NODERED_DIR" ]]; then + for dir in \ + "/opt/nextcloud-node-red" \ + "/home/ncdeploy/nextcloud-node-red" \ + "/root/nextcloud-node-red" \ + "$(docker inspect -f '{{.Config.Labels}}' nextcloud-node-red 2>/dev/null | grep -o 'com.docker.compose.project.working_dir=[^ ]*' | cut -d= -f2)"; do + if [[ -d "$dir" && -f "$dir/Dockerfile" ]]; then + NODERED_DIR="$dir" + break + fi + done +fi + +if [[ -z "$NODERED_DIR" ]]; then + echo "ERROR: Could not auto-detect nextcloud-node-red directory." + echo "Set NODERED_DIR environment variable." + exit 1 +fi + +echo "Target Node-RED: $NODERED_DIR" + +# Clone or update repo +if [[ -d "$INSTALL_DIR/.git" ]]; then + echo "Updating existing repo..." + cd "$INSTALL_DIR" + git pull +else + echo "Cloning repo..." + git clone "$REPO_URL" "$INSTALL_DIR" + cd "$INSTALL_DIR" +fi + +# Run integration +export NODERED_DIR +"$INSTALL_DIR/integrate-into-nodered.sh" + +echo "" +echo "=== Bootstrap Complete ===" +echo "Rebuild with: cd $NODERED_DIR && docker compose build --no-cache && docker compose up -d" \ No newline at end of file diff --git a/flows/example-flows.json b/flows/example-flows.json new file mode 100644 index 0000000..e89be2f --- /dev/null +++ b/flows/example-flows.json @@ -0,0 +1,266 @@ +[ + { + "id": "agent-config", + "type": "agent-config", + "name": "Agent Config", + "cliPath": "agent", + "workspaceDir": "/data/agent-workspace", + "apiEndpoint": "http://localhost:3000", + "defaultArgs": "", + "maxConcurrent": 3, + "autoStart": true + }, + { + "id": "run-task", + "type": "inject", + "name": "Run Task", + "props": [ + {"p": "payload"}, + {"p": "prompt", "v": "Process all files in /data/input and generate summary report", "vt": "str"} + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "x": 200, + "y": 200, + "wires": [["agent-exec-run"]] + }, + { + "id": "agent-exec-run", + "type": "agent-exec", + "name": "Agent Exec", + "agentConfig": "agent-config", + "command": "run", + "args": "", + "workingDir": "/data/agent-workspace", + "timeout": "120000", + "outputFormat": "text", + "x": 450, + "y": 200, + "wires": [["write-result", "debug-output"]] + }, + { + "id": "write-result", + "type": "agent-file", + "name": "Write Result", + "agentConfig": "agent-config", + "operation": "write", + "filePath": "results/summary.txt", + "encoding": "utf8", + "createDirs": true, + "x": 700, + "y": 200, + "wires": [["list-results"]] + }, + { + "id": "list-results", + "type": "agent-file", + "name": "List Results", + "agentConfig": "agent-config", + "operation": "list", + "filePath": "results", + "encoding": "utf8", + "x": 950, + "y": 200, + "wires": [["debug-list"]] + }, + { + "id": "debug-output", + "type": "debug", + "name": "Debug Output", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "payload", + "targetType": "msg", + "x": 700, + "y": 140, + "wires": [] + }, + { + "id": "debug-list", + "type": "debug", + "name": "Debug File List", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "payload", + "targetType": "msg", + "x": 1200, + "y": 200, + "wires": [] + }, + { + "id": "start-process", + "type": "inject", + "name": "Start Process", + "props": [ + {"p": "action", "v": "start", "vt": "str"}, + {"p": "processId", "v": "data-processor", "vt": "str"}, + {"p": "payload", "v": "Watch /data/input for new files and process them", "vt": "str"} + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "x": 200, + "y": 400, + "wires": [["agent-process"]] + }, + { + "id": "send-input", + "type": "inject", + "name": "Send Input", + "props": [ + {"p": "action", "v": "send", "vt": "str"}, + {"p": "processId", "v": "data-processor", "vt": "str"}, + {"p": "payload", "v": "Also generate CSV export", "vt": "str"} + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "x": 200, + "y": 460, + "wires": [["agent-process"]] + }, + { + "id": "check-status", + "type": "inject", + "name": "Check Status", + "props": [ + {"p": "action", "v": "status", "vt": "str"}, + {"p": "processId", "v": "data-processor", "vt": "str"} + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "x": 200, + "y": 520, + "wires": [["agent-process"]] + }, + { + "id": "agent-process", + "type": "agent-process", + "name": "Data Processor", + "agentConfig": "agent-config", + "processId": "data-processor", + "action": "start", + "workingDir": "/data/agent-workspace", + "autoRestart": true, + "x": 450, + "y": 460, + "wires": [["debug-process"]] + }, + { + "id": "debug-process", + "type": "debug", + "name": "Debug Process", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "payload", + "targetType": "msg", + "x": 700, + "y": 460, + "wires": [] + }, + { + "id": "nextcloud-flow", + "type": "inject", + "name": "Nextcloud + Agent Flow", + "props": [ + {"p": "topic", "v": "nextcloud-agent", "vt": "str"} + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "x": 200, + "y": 650, + "wires": [["nc-list-files"]] + }, + { + "id": "nc-list-files", + "type": "file-operations", + "name": "List Nextcloud Files", + "nextcloud": "nextcloud-config", + "operation": "list", + "path": "/Documents", + "x": 450, + "y": 650, + "wires": [["filter-files"]] + }, + { + "id": "filter-files", + "type": "function", + "name": "Filter Files", + "func": "const files = msg.payload.ocs.data.filter(f => f.name.endsWith('.txt') || f.name.endsWith('.md'));\nmsg.payload = files.map(f => f.name);\nreturn msg;", + "outputs": 1, + "x": 700, + "y": 650, + "wires": [["agent-process-files"]] + }, + { + "id": "agent-process-files", + "type": "agent-exec", + "name": "Process with Agent", + "agentConfig": "agent-config", + "command": "run", + "args": "", + "workingDir": "/data/agent-workspace", + "timeout": "180000", + "outputFormat": "text", + "x": 950, + "y": 650, + "wires": [["save-processed"]] + }, + { + "id": "save-processed", + "type": "agent-file", + "name": "Save Processed Output", + "agentConfig": "agent-config", + "operation": "write", + "filePath": "processed/output.txt", + "encoding": "utf8", + "createDirs": true, + "x": 1200, + "y": 650, + "wires": [["nc-upload-processed"]] + }, + { + "id": "nc-upload-processed", + "type": "file-operations", + "name": "Upload to Nextcloud", + "nextcloud": "nextcloud-config", + "operation": "upload", + "path": "/Documents/processed/output.txt", + "x": 1450, + "y": 650, + "wires": [["debug-upload"]] + }, + { + "id": "debug-upload", + "type": "debug", + "name": "Debug Upload", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "payload", + "targetType": "msg", + "x": 1700, + "y": 650, + "wires": [] + } +] \ No newline at end of file diff --git a/integrate-into-nodered.sh b/integrate-into-nodered.sh new file mode 100644 index 0000000..9895b51 --- /dev/null +++ b/integrate-into-nodered.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Integration script for node-red-contrib-nextcloud-agent +# Patches agent nodes into existing nextcloud-node-red Docker image + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NODERED_DIR="${NODERED_DIR:-/path/to/nextcloud-node-red}" + +echo "=== Nextcloud Agent Node-RED Integration ===" +echo "Source: $SCRIPT_DIR" +echo "Target: $NODERED_DIR" + +if [[ ! -d "$NODERED_DIR" ]]; then + echo "ERROR: NODERED_DIR not found: $NODERED_DIR" + echo "Set NODERED_DIR environment variable or edit this script" + exit 1 +fi + +if [[ ! -f "$NODERED_DIR/Dockerfile" ]]; then + echo "ERROR: No Dockerfile found in $NODERED_DIR" + exit 1 +fi + +# 1. Copy nodes to nextcloud-node-red nodes directory +NODES_TARGET="$NODERED_DIR/nodes/nextcloud-ocs" +echo "Copying agent nodes to $NODES_TARGET..." +mkdir -p "$NODES_TARGET" + +cp "$SCRIPT_DIR/nodes"/*.js "$NODES_TARGET/" +cp "$SCRIPT_DIR/nodes"/*.html "$NODES_TARGET/" + +echo "Nodes copied." + +# 2. Patch Dockerfile to install agent CLI +DOCKERFILE="$NODERED_DIR/Dockerfile" +echo "Patching Dockerfile..." + +if grep -q "agent-cli" "$DOCKERFILE"; then + echo "Dockerfile already contains agent CLI installation" +else + cp "$DOCKERFILE" "$DOCKERFILE.bak.$(date +%s)" + + sed -i '/COPY nodes\//i\ +# Install agent CLI (adjust for your specific agent)\ +RUN npm install -g your-agent-cli@latest || \\\ + (curl -fsSL https://your-agent.example.com/install.sh | bash && \\\ + ln -sf ~/.agent/bin/agent /usr/local/bin/agent)\ +' "$DOCKERFILE" + + echo "Dockerfile patched with agent CLI installation (EDIT THE INSTALL COMMAND)." +fi + +# 3. Patch entrypoint.sh to include agent nodes +ENTRYPOINT="$NODERED_DIR/entrypoint.sh" +echo "Patching entrypoint.sh..." + +if [[ -f "$ENTRYPOINT" ]]; then + cp "$ENTRYPOINT" "$ENTRYPOINT.bak.$(date +%s)" + + if ! grep -q "node-red-contrib-nextcloud-agent" "$ENTRYPOINT"; then + sed -i '/copy nodes/a\ +# Install agent nodes\ +if [ -d "/opt/nextcloud-nodes/node-red-contrib-nextcloud-agent" ]; then \ + cp -r /opt/nextcloud-nodes/node-red-contrib-nextcloud-agent/* /data/node_modules/ 2>/dev/null || true \ +fi' "$ENTRYPOINT" + echo "entrypoint.sh patched." + else + echo "entrypoint.sh already contains agent nodes." + fi +else + echo "WARNING: entrypoint.sh not found at $ENTRYPOINT" +fi + +# 4. Create agent nodes package in /opt for entrypoint to copy +OPT_NODES_DIR="$NODERED_DIR/opt/nextcloud-nodes" +echo "Creating agent nodes package in $OPT_NODES_DIR..." +mkdir -p "$OPT_NODES_DIR/node-red-contrib-nextcloud-agent/nodes" +cp "$SCRIPT_DIR/nodes"/*.js "$OPT_NODES_DIR/node-red-contrib-nextcloud-agent/nodes/" +cp "$SCRIPT_DIR/nodes"/*.html "$OPT_NODES_DIR/node-red-contrib-nextcloud-agent/nodes/" +cp "$SCRIPT_DIR/package.json" "$OPT_NODES_DIR/node-red-contrib-nextcloud-agent/package.json" + +echo "Agent nodes package created." + +# 5. Update docker-compose.yml to add agent workspace volume +COMPOSE="$NODERED_DIR/docker-compose.yml" +if [[ -f "$COMPOSE" ]]; then + echo "Checking docker-compose.yml for agent workspace volume..." + if ! grep -q "agent-workspace" "$COMPOSE"; then + cp "$COMPOSE" "$COMPOSE.bak.$(date +%s)" + sed -i '/volumes:/a\ + - agent-workspace:/data/agent-workspace' "$COMPOSE" + sed -i '/^volumes:/a\ + agent-workspace:' "$COMPOSE" + echo "docker-compose.yml updated with agent-workspace volume." + else + echo "docker-compose.yml already has agent-workspace volume." + fi +fi + +echo "" +echo "=== Integration Complete ===" +echo "" +echo "IMPORTANT: Edit the Dockerfile to install YOUR specific agent CLI:" +echo " $NODERED_DIR/Dockerfile" +echo "" +echo "Next steps:" +echo "1. Rebuild the Docker image:" +echo " cd $NODERED_DIR && docker compose build --no-cache" +echo "" +echo "2. Restart the container:" +echo " docker compose up -d" +echo "" +echo "3. In Node-RED, configure an 'agent config' node with:" +echo " - CLI Command: agent (your installed agent binary)" +echo " - Workspace Directory: /data/agent-workspace" +echo " - API Endpoint: http://localhost:3000 (if using agent server mode)" +echo "" +echo "4. Import example flows from: $SCRIPT_DIR/flows/" \ No newline at end of file diff --git a/nodes/agent-config.html b/nodes/agent-config.html new file mode 100644 index 0000000..857c033 --- /dev/null +++ b/nodes/agent-config.html @@ -0,0 +1,56 @@ + + + \ No newline at end of file diff --git a/nodes/agent-config.js b/nodes/agent-config.js new file mode 100644 index 0000000..dbdc128 --- /dev/null +++ b/nodes/agent-config.js @@ -0,0 +1,20 @@ +'use strict'; + +module.exports = function (RED) { + function AgentConfigNode(n) { + RED.nodes.createNode(this, n); + this.cliPath = n.cliPath || 'agent'; + this.workspaceDir = n.workspaceDir || '/data/agent-workspace'; + this.apiEndpoint = (n.apiEndpoint || 'http://localhost:3000').replace(/\/$/, ''); + this.apiKey = this.credentials.apiKey || ''; + this.autoStart = n.autoStart !== false; + this.defaultArgs = n.defaultArgs || ''; + this.maxConcurrent = parseInt(n.maxConcurrent) || 3; + } + + RED.nodes.registerType('agent-config', AgentConfigNode, { + credentials: { + apiKey: { type: 'password' } + } + }); +}; \ No newline at end of file diff --git a/nodes/agent-exec.html b/nodes/agent-exec.html new file mode 100644 index 0000000..d700024 --- /dev/null +++ b/nodes/agent-exec.html @@ -0,0 +1,70 @@ + + + \ No newline at end of file diff --git a/nodes/agent-exec.js b/nodes/agent-exec.js new file mode 100644 index 0000000..c32b8df --- /dev/null +++ b/nodes/agent-exec.js @@ -0,0 +1,133 @@ +'use strict'; + +const { spawn } = require('child_process'); +const path = require('path'); + +module.exports = function (RED) { + function AgentExecNode(config) { + RED.nodes.createNode(this, config); + const node = this; + this.configNode = RED.nodes.getNode(config.agentConfig); + this.command = config.command || 'run'; + this.args = config.args || ''; + this.workingDir = config.workingDir || ''; + this.timeout = parseInt(config.timeout) || 120000; + this.outputFormat = config.outputFormat || 'text'; + + if (!this.configNode) { + node.error('No agent config node selected'); + return; + } + + node.on('input', async function (msg) { + const cfg = node.configNode; + const startTime = Date.now(); + + const cliCmd = cfg.cliPath || 'agent'; + const workspaceDir = msg.workingDir || node.workingDir || cfg.workspaceDir || '/data/agent-workspace'; + const command = msg.command || node.command; + const args = msg.args ? (Array.isArray(msg.args) ? msg.args : msg.args.split(' ').filter(Boolean)) : (node.args ? node.args.split(' ').filter(Boolean) : []); + const timeout = msg.timeout || node.timeout; + + const fullArgs = [command, ...args]; + if (command === 'run' && msg.prompt) { + fullArgs.push('--prompt', msg.prompt); + } + if (cfg.defaultArgs) { + fullArgs.unshift(...cfg.defaultArgs.split(' ').filter(Boolean)); + } + + node.status({ fill: 'blue', shape: 'dot', text: `Running: ${command}` }); + + try { + const result = await runAgent(cliCmd, fullArgs, { + cwd: workspaceDir, + timeout, + env: { + ...process.env, + AGENT_WORKSPACE: workspaceDir, + AGENT_API_ENDPOINT: cfg.apiEndpoint, + AGENT_API_KEY: cfg.apiKey || '' + } + }); + + const duration = Date.now() - startTime; + node.status({ fill: 'green', shape: 'dot', text: `Done (${duration}ms)` }); + + msg.payload = node.outputFormat === 'json' ? parseOutput(result.stdout) : result.stdout; + msg.stderr = result.stderr; + msg.exitCode = result.exitCode; + msg.duration = duration; + msg.command = `${cliCmd} ${fullArgs.join(' ')}`; + msg.workingDir = workspaceDir; + + node.send(msg); + } catch (err) { + const duration = Date.now() - startTime; + node.status({ fill: 'red', shape: 'ring', text: `Error (${duration}ms)` }); + msg.error = err.message; + msg.stderr = err.stderr || ''; + msg.exitCode = err.exitCode || -1; + msg.duration = duration; + node.error(`agent ${command} failed: ${err.message}`, msg); + } + }); + } + + function runAgent(cmd, args, options) { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + let exitCode = 0; + + const child = spawn(cmd, args, { + cwd: options.cwd, + env: options.env, + timeout: options.timeout, + windowsHide: true + }); + + child.stdout.on('data', (data) => { stdout += data.toString(); }); + child.stderr.on('data', (data) => { stderr += data.toString(); }); + + child.on('close', (code) => { + exitCode = code || 0; + if (exitCode === 0) { + resolve({ stdout, stderr, exitCode }); + } else { + const err = new Error(`Process exited with code ${exitCode}`); + err.exitCode = exitCode; + err.stderr = stderr; + reject(err); + } + }); + + child.on('error', (err) => { + err.exitCode = -1; + reject(err); + }); + + child.on('timeout', () => { + child.kill('SIGTERM'); + const err = new Error(`Process timed out after ${options.timeout}ms`); + err.exitCode = -2; + err.stderr = stderr; + reject(err); + }); + }); + } + + function parseOutput(text) { + try { + const trimmed = text.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + return JSON.parse(trimmed); + } + return text; + } catch { + return text; + } + } + + RED.nodes.registerType('agent-exec', AgentExecNode); +}; \ No newline at end of file diff --git a/nodes/agent-file.html b/nodes/agent-file.html new file mode 100644 index 0000000..140acf3 --- /dev/null +++ b/nodes/agent-file.html @@ -0,0 +1,79 @@ + + + \ No newline at end of file diff --git a/nodes/agent-file.js b/nodes/agent-file.js new file mode 100644 index 0000000..1516ccd --- /dev/null +++ b/nodes/agent-file.js @@ -0,0 +1,133 @@ +'use strict'; + +const fs = require('fs').promises; +const path = require('path'); + +module.exports = function (RED) { + function AgentFileNode(config) { + RED.nodes.createNode(this, config); + const node = this; + this.configNode = RED.nodes.getNode(config.agentConfig); + this.operation = config.operation || 'read'; + this.filePath = config.filePath || ''; + this.encoding = config.encoding || 'utf8'; + this.createDirs = config.createDirs !== false; + + if (!this.configNode) { + node.error('No agent config node selected'); + return; + } + + node.on('input', async function (msg) { + const cfg = node.configNode; + const workspaceDir = cfg.workspaceDir || '/data/agent-workspace'; + const operation = msg.operation || node.operation; + const filePath = msg.filePath || node.filePath; + const encoding = msg.encoding || node.encoding; + + if (!filePath) { + node.error('No file path specified', msg); + return; + } + + const fullPath = path.isAbsolute(filePath) + ? filePath + : path.join(workspaceDir, filePath); + + const resolvedWorkspace = path.resolve(workspaceDir); + const resolvedPath = path.resolve(fullPath); + if (!resolvedPath.startsWith(resolvedWorkspace)) { + node.error('Path traversal attempt blocked', msg); + return; + } + + node.status({ fill: 'blue', shape: 'dot', text: operation }); + + try { + let result; + + switch (operation) { + case 'read': + result = await fs.readFile(resolvedPath, encoding); + break; + + case 'write': + const content = msg.payload; + if (node.createDirs) { + await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); + } + await fs.writeFile(resolvedPath, content, encoding); + result = { written: true, path: filePath, bytes: Buffer.byteLength(content, encoding) }; + break; + + case 'append': + const appendContent = msg.payload; + if (node.createDirs) { + await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); + } + await fs.appendFile(resolvedPath, appendContent, encoding); + result = { appended: true, path: filePath, bytes: Buffer.byteLength(appendContent, encoding) }; + break; + + case 'delete': + await fs.unlink(resolvedPath); + result = { deleted: true, path: filePath }; + break; + + case 'exists': + try { + await fs.access(resolvedPath); + result = { exists: true, path: filePath }; + } catch { + result = { exists: false, path: filePath }; + } + break; + + case 'list': + const entries = await fs.readdir(resolvedPath, { withFileTypes: true }); + result = entries.map(e => ({ + name: e.name, + path: path.join(filePath, e.name), + isDirectory: e.isDirectory(), + isFile: e.isFile() + })); + break; + + case 'stat': + const stats = await fs.stat(resolvedPath); + result = { + path: filePath, + size: stats.size, + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + created: stats.birthtime, + modified: stats.mtime + }; + break; + + case 'mkdir': + await fs.mkdir(resolvedPath, { recursive: true }); + result = { created: true, path: filePath }; + break; + + default: + throw new Error(`Unknown operation: ${operation}`); + } + + node.status({ fill: 'green', shape: 'dot', text: 'Done' }); + msg.payload = result; + msg.filePath = filePath; + msg.operation = operation; + node.send(msg); + } catch (err) { + node.status({ fill: 'red', shape: 'ring', text: 'Error' }); + msg.error = err.message; + msg.filePath = filePath; + msg.operation = operation; + node.error(`File ${operation} failed: ${err.message}`, msg); + } + }); + } + + RED.nodes.registerType('agent-file', AgentFileNode); +}; \ No newline at end of file diff --git a/nodes/agent-process.html b/nodes/agent-process.html new file mode 100644 index 0000000..30a809d --- /dev/null +++ b/nodes/agent-process.html @@ -0,0 +1,83 @@ + + + \ No newline at end of file diff --git a/nodes/agent-process.js b/nodes/agent-process.js new file mode 100644 index 0000000..a8a28d1 --- /dev/null +++ b/nodes/agent-process.js @@ -0,0 +1,180 @@ +'use strict'; + +const { spawn } = require('child_process'); + +module.exports = function (RED) { + const agentSessions = new Map(); + + function AgentProcessNode(config) { + RED.nodes.createNode(this, config); + const node = this; + this.configNode = RED.nodes.getNode(config.agentConfig); + this.processId = config.processId || 'default'; + this.action = config.action || 'start'; + this.initialInput = config.initialInput || ''; + this.workingDir = config.workingDir || ''; + this.autoRestart = config.autoRestart !== false; + + if (!this.configNode) { + node.error('No agent config node selected'); + return; + } + + node.on('input', async function (msg) { + const cfg = node.configNode; + const action = msg.action || node.action; + const processId = msg.processId || node.processId; + const workspaceDir = msg.workingDir || node.workingDir || cfg.workspaceDir || '/data/agent-workspace'; + + try { + switch (action) { + case 'start': + await startProcess(node, cfg, processId, workspaceDir, msg); + break; + case 'stop': + await stopProcess(node, processId); + break; + case 'restart': + await stopProcess(node, processId); + await startProcess(node, cfg, processId, workspaceDir, msg); + break; + case 'send': + await sendToProcess(node, processId, msg.payload || msg.initialInput); + break; + case 'status': + await getProcessStatus(node, processId); + break; + default: + throw new Error(`Unknown action: ${action}`); + } + } catch (err) { + node.error(`Process ${action} failed: ${err.message}`, msg); + } + }); + + node.on('close', function (removed, done) { + if (removed) { + stopProcess(node, node.processId).finally(done); + } else { + done(); + } + }); + } + + async function startProcess(node, cfg, processId, workspaceDir, msg) { + if (agentSessions.has(processId)) { + node.warn(`Process ${processId} already running`); + msg.payload = { status: 'running', processId }; + node.send(msg); + return; + } + + const cliCmd = cfg.cliPath || 'agent'; + const initialInput = msg.initialInput || msg.payload || node.initialInput; + const defaultArgs = cfg.defaultArgs ? cfg.defaultArgs.split(' ').filter(Boolean) : []; + + const args = ['run', ...defaultArgs]; + if (initialInput) args.push('--input', initialInput); + + node.status({ fill: 'yellow', shape: 'dot', text: `Starting ${processId}...` }); + + const child = spawn(cliCmd, args, { + cwd: workspaceDir, + env: { + ...process.env, + AGENT_WORKSPACE: workspaceDir, + AGENT_API_ENDPOINT: cfg.apiEndpoint, + AGENT_API_KEY: cfg.apiKey || '' + }, + windowsHide: true + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { stdout += data.toString(); }); + child.stderr.on('data', (data) => { stderr += data.toString(); }); + + const session = { + id: processId, + process: child, + workspaceDir, + startTime: Date.now(), + stdout, + stderr + }; + + agentSessions.set(processId, session); + + child.on('close', (code) => { + agentSessions.delete(processId); + node.status({ fill: 'red', shape: 'ring', text: `Stopped (${code})` }); + node.send({ + payload: { status: 'stopped', processId, exitCode: code, stdout, stderr }, + processId + }); + }); + + child.on('error', (err) => { + agentSessions.delete(processId); + node.status({ fill: 'red', shape: 'ring', text: 'Error' }); + node.error(`Process ${processId} error: ${err.message}`); + }); + + await new Promise(r => setTimeout(r, 2000)); + + node.status({ fill: 'green', shape: 'dot', text: `Running: ${processId}` }); + msg.payload = { status: 'started', processId, pid: child.pid }; + msg.processId = processId; + node.send(msg); + } + + async function stopProcess(node, processId) { + const session = agentSessions.get(processId); + if (!session) { + node.warn(`Process ${processId} not found`); + return; + } + + session.process.kill('SIGTERM'); + await new Promise(r => setTimeout(r, 1000)); + if (!session.process.killed) { + session.process.kill('SIGKILL'); + } + } + + async function sendToProcess(node, processId, input) { + const session = agentSessions.get(processId); + if (!session) { + throw new Error(`Process ${processId} not running`); + } + + if (session.process.stdin.writable) { + session.process.stdin.write(input + '\n'); + } + + node.status({ fill: 'blue', shape: 'dot', text: `Sent to ${processId}` }); + node.send({ payload: { sent: true, processId, input }, processId }); + } + + async function getProcessStatus(node, processId) { + const session = agentSessions.get(processId); + if (!session) { + node.send({ payload: { status: 'not running', processId }, processId }); + return; + } + + node.send({ + payload: { + status: 'running', + processId, + pid: session.process.pid, + uptime: Date.now() - session.startTime, + workspaceDir: session.workspaceDir + }, + processId + }); + } + + RED.nodes.registerType('agent-process', AgentProcessNode); +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..6ac906a --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "node-red-contrib-nextcloud-agent", + "version": "1.0.0", + "description": "Node-RED nodes for CLI agent integration with Nextcloud", + "license": "MIT", + "author": "hannesahlin", + "keywords": [ + "node-red", + "nextcloud", + "cli", + "agent", + "automation" + ], + "node-red": { + "version": ">=3.0.0", + "nodes": { + "agent-config": "nodes/agent-config.js", + "agent-exec": "nodes/agent-exec.js", + "agent-file": "nodes/agent-file.js", + "agent-process": "nodes/agent-process.js" + } + }, + "dependencies": { + "ws": "^8.16.0" + }, + "engines": { + "node": ">=18" + } +} \ No newline at end of file