Initial release: generic CLI agent nodes for Nextcloud Node-RED
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
@@ -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/"
|
||||
@@ -0,0 +1,56 @@
|
||||
<script type="text/html" data-template-name="agent-config">
|
||||
<div class="form-row">
|
||||
<label for="node-input-cliPath"><i class="fa fa-terminal"></i> CLI Command</label>
|
||||
<input type="text" id="node-input-cliPath" placeholder="agent (or full path like /usr/local/bin/agent)">
|
||||
<span class="help-text">Command or full path to the CLI agent binary. Must be in PATH or absolute path.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-workspaceDir"><i class="fa fa-folder"></i> Workspace Directory</label>
|
||||
<input type="text" id="node-input-workspaceDir" placeholder="/data/agent-workspace">
|
||||
<span class="help-text">Directory where the agent will operate. Should be accessible from Node-RED container.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-apiEndpoint"><i class="fa fa-server"></i> API Endpoint</label>
|
||||
<input type="text" id="node-input-apiEndpoint" placeholder="http://localhost:3000">
|
||||
<span class="help-text">Agent REST API endpoint (if running in server mode).</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-apiKey"><i class="fa fa-key"></i> API Key</label>
|
||||
<input type="password" id="node-input-apiKey" placeholder="Optional API key for agent server">
|
||||
<span class="help-text">API key if agent server requires authentication.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-defaultArgs"><i class="fa fa-list"></i> Default Arguments</label>
|
||||
<input type="text" id="node-input-defaultArgs" placeholder="--verbose --model default">
|
||||
<span class="help-text">Default arguments passed to the agent (space-separated).</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-maxConcurrent"><i class="fa fa-tasks"></i> Max Concurrent Processes</label>
|
||||
<input type="number" id="node-input-maxConcurrent" min="1" max="10" value="3" style="width: 80px;">
|
||||
<span class="help-text">Maximum number of concurrent agent processes.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-autoStart"><i class="fa fa-play"></i> Auto-start Process</label>
|
||||
<input type="checkbox" id="node-input-autoStart" checked>
|
||||
<span class="help-text">Automatically start agent process when node receives input.</span>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-help-name="agent-config">
|
||||
<p>Configuration node for CLI agent integration.</p>
|
||||
<p>This node stores connection settings for a CLI agent. Other agent nodes reference this config.</p>
|
||||
<h3>Settings</h3>
|
||||
<ul>
|
||||
<li><b>CLI Command</b> - Command or full path to the agent binary. Default: <code>agent</code> (must be in PATH).</li>
|
||||
<li><b>Workspace Directory</b> - Directory where agent will read/write files. Should be a shared volume accessible from both Node-RED and agent.</li>
|
||||
<li><b>API Endpoint</b> - If running agent in server mode, the REST API endpoint.</li>
|
||||
<li><b>API Key</b> - Optional authentication key for agent server mode.</li>
|
||||
<li><b>Default Arguments</b> - Default arguments passed to the agent.</li>
|
||||
<li><b>Max Concurrent Processes</b> - Limit concurrent agent processes.</li>
|
||||
<li><b>Auto-start Process</b> - Start agent automatically on input.</li>
|
||||
</ul>
|
||||
<h3>Docker Setup</h3>
|
||||
<p>When running in Docker, ensure the workspace directory is mounted as a volume in both Node-RED and agent containers:</p>
|
||||
<pre><code>volumes:
|
||||
- agent-workspace:/data/agent-workspace</code></pre>
|
||||
</script>
|
||||
@@ -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' }
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
<script type="text/html" data-template-name="agent-exec">
|
||||
<div class="form-row">
|
||||
<label for="node-input-agentConfig"><i class="fa fa-cog"></i> Agent Config</label>
|
||||
<input type="text" id="node-input-agentConfig" class="node-config-input" placeholder="Select agent config node">
|
||||
<span class="help-text">Select the agent configuration node.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-command"><i class="fa fa-terminal"></i> Command</label>
|
||||
<select id="node-input-command">
|
||||
<option value="run">run - Execute a task</option>
|
||||
<option value="version">version - Show version</option>
|
||||
<option value="config">config - Show configuration</option>
|
||||
<option value="help">help - Show help</option>
|
||||
</select>
|
||||
<span class="help-text">Agent subcommand to execute.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-args"><i class="fa fa-list"></i> Arguments</label>
|
||||
<input type="text" id="node-input-args" placeholder="--verbose --output json">
|
||||
<span class="help-text">Additional arguments (space-separated). Can be overridden by msg.args.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-workingDir"><i class="fa fa-folder"></i> Working Directory</label>
|
||||
<input type="text" id="node-input-workingDir" placeholder="/data/agent-workspace">
|
||||
<span class="help-text">Working directory for agent. Overrides config. Can be overridden by msg.workingDir.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-timeout"><i class="fa fa-clock"></i> Timeout (ms)</label>
|
||||
<input type="number" id="node-input-timeout" min="5000" max="600000" value="120000" style="width: 100px;">
|
||||
<span class="help-text">Maximum execution time in milliseconds.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-outputFormat"><i class="fa fa-code"></i> Output Format</label>
|
||||
<select id="node-input-outputFormat">
|
||||
<option value="text">Text (raw stdout)</option>
|
||||
<option value="json">JSON (parse stdout as JSON)</option>
|
||||
</select>
|
||||
<span class="help-text">How to parse the output.</span>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-help-name="agent-exec">
|
||||
<p>Execute CLI agent commands from Node-RED flows.</p>
|
||||
<h3>Usage</h3>
|
||||
<p>This node runs agent commands and returns the output. The most common use is <code>agent run</code> with a prompt/input.</p>
|
||||
<h3>Input Message Properties</h3>
|
||||
<ul>
|
||||
<li><code>msg.command</code> - Override the command (e.g., "run", "version")</li>
|
||||
<li><code>msg.args</code> - Override arguments (array or space-separated string)</li>
|
||||
<li><code>msg.prompt</code> - Input text for <code>run</code> command</li>
|
||||
<li><code>msg.workingDir</code> - Override working directory</li>
|
||||
<li><code>msg.timeout</code> - Override timeout in ms</li>
|
||||
</ul>
|
||||
<h3>Output Message Properties</h3>
|
||||
<ul>
|
||||
<li><code>msg.payload</code> - Command output (text or parsed JSON)</li>
|
||||
<li><code>msg.stderr</code> - Standard error output</li>
|
||||
<li><code>msg.exitCode</code> - Process exit code</li>
|
||||
<li><code>msg.duration</code> - Execution time in milliseconds</li>
|
||||
<li><code>msg.command</code> - Full command that was executed</li>
|
||||
<li><code>msg.workingDir</code> - Working directory used</li>
|
||||
<li><code>msg.error</code> - Error message if failed</li>
|
||||
</ul>
|
||||
<h3>Example Flow</h3>
|
||||
<pre><code>// Inject node with:
|
||||
msg.payload = "Process the files in /data/input and generate a report";
|
||||
msg.prompt = msg.payload;
|
||||
return msg;</code></pre>
|
||||
<p>Connect to agent-exec with command "run" to execute the task.</p>
|
||||
</script>
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
<script type="text/html" data-template-name="agent-file">
|
||||
<div class="form-row">
|
||||
<label for="node-input-agentConfig"><i class="fa fa-cog"></i> Agent Config</label>
|
||||
<input type="text" id="node-input-agentConfig" class="node-config-input" placeholder="Select agent config node">
|
||||
<span class="help-text">Select the agent configuration node (provides workspace directory).</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-operation"><i class="fa fa-tasks"></i> Operation</label>
|
||||
<select id="node-input-operation">
|
||||
<option value="read">Read File</option>
|
||||
<option value="write">Write File</option>
|
||||
<option value="append">Append to File</option>
|
||||
<option value="delete">Delete File</option>
|
||||
<option value="exists">Check Exists</option>
|
||||
<option value="list">List Directory</option>
|
||||
<option value="stat">File Stats</option>
|
||||
<option value="mkdir">Create Directory</option>
|
||||
</select>
|
||||
<span class="help-text">File operation to perform.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-filePath"><i class="fa fa-file"></i> File Path</label>
|
||||
<input type="text" id="node-input-filePath" placeholder="output/result.txt or /data/agent-workspace/output/result.txt">
|
||||
<span class="help-text">Path relative to workspace directory, or absolute path. Can be overridden by msg.filePath.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-encoding"><i class="fa fa-font"></i> Encoding</label>
|
||||
<select id="node-input-encoding">
|
||||
<option value="utf8">UTF-8</option>
|
||||
<option value="ascii">ASCII</option>
|
||||
<option value="latin1">Latin1</option>
|
||||
<option value="base64">Base64</option>
|
||||
<option value="hex">Hex</option>
|
||||
</select>
|
||||
<span class="help-text">Text encoding for read/write operations.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-createDirs"><i class="fa fa-folder-plus"></i> Create Parent Directories</label>
|
||||
<input type="checkbox" id="node-input-createDirs" checked>
|
||||
<span class="help-text">Automatically create parent directories for write/append operations.</span>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-help-name="agent-file">
|
||||
<p>Read, write, and manage files in the agent workspace directory.</p>
|
||||
<h3>Operations</h3>
|
||||
<ul>
|
||||
<li><b>Read</b> - Returns file content as string (or Buffer for binary encodings)</li>
|
||||
<li><b>Write</b> - Creates/overwrites file with msg.payload content</li>
|
||||
<li><b>Append</b> - Appends msg.payload to existing file</li>
|
||||
<li><b>Delete</b> - Removes file</li>
|
||||
<li><b>Exists</b> - Returns {exists: true/false}</li>
|
||||
<li><b>List</b> - Returns array of directory entries with name, path, isDirectory, isFile</li>
|
||||
<li><b>Stat</b> - Returns file stats (size, timestamps, type)</li>
|
||||
<li><b>Mkdir</b> - Creates directory recursively</li>
|
||||
</ul>
|
||||
<h3>Input Message Properties</h3>
|
||||
<ul>
|
||||
<li><code>msg.operation</code> - Override operation</li>
|
||||
<li><code>msg.filePath</code> - Override file path</li>
|
||||
<li><code>msg.payload</code> - Content for write/append operations</li>
|
||||
<li><code>msg.encoding</code> - Override encoding</li>
|
||||
</ul>
|
||||
<h3>Output Message Properties</h3>
|
||||
<ul>
|
||||
<li><code>msg.payload</code> - Operation result (content, stats, or status object)</li>
|
||||
<li><code>msg.filePath</code> - File path used</li>
|
||||
<li><code>msg.operation</code> - Operation performed</li>
|
||||
<li><code>msg.error</code> - Error message if failed</li>
|
||||
</ul>
|
||||
<h3>Security</h3>
|
||||
<p>Paths are restricted to the configured workspace directory. Path traversal attempts are blocked.</p>
|
||||
<h3>Example: Write agent output to file</h3>
|
||||
<pre><code>// After agent-exec generates output:
|
||||
msg.filePath = "results/output.txt";
|
||||
msg.payload = msg.payload; // output from agent-exec
|
||||
msg.operation = "write";
|
||||
return msg;</code></pre>
|
||||
</script>
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
<script type="text/html" data-template-name="agent-process">
|
||||
<div class="form-row">
|
||||
<label for="node-input-agentConfig"><i class="fa fa-cog"></i> Agent Config</label>
|
||||
<input type="text" id="node-input-agentConfig" class="node-config-input" placeholder="Select agent config node">
|
||||
<span class="help-text">Select the agent configuration node.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-processId"><i class="fa fa-id-badge"></i> Process ID</label>
|
||||
<input type="text" id="node-input-processId" placeholder="default">
|
||||
<span class="help-text">Unique identifier for this process instance.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-action"><i class="fa fa-play"></i> Action</label>
|
||||
<select id="node-input-action">
|
||||
<option value="start">Start Process</option>
|
||||
<option value="stop">Stop Process</option>
|
||||
<option value="restart">Restart Process</option>
|
||||
<option value="send">Send Input</option>
|
||||
<option value="status">Get Status</option>
|
||||
</select>
|
||||
<span class="help-text">Action to perform on the process.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-initialInput"><i class="fa fa-comment"></i> Initial Input</label>
|
||||
<input type="text" id="node-input-initialInput" placeholder="Process the files in /data/input...">
|
||||
<span class="help-text">Initial input for the process (used on start). Can be overridden by msg.payload or msg.initialInput.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-workingDir"><i class="fa fa-folder"></i> Working Directory</label>
|
||||
<input type="text" id="node-input-workingDir" placeholder="/data/agent-workspace">
|
||||
<span class="help-text">Workspace directory for this process. Overrides config.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-autoRestart"><i class="fa fa-redo"></i> Auto Restart</label>
|
||||
<input type="checkbox" id="node-input-autoRestart" checked>
|
||||
<span class="help-text">Automatically restart process if it crashes.</span>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-help-name="agent-process">
|
||||
<p>Manage persistent CLI agent processes.</p>
|
||||
<h3>Actions</h3>
|
||||
<ul>
|
||||
<li><b>Start</b> - Launch a new persistent agent process</li>
|
||||
<li><b>Stop</b> - Gracefully terminate the process</li>
|
||||
<li><b>Restart</b> - Stop and start the process</li>
|
||||
<li><b>Send</b> - Send input to a running process via stdin</li>
|
||||
<li><b>Status</b> - Get current process status (running, pid, uptime)</li>
|
||||
</ul>
|
||||
<h3>Input Message Properties</h3>
|
||||
<ul>
|
||||
<li><code>msg.action</code> - Override action</li>
|
||||
<li><code>msg.processId</code> - Override process ID</li>
|
||||
<li><code>msg.payload</code> - Input for start/send actions</li>
|
||||
<li><code>msg.initialInput</code> - Alternative to msg.payload for start action</li>
|
||||
<li><code>msg.workingDir</code> - Override workspace directory</li>
|
||||
</ul>
|
||||
<h3>Output Message Properties</h3>
|
||||
<ul>
|
||||
<li><code>msg.payload</code> - Status object with process info</li>
|
||||
<li><code>msg.processId</code> - Process ID</li>
|
||||
<li><code>msg.error</code> - Error message if failed</li>
|
||||
</ul>
|
||||
<h3>Process Lifecycle</h3>
|
||||
<p>Processes run as persistent child processes. They maintain state across interactions. Use <b>Send</b> action to continue interactions with a running process.</p>
|
||||
<h3>Example: Continuous processing session</h3>
|
||||
<pre><code>// Start process with initial context
|
||||
msg.action = "start";
|
||||
msg.processId = "data-processor";
|
||||
msg.payload = "Watch /data/input for new files and process them";
|
||||
return msg;
|
||||
|
||||
// Later, send follow-up commands
|
||||
msg.action = "send";
|
||||
msg.processId = "data-processor";
|
||||
msg.payload = "Now also generate summary report";
|
||||
return msg;
|
||||
|
||||
// Check status
|
||||
msg.action = "status";
|
||||
msg.processId = "data-processor";
|
||||
return msg;</code></pre>
|
||||
</script>
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user