unifi-mcp-worker 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +210 -0
- package/bin/cli.mjs +74 -0
- package/install.sh +44 -0
- package/package.json +37 -0
- package/src/commands/add-location.mjs +92 -0
- package/src/commands/destroy.mjs +75 -0
- package/src/commands/install.mjs +149 -0
- package/src/commands/rotate-tokens.mjs +114 -0
- package/src/commands/status.mjs +42 -0
- package/src/commands/upgrade.mjs +108 -0
- package/src/lib/api.mjs +46 -0
- package/src/lib/config.mjs +37 -0
- package/src/lib/display.mjs +82 -0
- package/src/lib/prerequisites.mjs +50 -0
- package/src/lib/tokens.mjs +5 -0
- package/src/lib/wrangler.mjs +69 -0
- package/worker/package-lock.json +3156 -0
- package/worker/package.json +18 -0
- package/worker/src/auth.ts +83 -0
- package/worker/src/index.ts +93 -0
- package/worker/src/mcp-handler.ts +66 -0
- package/worker/src/relay-object.ts +1003 -0
- package/worker/src/types.ts +166 -0
- package/worker/tsconfig.json +17 -0
- package/worker/vitest.config.ts +7 -0
- package/worker/wrangler.toml +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# UniFi MCP Relay Worker
|
|
2
|
+
|
|
3
|
+
A Cloudflare Worker that enables cloud agents to access locally-hosted UniFi MCP servers via a secure relay gateway.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## How It Works
|
|
8
|
+
|
|
9
|
+
Cloud agents communicate with your local UniFi MCP server through a two-leg relay:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
Cloud Agent (Claude, n8n, etc.)
|
|
13
|
+
| HTTPS POST /mcp (Bearer AGENT_TOKEN)
|
|
14
|
+
v
|
|
15
|
+
Cloudflare Worker ────────────────────────────────── Durable Object (RelayObject)
|
|
16
|
+
|
|
|
17
|
+
WebSocket (WSS) /ws (relay_token)
|
|
18
|
+
|
|
|
19
|
+
unifi-mcp-relay (local network)
|
|
20
|
+
|
|
|
21
|
+
UniFi MCP Server (stdio/HTTP)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The Durable Object persists location registrations in SQLite and maintains live WebSocket connections to one or more relay clients. When a tool call arrives from a cloud agent, the worker routes it to the appropriate relay client and streams the result back.
|
|
25
|
+
|
|
26
|
+
**Multi-location support:** Multiple relay clients can connect simultaneously, each representing a different physical location (home lab, branch office, customer site). Read-only tool calls are automatically fanned out to all connected locations and results are aggregated. Write operations require an explicit `__location` argument to target a specific site — useful for MSP workflows.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
### Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
curl -fsSL https://raw.githubusercontent.com/sirkirby/unifi-mcp-worker/main/install.sh | bash
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or install the CLI directly:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install -g unifi-mcp-worker
|
|
42
|
+
unifi-mcp-worker install
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The CLI will:
|
|
46
|
+
1. Check prerequisites (Node.js, Wrangler)
|
|
47
|
+
2. Deploy the worker to your Cloudflare account
|
|
48
|
+
3. Generate and set authentication tokens
|
|
49
|
+
4. Display your tokens and next steps
|
|
50
|
+
|
|
51
|
+
### Upgrade
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
unifi-mcp-worker upgrade
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Add a Location
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
unifi-mcp-worker add-location
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Manage Tokens
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
unifi-mcp-worker rotate-tokens
|
|
67
|
+
unifi-mcp-worker status
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Remove
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
unifi-mcp-worker destroy
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Configuration Reference
|
|
79
|
+
|
|
80
|
+
### Secrets
|
|
81
|
+
|
|
82
|
+
| Secret | Purpose |
|
|
83
|
+
|--------|---------|
|
|
84
|
+
| `AGENT_TOKEN` | Bearer token required by cloud agents calling the `/mcp` endpoint |
|
|
85
|
+
| `ADMIN_TOKEN` | Bearer token required for admin API calls (`/api/*`) |
|
|
86
|
+
|
|
87
|
+
Secrets are set automatically by the CLI and can be rotated with `unifi-mcp-worker rotate-tokens`.
|
|
88
|
+
|
|
89
|
+
### Environment Variables
|
|
90
|
+
|
|
91
|
+
| Variable | Default | Purpose |
|
|
92
|
+
|----------|---------|---------|
|
|
93
|
+
| `TOOL_REGISTRATION_MODE` | `lazy` | Tool registration mode sent to relay clients. `lazy` registers only meta-tools (~200 tokens); `eager` registers all tools upfront (~5,000 tokens) |
|
|
94
|
+
|
|
95
|
+
Variables are set in `wrangler.toml` under `[vars]` or overridden via the Cloudflare dashboard.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## API Reference
|
|
100
|
+
|
|
101
|
+
| Route | Method | Auth | Purpose |
|
|
102
|
+
|-------|--------|------|---------|
|
|
103
|
+
| `/health` | GET | None | Health check — returns `{"status":"ok"}` |
|
|
104
|
+
| `/mcp` | POST | `AGENT_TOKEN` | MCP JSON-RPC endpoint for cloud agents |
|
|
105
|
+
| `/ws` | GET | `relay_token` | WebSocket upgrade for relay client connections |
|
|
106
|
+
| `/api/locations` | GET | `ADMIN_TOKEN` | List registered locations and connection status |
|
|
107
|
+
| `/api/locations/token` | POST | `ADMIN_TOKEN` | Generate a relay token for a new location |
|
|
108
|
+
|
|
109
|
+
### MCP Endpoint
|
|
110
|
+
|
|
111
|
+
The `/mcp` endpoint accepts standard MCP JSON-RPC requests:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
curl -X POST https://your-worker.workers.dev/mcp \
|
|
115
|
+
-H "Authorization: Bearer <agent-token>" \
|
|
116
|
+
-H "Content-Type: application/json" \
|
|
117
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Built-in Meta-Tools
|
|
121
|
+
|
|
122
|
+
The relay always exposes three meta-tools regardless of registration mode:
|
|
123
|
+
|
|
124
|
+
| Tool | Purpose |
|
|
125
|
+
|------|---------|
|
|
126
|
+
| `unifi_tool_index` | List all available UniFi tools with optional category or search filter |
|
|
127
|
+
| `unifi_execute` | Execute any UniFi tool by name; use `__location` to target a specific site |
|
|
128
|
+
| `unifi_batch` | Execute multiple UniFi tools in a single request |
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Token Management
|
|
133
|
+
|
|
134
|
+
The CLI manages tokens automatically during `install` and `rotate-tokens`. You can also manage tokens manually via the admin API:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
curl -X POST https://your-worker.workers.dev/api/locations/token \
|
|
138
|
+
-H "Authorization: Bearer <admin-token>" \
|
|
139
|
+
-H "Content-Type: application/json" \
|
|
140
|
+
-d '{"location_name": "Home Lab"}'
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Response:
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{
|
|
147
|
+
"location_id": "a1b2c3d4-...",
|
|
148
|
+
"location_name": "Home Lab",
|
|
149
|
+
"token": "<relay-token>"
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Store the `token` value securely — it is only returned once. Provide it to `unifi-mcp-relay` as `UNIFI_MCP_RELAY_TOKEN` (see [unifi-mcp](https://github.com/sirkirby/unifi-mcp) for relay client configuration).
|
|
154
|
+
|
|
155
|
+
### List Registered Locations
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
unifi-mcp-worker status
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Or via the admin API:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
curl https://your-worker.workers.dev/api/locations \
|
|
165
|
+
-H "Authorization: Bearer <admin-token>"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Response includes `connected: true/false` indicating whether the relay client WebSocket is currently active.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Connecting Cloud Agents
|
|
173
|
+
|
|
174
|
+
Point any MCP-compatible client at the worker URL:
|
|
175
|
+
|
|
176
|
+
- **Endpoint:** `https://your-worker.workers.dev/mcp`
|
|
177
|
+
- **Auth:** `Authorization: Bearer <agent-token>`
|
|
178
|
+
- **Transport:** HTTP POST (standard MCP JSON-RPC)
|
|
179
|
+
|
|
180
|
+
Compatible clients include Claude connectors, ChatGPT plugins, n8n MCP nodes, and any platform that supports the MCP protocol over HTTP.
|
|
181
|
+
|
|
182
|
+
### Multi-Location Writes
|
|
183
|
+
|
|
184
|
+
When multiple relay clients are connected, write operations require a `__location` argument passed to `unifi_execute`:
|
|
185
|
+
|
|
186
|
+
```json
|
|
187
|
+
{
|
|
188
|
+
"tool_name": "block_client",
|
|
189
|
+
"arguments": { "mac": "aa:bb:cc:dd:ee:ff" },
|
|
190
|
+
"__location": "a1b2c3d4-..."
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Read-only operations (tools with `readOnlyHint: true`) are automatically fanned out to all connected locations and results are returned as an aggregated response.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Security
|
|
199
|
+
|
|
200
|
+
- **Timing-safe token comparison** — all token validation uses constant-time comparison to prevent timing attacks
|
|
201
|
+
- **SHA-256 hashed storage** — relay tokens are stored as hashes in SQLite; the plaintext is never persisted
|
|
202
|
+
- **Per-location isolation** — each relay client is identified by its own relay token and location ID; locations cannot access each other's data
|
|
203
|
+
- **HTTPS/WSS transport** — all traffic is encrypted in transit; Cloudflare terminates TLS at the edge
|
|
204
|
+
- **No credentials in config** — tokens and secrets are managed via the CLI or `wrangler secret put`, never committed to source
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Related
|
|
209
|
+
|
|
210
|
+
- [unifi-mcp](https://github.com/sirkirby/unifi-mcp) — UniFi MCP server and `unifi-mcp-relay` client that connects to this relay worker
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
|
|
5
|
+
const COMMANDS = {
|
|
6
|
+
install: () => import("../src/commands/install.mjs"),
|
|
7
|
+
upgrade: () => import("../src/commands/upgrade.mjs"),
|
|
8
|
+
"add-location": () => import("../src/commands/add-location.mjs"),
|
|
9
|
+
"rotate-tokens": () => import("../src/commands/rotate-tokens.mjs"),
|
|
10
|
+
status: () => import("../src/commands/status.mjs"),
|
|
11
|
+
destroy: () => import("../src/commands/destroy.mjs"),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const { values, positionals } = parseArgs({
|
|
15
|
+
allowPositionals: true,
|
|
16
|
+
options: {
|
|
17
|
+
version: { type: "boolean", short: "v" },
|
|
18
|
+
help: { type: "boolean", short: "h" },
|
|
19
|
+
"non-interactive": { type: "boolean" },
|
|
20
|
+
yes: { type: "boolean", short: "y" },
|
|
21
|
+
"worker-name": { type: "string" },
|
|
22
|
+
"location-name": { type: "string" },
|
|
23
|
+
"worker-url": { type: "string" },
|
|
24
|
+
"admin-token": { type: "string" },
|
|
25
|
+
token: { type: "string" },
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (values.version) {
|
|
30
|
+
const { readFileSync } = await import("node:fs");
|
|
31
|
+
const { fileURLToPath } = await import("node:url");
|
|
32
|
+
const { join, dirname } = await import("node:path");
|
|
33
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
34
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
35
|
+
console.log(pkg.version);
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const command = positionals[0];
|
|
40
|
+
|
|
41
|
+
if (values.help || !command) {
|
|
42
|
+
console.log(`
|
|
43
|
+
Usage: unifi-mcp-worker <command> [options]
|
|
44
|
+
|
|
45
|
+
Commands:
|
|
46
|
+
install Deploy the Cloudflare Worker and generate tokens
|
|
47
|
+
upgrade Redeploy the latest worker code
|
|
48
|
+
add-location Add a new location (generates a relay token)
|
|
49
|
+
rotate-tokens Rotate agent, admin, or relay tokens
|
|
50
|
+
status Show deployment info and token summary
|
|
51
|
+
destroy Remove the worker and clean up
|
|
52
|
+
|
|
53
|
+
Options:
|
|
54
|
+
--version, -v Show CLI version
|
|
55
|
+
--help, -h Show this help
|
|
56
|
+
--non-interactive Disable interactive prompts (requires flags for all values)
|
|
57
|
+
--yes, -y Skip confirmation prompts
|
|
58
|
+
--worker-name <name> Worker name (default: unifi-mcp-relay)
|
|
59
|
+
--location-name <name> Location name (default: Home Lab)
|
|
60
|
+
--worker-url <url> Worker URL (for add-location, rotate-tokens)
|
|
61
|
+
--admin-token <token> Admin token override
|
|
62
|
+
--token <type> Token to rotate: agent, admin, relay, all
|
|
63
|
+
`);
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!COMMANDS[command]) {
|
|
68
|
+
console.error(`Unknown command: ${command}`);
|
|
69
|
+
console.error(`Run 'unifi-mcp-worker --help' for available commands.`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const mod = await COMMANDS[command]();
|
|
74
|
+
await mod.run(values);
|
package/install.sh
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
COMMAND="${1:-install}"
|
|
5
|
+
|
|
6
|
+
echo ""
|
|
7
|
+
echo " UniFi MCP Worker CLI"
|
|
8
|
+
echo ""
|
|
9
|
+
|
|
10
|
+
# Check Node.js
|
|
11
|
+
if ! command -v node &>/dev/null; then
|
|
12
|
+
echo " Error: Node.js is required but not installed."
|
|
13
|
+
echo " Install it from https://nodejs.org/ or via your package manager:"
|
|
14
|
+
echo " brew install node (macOS)"
|
|
15
|
+
echo " apt install nodejs (Debian/Ubuntu)"
|
|
16
|
+
echo " winget install Volta (Windows)"
|
|
17
|
+
echo ""
|
|
18
|
+
exit 1
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
# Check minimum Node version (18+)
|
|
22
|
+
NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
|
|
23
|
+
if [ "$NODE_VERSION" -lt 18 ]; then
|
|
24
|
+
echo " Error: Node.js 18+ required. Found: $(node -v)"
|
|
25
|
+
echo " Update from https://nodejs.org/"
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
echo " Node.js $(node -v) detected."
|
|
30
|
+
|
|
31
|
+
# Install or update the CLI
|
|
32
|
+
if command -v unifi-mcp-worker &>/dev/null; then
|
|
33
|
+
echo " Updating unifi-mcp-worker CLI..."
|
|
34
|
+
npm install -g unifi-mcp-worker@latest --silent 2>/dev/null || npm install -g unifi-mcp-worker@latest
|
|
35
|
+
else
|
|
36
|
+
echo " Installing unifi-mcp-worker CLI..."
|
|
37
|
+
npm install -g unifi-mcp-worker --silent 2>/dev/null || npm install -g unifi-mcp-worker
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
echo ""
|
|
41
|
+
|
|
42
|
+
# Run the requested command, passing through any additional args
|
|
43
|
+
shift 2>/dev/null || true
|
|
44
|
+
unifi-mcp-worker "$COMMAND" "$@"
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "unifi-mcp-worker",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI to deploy and manage the UniFi MCP Cloudflare Worker relay",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"unifi-mcp-worker": "./bin/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"worker/src/",
|
|
13
|
+
"worker/package.json",
|
|
14
|
+
"worker/package-lock.json",
|
|
15
|
+
"worker/wrangler.toml",
|
|
16
|
+
"worker/tsconfig.json",
|
|
17
|
+
"worker/vitest.config.ts",
|
|
18
|
+
"install.sh"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test test/cli/*.test.mjs",
|
|
22
|
+
"test:worker": "cd worker && npm test",
|
|
23
|
+
"test:all": "npm test && npm run test:worker"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"prompts": "^2.4.2",
|
|
27
|
+
"chalk": "^5.4.1"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18.0.0"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/sirkirby/unifi-mcp-worker.git"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// src/commands/add-location.mjs
|
|
2
|
+
import prompts from "prompts";
|
|
3
|
+
import { loadConfig, saveConfig } from "../lib/config.mjs";
|
|
4
|
+
import { healthCheck, createLocationToken } from "../lib/api.mjs";
|
|
5
|
+
import { showAddLocationSuccess, showError } from "../lib/display.mjs";
|
|
6
|
+
|
|
7
|
+
export async function run(flags) {
|
|
8
|
+
const config = loadConfig();
|
|
9
|
+
if (!config) {
|
|
10
|
+
showError("No deployment found. Run 'unifi-mcp-worker install' first.");
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let locationName, workerUrl, adminToken;
|
|
15
|
+
|
|
16
|
+
if (flags["non-interactive"]) {
|
|
17
|
+
locationName = flags["location-name"];
|
|
18
|
+
workerUrl = flags["worker-url"] || config.worker_url;
|
|
19
|
+
adminToken = flags["admin-token"] || config.admin_token;
|
|
20
|
+
if (!locationName) {
|
|
21
|
+
showError("--location-name is required in non-interactive mode.");
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
const answers = await prompts([
|
|
26
|
+
{
|
|
27
|
+
type: "text",
|
|
28
|
+
name: "locationName",
|
|
29
|
+
message: "Location name",
|
|
30
|
+
validate: (v) => (v ? true : "Location name is required"),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
type: "text",
|
|
34
|
+
name: "workerUrl",
|
|
35
|
+
message: "Worker URL",
|
|
36
|
+
initial: config.worker_url,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: "text",
|
|
40
|
+
name: "adminToken",
|
|
41
|
+
message: "Admin token",
|
|
42
|
+
initial: config.admin_token,
|
|
43
|
+
},
|
|
44
|
+
]);
|
|
45
|
+
locationName = answers.locationName;
|
|
46
|
+
workerUrl = answers.workerUrl;
|
|
47
|
+
adminToken = answers.adminToken;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log("\nChecking worker health...");
|
|
51
|
+
const health = await healthCheck(workerUrl);
|
|
52
|
+
if (!health.ok) {
|
|
53
|
+
showError(`Worker not reachable at ${workerUrl}`);
|
|
54
|
+
console.error(" Check that:");
|
|
55
|
+
console.error(" - The URL is correct");
|
|
56
|
+
console.error(" - The worker is deployed (run 'unifi-mcp-worker status')");
|
|
57
|
+
console.error(" - Cloudflare is not experiencing issues");
|
|
58
|
+
if (health.error) console.error(` Error: ${health.error}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
console.log(`Creating location "${locationName}"...`);
|
|
64
|
+
const loc = await createLocationToken(workerUrl, adminToken, locationName);
|
|
65
|
+
|
|
66
|
+
const existingIdx = config.locations.findIndex(
|
|
67
|
+
(l) => l.location_name === locationName
|
|
68
|
+
);
|
|
69
|
+
if (existingIdx >= 0) {
|
|
70
|
+
config.locations[existingIdx].relay_token = loc.relayToken;
|
|
71
|
+
config.locations[existingIdx].location_id = loc.locationId;
|
|
72
|
+
} else {
|
|
73
|
+
config.locations.push({
|
|
74
|
+
location_name: locationName,
|
|
75
|
+
relay_token: loc.relayToken,
|
|
76
|
+
location_id: loc.locationId,
|
|
77
|
+
created_at: new Date().toISOString(),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
saveConfig(config);
|
|
82
|
+
|
|
83
|
+
showAddLocationSuccess({
|
|
84
|
+
locationName,
|
|
85
|
+
relayToken: loc.relayToken,
|
|
86
|
+
workerUrl,
|
|
87
|
+
});
|
|
88
|
+
} catch (err) {
|
|
89
|
+
showError(`Failed to create location: ${err.message}`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/commands/destroy.mjs
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import prompts from "prompts";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import { loadConfig, deleteConfig } from "../lib/config.mjs";
|
|
7
|
+
import { ensureWrangler, checkWranglerAuth, isGhAvailable } from "../lib/prerequisites.mjs";
|
|
8
|
+
import { deleteWorker, login } from "../lib/wrangler.mjs";
|
|
9
|
+
import { showError } from "../lib/display.mjs";
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
export async function run(flags) {
|
|
14
|
+
const config = loadConfig();
|
|
15
|
+
if (!config) {
|
|
16
|
+
showError("No deployment found. Nothing to destroy.");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!flags.yes && !flags["non-interactive"]) {
|
|
21
|
+
const answer = await prompts({
|
|
22
|
+
type: "confirm",
|
|
23
|
+
name: "confirm",
|
|
24
|
+
message: `This will delete worker "${config.worker_name}" and all associated data. Are you sure?`,
|
|
25
|
+
initial: false,
|
|
26
|
+
});
|
|
27
|
+
if (!answer.confirm) {
|
|
28
|
+
console.log("Cancelled.");
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const wranglerOk = await ensureWrangler();
|
|
34
|
+
if (!wranglerOk) process.exit(1);
|
|
35
|
+
|
|
36
|
+
const authed = await checkWranglerAuth();
|
|
37
|
+
if (!authed) {
|
|
38
|
+
console.log("You need to log in to Cloudflare.");
|
|
39
|
+
await login();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
console.log(`\nDeleting worker "${config.worker_name}"...`);
|
|
44
|
+
await deleteWorker(config.worker_name);
|
|
45
|
+
console.log("Worker deleted.");
|
|
46
|
+
|
|
47
|
+
if (config.auto_update_repo) {
|
|
48
|
+
const ghOk = await isGhAvailable();
|
|
49
|
+
if (ghOk) {
|
|
50
|
+
let deleteRepo = flags.yes || flags["non-interactive"];
|
|
51
|
+
if (!deleteRepo) {
|
|
52
|
+
const answer = await prompts({
|
|
53
|
+
type: "confirm",
|
|
54
|
+
name: "deleteRepo",
|
|
55
|
+
message: `Delete auto-update repo "${config.auto_update_repo}"?`,
|
|
56
|
+
initial: false,
|
|
57
|
+
});
|
|
58
|
+
deleteRepo = answer.deleteRepo;
|
|
59
|
+
}
|
|
60
|
+
if (deleteRepo) {
|
|
61
|
+
await execFileAsync("gh", ["repo", "delete", config.auto_update_repo, "--yes"], {
|
|
62
|
+
timeout: 30_000,
|
|
63
|
+
});
|
|
64
|
+
console.log("Auto-update repo deleted.");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
deleteConfig();
|
|
70
|
+
console.log(chalk.green("\n Worker removed. Cloudflare account and local config cleaned up.\n"));
|
|
71
|
+
} catch (err) {
|
|
72
|
+
showError(`Destroy failed: ${err.message}`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// src/commands/install.mjs
|
|
2
|
+
import prompts from "prompts";
|
|
3
|
+
import { loadConfig, saveConfig } from "../lib/config.mjs";
|
|
4
|
+
import { generateToken } from "../lib/tokens.mjs";
|
|
5
|
+
import { ensureWrangler, checkWranglerAuth } from "../lib/prerequisites.mjs";
|
|
6
|
+
import { deploy, putSecret, login } from "../lib/wrangler.mjs";
|
|
7
|
+
import { createLocationToken } from "../lib/api.mjs";
|
|
8
|
+
import { showInstallSuccess, showError } from "../lib/display.mjs";
|
|
9
|
+
|
|
10
|
+
export async function run(flags) {
|
|
11
|
+
const existing = loadConfig();
|
|
12
|
+
if (existing && !existing.setup_incomplete) {
|
|
13
|
+
showError("A deployment already exists. Run 'unifi-mcp-worker status' to see it.");
|
|
14
|
+
showError("To start fresh, run 'unifi-mcp-worker destroy' first.");
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const wranglerOk = await ensureWrangler();
|
|
19
|
+
if (!wranglerOk) process.exit(1);
|
|
20
|
+
|
|
21
|
+
const authed = await checkWranglerAuth();
|
|
22
|
+
if (!authed) {
|
|
23
|
+
console.log("You need to log in to Cloudflare.");
|
|
24
|
+
await login();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const resuming = existing?.setup_incomplete;
|
|
28
|
+
const resumeFrom = resuming ? existing._resume_step || "deploy" : "deploy";
|
|
29
|
+
|
|
30
|
+
let workerName, locationName, customDomain;
|
|
31
|
+
|
|
32
|
+
if (resuming) {
|
|
33
|
+
workerName = existing.worker_name;
|
|
34
|
+
locationName = flags["location-name"] || "Home Lab";
|
|
35
|
+
} else if (flags["non-interactive"]) {
|
|
36
|
+
workerName = flags["worker-name"] || "unifi-mcp-relay";
|
|
37
|
+
locationName = flags["location-name"] || "Home Lab";
|
|
38
|
+
} else {
|
|
39
|
+
const answers = await prompts([
|
|
40
|
+
{
|
|
41
|
+
type: "text",
|
|
42
|
+
name: "workerName",
|
|
43
|
+
message: "Worker name",
|
|
44
|
+
initial: "unifi-mcp-relay",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: "text",
|
|
48
|
+
name: "customDomain",
|
|
49
|
+
message: "Custom domain (leave blank for default *.workers.dev)",
|
|
50
|
+
initial: "",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
type: "text",
|
|
54
|
+
name: "locationName",
|
|
55
|
+
message: "Location name for your first relay",
|
|
56
|
+
initial: "Home Lab",
|
|
57
|
+
},
|
|
58
|
+
]);
|
|
59
|
+
workerName = answers.workerName;
|
|
60
|
+
customDomain = answers.customDomain || null;
|
|
61
|
+
locationName = answers.locationName;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!workerName || !locationName) {
|
|
65
|
+
showError("Worker name and location name are required.");
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const agentToken = resuming ? existing.agent_token : generateToken();
|
|
70
|
+
const adminToken = resuming ? existing.admin_token : generateToken();
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
let workerUrl = existing?.worker_url;
|
|
74
|
+
|
|
75
|
+
if (resumeFrom === "deploy") {
|
|
76
|
+
console.log(`\nDeploying worker "${workerName}"...`);
|
|
77
|
+
const result = await deploy(workerName);
|
|
78
|
+
workerUrl = result.workerUrl;
|
|
79
|
+
if (!workerUrl) {
|
|
80
|
+
workerUrl = `https://${workerName}.workers.dev`;
|
|
81
|
+
}
|
|
82
|
+
console.log(`Deployed to ${workerUrl}`);
|
|
83
|
+
|
|
84
|
+
saveConfig({
|
|
85
|
+
setup_incomplete: true,
|
|
86
|
+
_resume_step: "secrets",
|
|
87
|
+
worker_name: workerName,
|
|
88
|
+
worker_url: workerUrl,
|
|
89
|
+
agent_token: agentToken,
|
|
90
|
+
admin_token: adminToken,
|
|
91
|
+
locations: [],
|
|
92
|
+
created_at: new Date().toISOString(),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (resumeFrom === "deploy" || resumeFrom === "secrets") {
|
|
97
|
+
console.log("Setting AGENT_TOKEN...");
|
|
98
|
+
await putSecret(workerName, "AGENT_TOKEN", agentToken);
|
|
99
|
+
console.log("Setting ADMIN_TOKEN...");
|
|
100
|
+
await putSecret(workerName, "ADMIN_TOKEN", adminToken);
|
|
101
|
+
|
|
102
|
+
const cfg = loadConfig();
|
|
103
|
+
cfg._resume_step = "location";
|
|
104
|
+
saveConfig(cfg);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (resumeFrom === "deploy" || resumeFrom === "secrets" || resumeFrom === "location") {
|
|
108
|
+
console.log(`Creating location "${locationName}"...`);
|
|
109
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
110
|
+
|
|
111
|
+
const loc = await createLocationToken(
|
|
112
|
+
workerUrl || existing?.worker_url,
|
|
113
|
+
adminToken || existing?.admin_token,
|
|
114
|
+
locationName
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
saveConfig({
|
|
118
|
+
worker_name: workerName,
|
|
119
|
+
worker_url: workerUrl || existing?.worker_url,
|
|
120
|
+
custom_domain: customDomain || null,
|
|
121
|
+
agent_token: agentToken || existing?.agent_token,
|
|
122
|
+
admin_token: adminToken || existing?.admin_token,
|
|
123
|
+
locations: [
|
|
124
|
+
{
|
|
125
|
+
location_name: locationName,
|
|
126
|
+
relay_token: loc.relayToken,
|
|
127
|
+
location_id: loc.locationId,
|
|
128
|
+
created_at: new Date().toISOString(),
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
auto_update_repo: null,
|
|
132
|
+
created_at: existing?.created_at || new Date().toISOString(),
|
|
133
|
+
last_upgraded: new Date().toISOString(),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
showInstallSuccess({
|
|
137
|
+
workerUrl: workerUrl || existing?.worker_url,
|
|
138
|
+
agentToken: agentToken || existing?.agent_token,
|
|
139
|
+
adminToken: adminToken || existing?.admin_token,
|
|
140
|
+
relayToken: loc.relayToken,
|
|
141
|
+
locationName,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
showError(`Install failed: ${err.message}`);
|
|
146
|
+
console.error("\nPartial state saved. Re-run 'unifi-mcp-worker install' to resume.");
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
}
|