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 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
+ }