@dinglebear/unraid 0.2.5
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/LICENSE +21 -0
- package/README.md +515 -0
- package/bin/runraid.js +17 -0
- package/lib/platform.js +51 -0
- package/package.json +66 -0
- package/scripts/check-package.js +490 -0
- package/scripts/install.js +91 -0
- package/scripts/sync-readme.js +26 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jmagar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
# unraid-rmcp
|
|
2
|
+
|
|
3
|
+
Rust MCP server and CLI for Unraid GraphQL operations across NAS, Docker, VM, and storage workflows.
|
|
4
|
+
|
|
5
|
+
It exposes one MCP tool, `unraid`, plus the `runraid` CLI. Agents can inspect
|
|
6
|
+
array health, disks, Docker containers and logs, VMs, shares, notifications,
|
|
7
|
+
system metrics, UPS, logs, network settings, plugins, parity history, rclone,
|
|
8
|
+
remote access, and Unraid Connect through stdio MCP, Streamable HTTP MCP, or
|
|
9
|
+
direct shell commands.
|
|
10
|
+
|
|
11
|
+
**30-second path:** install CRGX, set `UNRAID_API_URL` and `UNRAID_API_KEY`,
|
|
12
|
+
then run `crgx unraid-rmcp -- server --json` -> start loopback HTTP with
|
|
13
|
+
`UNRAID_RMCP_HOST=127.0.0.1 crgx unraid-rmcp -- serve mcp` -> call
|
|
14
|
+
`tools/call` with `{"action":"server"}`.
|
|
15
|
+
|
|
16
|
+
**Status:** operational RMCP upstream-client server with the full Unraid GraphQL
|
|
17
|
+
query and mutation surface. Read actions require `unraid:read`; writes require
|
|
18
|
+
`unraid:admin`. Destructive operations use MCP form elicitation and fail closed
|
|
19
|
+
when the client cannot obtain user approval. HTTP MCP supports loopback dev mode,
|
|
20
|
+
static bearer tokens, and Google OAuth through `lab-auth`. Release binaries and
|
|
21
|
+
Docker images target linux/amd64 only.
|
|
22
|
+
|
|
23
|
+
**Not for:** replacing the Unraid web UI, running arbitrary shell commands,
|
|
24
|
+
storing API keys for callers, multi-tenant isolation, or passing Unraid API keys
|
|
25
|
+
through MCP tool arguments.
|
|
26
|
+
|
|
27
|
+
## Contents
|
|
28
|
+
|
|
29
|
+
- [Naming](#naming)
|
|
30
|
+
- [Capabilities And Boundaries](#capabilities-and-boundaries)
|
|
31
|
+
- [Install](#install)
|
|
32
|
+
- [Quickstart](#quickstart)
|
|
33
|
+
- [Client Configuration](#client-configuration)
|
|
34
|
+
- [Runtime Surfaces](#runtime-surfaces)
|
|
35
|
+
- [MCP Tool Reference](#mcp-tool-reference)
|
|
36
|
+
- [CLI Reference](#cli-reference)
|
|
37
|
+
- [Configuration](#configuration)
|
|
38
|
+
- [Authentication](#authentication)
|
|
39
|
+
- [Safety And Trust Model](#safety-and-trust-model)
|
|
40
|
+
- [Architecture](#architecture)
|
|
41
|
+
- [Distribution Contract](#distribution-contract)
|
|
42
|
+
- [Development](#development)
|
|
43
|
+
- [Verification](#verification)
|
|
44
|
+
- [Deployment](#deployment)
|
|
45
|
+
- [Troubleshooting](#troubleshooting)
|
|
46
|
+
- [Related Servers](#related-servers)
|
|
47
|
+
- [Documentation](#documentation)
|
|
48
|
+
- [License](#license)
|
|
49
|
+
|
|
50
|
+
## Naming
|
|
51
|
+
|
|
52
|
+
| Surface | This repo |
|
|
53
|
+
|---|---|
|
|
54
|
+
| Repository | `unraid` monorepo (`unraid-rs/` component) |
|
|
55
|
+
| Rust crate | `unraid-rmcp` |
|
|
56
|
+
| Binary / CLI | `runraid` |
|
|
57
|
+
| crates.io package | `unraid-rmcp` |
|
|
58
|
+
| CRGX command | `crgx unraid-rmcp -- <runraid args>` |
|
|
59
|
+
| Legacy npm package | `unraid-rmcp` |
|
|
60
|
+
| MCP tool | `unraid` |
|
|
61
|
+
| Config home | `~/.unraid` on hosts, `/data` in containers |
|
|
62
|
+
| Env prefixes | `UNRAID_*`, `UNRAID_RMCP_*` |
|
|
63
|
+
|
|
64
|
+
This repo is a small naming exception in the RMCP family: MCP/server variables
|
|
65
|
+
use `UNRAID_RMCP_*` rather than `UNRAID_MCP_*` for compatibility with the
|
|
66
|
+
existing deployment config.
|
|
67
|
+
|
|
68
|
+
## Capabilities And Boundaries
|
|
69
|
+
|
|
70
|
+
- Read Unraid array state, parity status, disk health, SMART summaries, and
|
|
71
|
+
capacity.
|
|
72
|
+
- Manage array, Docker, VM, notification, plugin, API-key, rclone, onboarding,
|
|
73
|
+
settings, remote-access, and Unraid Connect state through GraphQL mutations.
|
|
74
|
+
- Require MCP form elicitation for destructive operations while allowing ordinary
|
|
75
|
+
writes to proceed after scope authorization.
|
|
76
|
+
- Provide pagination/filtering for MCP list actions and output truncation for
|
|
77
|
+
large MCP responses.
|
|
78
|
+
- Expose the `server_summary` prompt and `unraid://schema/mcp-tool` schema
|
|
79
|
+
resource.
|
|
80
|
+
- Provide setup and doctor commands for local plugin/runtime checks.
|
|
81
|
+
|
|
82
|
+
| This repo owns | Unraid owns | Explicitly out of scope |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| MCP/CLI projection, GraphQL operation selection, response shaping, pagination, auth policy, elicitation policy, setup checks, and prompt/resource metadata. | NAS state, array/docker/VM behavior, GraphQL schema, Unraid API key issuance, remote access, and Connect state. | Arbitrary shell execution, controller UI replacement, credential brokerage, background monitoring, multi-tenant sandboxing, and direct local filesystem writes. |
|
|
85
|
+
|
|
86
|
+
## Install
|
|
87
|
+
|
|
88
|
+
| Path | Command | Best for | Notes |
|
|
89
|
+
|---|---|---|---|
|
|
90
|
+
| CRGX | `crgx unraid-rmcp -- --help` | Local MCP clients and quick trials. | Resolves the crate from crates.io and downloads the matching GitHub Release binary. |
|
|
91
|
+
| Cargo install | `cargo install unraid-rmcp --locked` | Persistent CLI installation. | Builds `runraid` from the crates.io source package. |
|
|
92
|
+
| Release installer | `curl -fsSL https://raw.githubusercontent.com/dinglebear-ai/unraid/main/unraid-rs/scripts/install.sh \| bash` | Host installs without CRGX. | Installs `runraid` for linux/amd64. |
|
|
93
|
+
| Docker / Compose | `docker compose up -d` | Shared HTTP MCP deployments. | Reads `.env` and exposes container port `40010`. |
|
|
94
|
+
| Build from source | `cargo build --release` | Development and audits. | Produces `target/release/runraid`. |
|
|
95
|
+
| Plugin | `claude plugin install agents/unraid-rs` | Claude Code local plugin setup from this checkout. | Ships the skill and local runtime metadata. No hooks — run `runraid setup plugin-hook` once to provision credentials. |
|
|
96
|
+
|
|
97
|
+
### CRGX / crates.io
|
|
98
|
+
|
|
99
|
+
Run the stdio MCP server or CLI without installing Node or compiling Rust:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
crgx unraid-rmcp -- --help
|
|
103
|
+
crgx unraid-rmcp -- mcp
|
|
104
|
+
crgx unraid-rmcp -- server --json
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
CRGX resolves `unraid-rmcp` from crates.io, reads its cargo-binstall metadata,
|
|
108
|
+
and downloads the matching `runraid` archive from the component-prefixed GitHub
|
|
109
|
+
Release. Exact crate versions can be pinned with `unraid-rmcp@<version>`.
|
|
110
|
+
|
|
111
|
+
The npm launcher remains available only as a compatibility path for existing
|
|
112
|
+
installations; new MCP configurations should use CRGX.
|
|
113
|
+
|
|
114
|
+
### Build From Source
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
git clone https://github.com/dinglebear-ai/unraid
|
|
118
|
+
cd unraid-mcp/unraid-rs
|
|
119
|
+
cargo build --release
|
|
120
|
+
./target/release/runraid --help
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Minimum supported Rust version: 1.90.
|
|
124
|
+
|
|
125
|
+
## Quickstart
|
|
126
|
+
|
|
127
|
+
### 1. Configure Unraid
|
|
128
|
+
|
|
129
|
+
Create an Unraid API key in Settings -> API Management, then set:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
export UNRAID_API_URL="https://10-1-0-2.<hash>.myunraid.net:31337/graphql"
|
|
133
|
+
export UNRAID_API_KEY="your-api-key-here"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Set `UNRAID_API_SKIP_TLS_VERIFY=true` only when your Unraid GraphQL endpoint uses
|
|
137
|
+
a certificate your host does not trust.
|
|
138
|
+
|
|
139
|
+
### 2. Run A Safe CLI Call
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
crgx unraid-rmcp -- server --json
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### 3. Start Loopback HTTP MCP
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
UNRAID_RMCP_HOST=127.0.0.1 crgx unraid-rmcp -- serve mcp
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
In another shell:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
curl -sf http://127.0.0.1:40010/health
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### 4. Make A First MCP Call
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
curl -s -X POST http://127.0.0.1:40010/mcp \
|
|
161
|
+
-H "Content-Type: application/json" \
|
|
162
|
+
-H "Accept: application/json, text/event-stream" \
|
|
163
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"unraid","arguments":{"action":"server"}}}'
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Client Configuration
|
|
167
|
+
|
|
168
|
+
### Claude Code Stdio
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"mcpServers": {
|
|
173
|
+
"unraid": {
|
|
174
|
+
"command": "crgx",
|
|
175
|
+
"args": ["unraid-rmcp", "--", "mcp"],
|
|
176
|
+
"env": {
|
|
177
|
+
"UNRAID_API_URL": "https://10-1-0-2.<hash>.myunraid.net:31337/graphql",
|
|
178
|
+
"UNRAID_API_KEY": "your-api-key-here"
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Claude Code HTTP
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{
|
|
189
|
+
"mcpServers": {
|
|
190
|
+
"unraid": {
|
|
191
|
+
"type": "http",
|
|
192
|
+
"url": "http://127.0.0.1:40010/mcp",
|
|
193
|
+
"headers": {
|
|
194
|
+
"Authorization": "Bearer ${UNRAID_RMCP_TOKEN}"
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Codex / Labby Gateway
|
|
202
|
+
|
|
203
|
+
Register Unraid through Labby as an HTTP upstream when sharing one long-running
|
|
204
|
+
server, or run it directly as stdio for local-only use.
|
|
205
|
+
|
|
206
|
+
```toml
|
|
207
|
+
[mcp_servers.unraid]
|
|
208
|
+
command = "crgx"
|
|
209
|
+
args = ["unraid-rmcp", "--", "mcp"]
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Generic MCP JSON
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
{
|
|
216
|
+
"command": "runraid",
|
|
217
|
+
"args": ["mcp"],
|
|
218
|
+
"env": {
|
|
219
|
+
"UNRAID_API_URL": "https://10-1-0-2.<hash>.myunraid.net:31337/graphql",
|
|
220
|
+
"UNRAID_API_KEY": "your-api-key-here"
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Do not put `UNRAID_API_KEY`, OAuth secrets, passwords, SSH keys, or upstream
|
|
226
|
+
bearer tokens in MCP tool arguments. Use env, config files, or the MCP client's
|
|
227
|
+
secret storage. MCP callers never provide credentials, tokens, keys, or secrets
|
|
228
|
+
as action arguments.
|
|
229
|
+
|
|
230
|
+
## Runtime Surfaces
|
|
231
|
+
|
|
232
|
+
| Surface | Status | Entry point | Purpose |
|
|
233
|
+
|---|---:|---|---|
|
|
234
|
+
| MCP stdio | Supported | `runraid mcp`, `crgx unraid-rmcp -- mcp` | Local child-process MCP clients. |
|
|
235
|
+
| MCP HTTP | Supported | `runraid serve mcp`, `POST /mcp` | Streamable HTTP MCP for local or shared server deployments. |
|
|
236
|
+
| CLI | Supported | `runraid <command>` | Scriptable parity and debugging. |
|
|
237
|
+
| Prompt | Supported | `server_summary` | Guides a model to call `info` and summarize server state. |
|
|
238
|
+
| Resource | Supported | `unraid://schema/mcp-tool` | JSON schema for the `unraid` tool. |
|
|
239
|
+
| Health endpoint | Supported | `GET /health` | Unauthenticated liveness check. |
|
|
240
|
+
| REST API | Not shipped | N/A | Unraid owns the GraphQL API. |
|
|
241
|
+
| Web UI | Not shipped | N/A | Unraid owns the web UI. |
|
|
242
|
+
|
|
243
|
+
## MCP Tool Reference
|
|
244
|
+
|
|
245
|
+
One MCP tool is exposed: `unraid`. Pass the required `action` argument to select
|
|
246
|
+
the operation.
|
|
247
|
+
|
|
248
|
+
### Core Actions
|
|
249
|
+
|
|
250
|
+
| Action | Description | Required params | Optional params |
|
|
251
|
+
|---|---|---|---|
|
|
252
|
+
| `array` | Array state, disk health, parity check status, and capacity. | none | none |
|
|
253
|
+
| `disks` | Physical disks with SMART status, temperature, size, interface, and partitions. | none | `limit`, `offset`, `name` |
|
|
254
|
+
| `docker` | Docker containers with state, status, ports, and update availability. | none | `limit`, `offset`, `state`, `name` |
|
|
255
|
+
| `docker_logs` | Container log lines. | `id` | `tail` |
|
|
256
|
+
| `vms` | Virtual machines and state. | none | `limit`, `offset`, `state`, `name` |
|
|
257
|
+
| `server` | Server identity, LAN/WAN IP, local/remote URLs, GUID, and online status. | none | none |
|
|
258
|
+
| `info` | OS, CPU, memory layout, Unraid version, and kernel version. | none | none |
|
|
259
|
+
| `shares` | User shares with size, cache settings, and encryption state. | none | `limit`, `offset`, `name` |
|
|
260
|
+
| `notifications` | Active warnings and alerts with overview counts. | none | none |
|
|
261
|
+
|
|
262
|
+
### System Actions
|
|
263
|
+
|
|
264
|
+
| Action | Description |
|
|
265
|
+
|---|---|
|
|
266
|
+
| `services` | Running system services with uptime. |
|
|
267
|
+
| `network` | Network access URLs and addresses. |
|
|
268
|
+
| `metrics` | Live CPU, memory, and temperature sensor data. |
|
|
269
|
+
| `vars` | System configuration variables. |
|
|
270
|
+
| `registration` | License type, state, and expiry. |
|
|
271
|
+
| `flash` | USB flash drive details. |
|
|
272
|
+
|
|
273
|
+
### Log And Storage Actions
|
|
274
|
+
|
|
275
|
+
| Action | Description | Required params | Optional params |
|
|
276
|
+
|---|---|---|---|
|
|
277
|
+
| `log_files` | Available log files with sizes and modified times. | none | none |
|
|
278
|
+
| `log_file` | Read a log file. | `path` | `lines`, `start_line` |
|
|
279
|
+
| `parity_history` | Past parity check results. | none | none |
|
|
280
|
+
| `rclone` | Backup remote configurations and drive names. | none | none |
|
|
281
|
+
|
|
282
|
+
### UPS, Remote, Plugin, And Meta Actions
|
|
283
|
+
|
|
284
|
+
| Action | Description |
|
|
285
|
+
|---|---|
|
|
286
|
+
| `ups` | UPS devices, charge, runtime, and load. |
|
|
287
|
+
| `ups_config` | UPS monitoring configuration. |
|
|
288
|
+
| `remote_access` | WAN access and port forwarding configuration. |
|
|
289
|
+
| `connect` | Unraid Connect dynamic remote access state. |
|
|
290
|
+
| `plugins` | Installed community plugins with versions. |
|
|
291
|
+
| `status` | Server observability: version, PID, uptime, and counters. |
|
|
292
|
+
| `help` | Markdown reference for all actions. |
|
|
293
|
+
|
|
294
|
+
Pagination and filtering are MCP-only. List actions return a paginated envelope
|
|
295
|
+
with `items`, `total`, `limit`, `offset`, `has_more`, and `next_offset`.
|
|
296
|
+
|
|
297
|
+
## CLI Reference
|
|
298
|
+
|
|
299
|
+
All CLI commands accept `--json` for machine-readable output.
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
runraid array [--json]
|
|
303
|
+
runraid disks [--json]
|
|
304
|
+
runraid docker [--json]
|
|
305
|
+
runraid docker logs <id> [--tail N] [--json]
|
|
306
|
+
runraid vms [--json]
|
|
307
|
+
runraid server [--json]
|
|
308
|
+
runraid info [--json]
|
|
309
|
+
runraid shares [--json]
|
|
310
|
+
runraid notifications [--json]
|
|
311
|
+
runraid services [--json]
|
|
312
|
+
runraid network [--json]
|
|
313
|
+
runraid metrics [--json]
|
|
314
|
+
runraid vars [--json]
|
|
315
|
+
runraid registration [--json]
|
|
316
|
+
runraid flash [--json]
|
|
317
|
+
runraid log-files [--json]
|
|
318
|
+
runraid log <path> [--lines N] [--start-line N] [--json]
|
|
319
|
+
runraid parity-history [--json]
|
|
320
|
+
runraid rclone [--json]
|
|
321
|
+
runraid ups [--json]
|
|
322
|
+
runraid ups-config [--json]
|
|
323
|
+
runraid remote-access [--json]
|
|
324
|
+
runraid connect [--json]
|
|
325
|
+
runraid plugins [--json]
|
|
326
|
+
runraid doctor [--json]
|
|
327
|
+
runraid setup check [--json]
|
|
328
|
+
runraid setup repair [--json]
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
`status` is MCP-only; `setup` and `doctor` are CLI-only.
|
|
332
|
+
|
|
333
|
+
## Configuration
|
|
334
|
+
|
|
335
|
+
Host installs read `~/.unraid/.env` before loading config. Containers read
|
|
336
|
+
`/data/.env`. Process environment overrides both.
|
|
337
|
+
|
|
338
|
+
| Variable | Default | Purpose |
|
|
339
|
+
|---|---|---|
|
|
340
|
+
| `UNRAID_API_URL` | unset | Full Unraid GraphQL endpoint URL. |
|
|
341
|
+
| `UNRAID_API_KEY` | unset | API key for the `x-api-key` header. |
|
|
342
|
+
| `UNRAID_API_SKIP_TLS_VERIFY` | `false` | Skip TLS certificate verification for self-signed endpoints. |
|
|
343
|
+
| `UNRAID_RMCP_HOST` | `0.0.0.0` | HTTP bind host. |
|
|
344
|
+
| `UNRAID_RMCP_PORT` | `40010` | HTTP bind port. |
|
|
345
|
+
| `UNRAID_RMCP_SERVER_NAME` | `unraid-rmcp` | Advertised MCP server name. |
|
|
346
|
+
| `UNRAID_RMCP_TOKEN` | unset | Static bearer token for HTTP MCP. |
|
|
347
|
+
| `UNRAID_RMCP_NO_AUTH` | `false` | Disable auth only for loopback development. |
|
|
348
|
+
| `UNRAID_RMCP_DISABLE_HTTP_AUTH` | `false` | Compatibility alias for disabling auth. |
|
|
349
|
+
| `UNRAID_NOAUTH` | `false` | Trust an upstream gateway to enforce auth. |
|
|
350
|
+
| `UNRAID_RMCP_ALLOWED_HOSTS` | unset | Extra accepted Host header values. |
|
|
351
|
+
| `UNRAID_RMCP_ALLOWED_ORIGINS` | unset | Extra accepted CORS origins. |
|
|
352
|
+
| `UNRAID_RMCP_PUBLIC_URL` | unset | Public URL for OAuth metadata. |
|
|
353
|
+
| `UNRAID_RMCP_AUTH_MODE` | `bearer` | `bearer` or `oauth`. |
|
|
354
|
+
| `UNRAID_RMCP_GOOGLE_CLIENT_ID` | unset | Google OAuth client ID. |
|
|
355
|
+
| `UNRAID_RMCP_GOOGLE_CLIENT_SECRET` | unset | Google OAuth client secret. |
|
|
356
|
+
| `UNRAID_RMCP_AUTH_ADMIN_EMAIL` | unset | Admin email for OAuth bootstrap. |
|
|
357
|
+
|
|
358
|
+
## Authentication
|
|
359
|
+
|
|
360
|
+
Stdio MCP runs as a local trusted child process and does not use HTTP auth.
|
|
361
|
+
|
|
362
|
+
HTTP MCP auth policy:
|
|
363
|
+
|
|
364
|
+
| State | Condition | Behavior |
|
|
365
|
+
|---|---|---|
|
|
366
|
+
| Loopback dev | `UNRAID_RMCP_HOST` starts with `127.` or auth is explicitly disabled on loopback | Local unauthenticated development is allowed. |
|
|
367
|
+
| Mounted bearer | Non-loopback with `UNRAID_RMCP_TOKEN` | Requires `Authorization: Bearer <token>` and action scopes. |
|
|
368
|
+
| Mounted OAuth | `UNRAID_RMCP_AUTH_MODE=oauth` | Uses Google OAuth/JWT through `lab-auth`. |
|
|
369
|
+
| Trusted gateway | `UNRAID_NOAUTH=true` | Assumes a reverse proxy or gateway already enforced auth. |
|
|
370
|
+
|
|
371
|
+
Read actions require `unraid:read`; mutating actions require `unraid:admin`.
|
|
372
|
+
Configured static bearer tokens are operator credentials and receive admin scope.
|
|
373
|
+
OAuth clients receive the scopes granted by the authorization flow.
|
|
374
|
+
|
|
375
|
+
## Safety And Trust Model
|
|
376
|
+
|
|
377
|
+
- Unraid API keys are loaded from config/env only.
|
|
378
|
+
- MCP callers select actions and arguments, not upstream credentials.
|
|
379
|
+
- Destructive actions issue an MCP form-elicitation request before dispatch. A
|
|
380
|
+
decline, cancellation, malformed response, or client without elicitation support
|
|
381
|
+
stops the operation before the GraphQL request is sent.
|
|
382
|
+
- Ordinary mutations remain directly available after `unraid:admin` authorization;
|
|
383
|
+
the server does not add a second confirmation parameter or disable writes.
|
|
384
|
+
- `log_file` reads through the Unraid GraphQL API, not arbitrary local files.
|
|
385
|
+
- Non-loopback HTTP deployments must use bearer auth, OAuth, or a trusted
|
|
386
|
+
authenticated gateway.
|
|
387
|
+
- This bridge does not sandbox Unraid itself. Unraid remains responsible for API
|
|
388
|
+
permissions and GraphQL response semantics.
|
|
389
|
+
|
|
390
|
+
## Architecture
|
|
391
|
+
|
|
392
|
+
```text
|
|
393
|
+
GraphQL operations (src/graphql.rs) queries + mutations
|
|
394
|
+
|
|
|
395
|
+
UnraidService (src/app.rs) action behavior and response shaping
|
|
396
|
+
|
|
|
397
|
+
MCP scope + elicitation authorization and destructive approval
|
|
398
|
+
|
|
|
399
|
+
MCP shim (src/mcp/tools.rs) JSON args -> service -> Value
|
|
400
|
+
CLI shim (src/cli.rs) argv -> service -> stdout
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
## Distribution Contract
|
|
404
|
+
|
|
405
|
+
- `Cargo.toml`, `Cargo.lock`, both `version` and `binaryVersion` in
|
|
406
|
+
`packages/unraid-rmcp/package.json`, `.release-please-manifest.json`, agent
|
|
407
|
+
manifests, and `server.json` must agree on the released version.
|
|
408
|
+
- crates.io publishes `lab-auth` first and `unraid-rmcp` second through
|
|
409
|
+
`.github/workflows/crates-publish.yml`; automatic publication stays disabled
|
|
410
|
+
until `CRATES_IO_PUBLISHING_ENABLED=true` and the `crates-io` environment holds
|
|
411
|
+
`CARGO_REGISTRY_TOKEN`.
|
|
412
|
+
- GitHub Releases publish the linux/amd64 `runraid` archive consumed by CRGX and
|
|
413
|
+
cargo-binstall through the manifest metadata in `Cargo.toml`.
|
|
414
|
+
- The npm package remains a compatibility surface only. Automatic npm publishing
|
|
415
|
+
is enabled only when `NPM_TRUSTED_PUBLISHING_ENABLED=true`.
|
|
416
|
+
- Docker/OCI metadata uses `ghcr.io/dinglebear-ai/unraid-rmcp:<version>`.
|
|
417
|
+
- `agents/unraid-rs/.mcp.json` must launch `crgx unraid-rmcp -- mcp` so stdio
|
|
418
|
+
clients resolve the crates.io package without requiring Node.
|
|
419
|
+
- The root README is curated. `docs/INVENTORY.md` is the curated inventory for
|
|
420
|
+
actions, CLI commands, env vars, HTTP endpoints, and dependencies.
|
|
421
|
+
|
|
422
|
+
## Development
|
|
423
|
+
|
|
424
|
+
```bash
|
|
425
|
+
cargo fmt --check
|
|
426
|
+
cargo test
|
|
427
|
+
cargo clippy -- -D warnings
|
|
428
|
+
cargo build --release
|
|
429
|
+
npm --prefix packages/unraid-rmcp run check
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
## Verification
|
|
433
|
+
|
|
434
|
+
```bash
|
|
435
|
+
python3 /home/jmagar/workspace/soma/scripts/check-readme-guide.py README.md
|
|
436
|
+
npm --prefix packages/unraid-rmcp run check
|
|
437
|
+
cargo check
|
|
438
|
+
cargo test
|
|
439
|
+
git diff --check
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Runtime smoke:
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
UNRAID_API_URL=https://10-1-0-2.<hash>.myunraid.net:31337/graphql \
|
|
446
|
+
UNRAID_API_KEY=... \
|
|
447
|
+
runraid server --json
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
HTTP smoke:
|
|
451
|
+
|
|
452
|
+
```bash
|
|
453
|
+
UNRAID_RMCP_HOST=127.0.0.1 runraid serve mcp
|
|
454
|
+
curl -sf http://127.0.0.1:40010/health
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
## Deployment
|
|
458
|
+
|
|
459
|
+
Use loopback for local development:
|
|
460
|
+
|
|
461
|
+
```bash
|
|
462
|
+
UNRAID_RMCP_HOST=127.0.0.1 runraid serve mcp
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
Use Docker Compose for shared HTTP deployment:
|
|
466
|
+
|
|
467
|
+
```bash
|
|
468
|
+
cp .env.example .env
|
|
469
|
+
docker compose up -d
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
When binding to a non-loopback address, configure `UNRAID_RMCP_TOKEN`,
|
|
473
|
+
`UNRAID_RMCP_AUTH_MODE=oauth`, or `UNRAID_NOAUTH=true` behind an authenticated
|
|
474
|
+
gateway.
|
|
475
|
+
|
|
476
|
+
## Troubleshooting
|
|
477
|
+
|
|
478
|
+
| Symptom | Check |
|
|
479
|
+
|---|---|
|
|
480
|
+
| `UNRAID_API_URL` or `UNRAID_API_KEY` is missing | Set it in env or `~/.unraid/.env`. |
|
|
481
|
+
| TLS errors against Unraid | Set `UNRAID_API_SKIP_TLS_VERIFY=true` only for self-signed endpoints. |
|
|
482
|
+
| HTTP `/mcp` returns unauthorized | Set `UNRAID_RMCP_TOKEN` and send `Authorization: Bearer <token>`. |
|
|
483
|
+
| Stdio client hangs or logs JSON errors | Ensure client config runs `unraid-rmcp mcp`, not the default HTTP server mode. |
|
|
484
|
+
| Large list response is truncated | Use `limit`, `offset`, `name`, or `state` filters on MCP list actions. |
|
|
485
|
+
| `docker_logs` fails | Pass a valid container `id` and optional `tail`. |
|
|
486
|
+
|
|
487
|
+
## Related Servers
|
|
488
|
+
|
|
489
|
+
- [soma](https://github.com/jmagar/soma) - RMCP runtime for provider-backed MCP servers.
|
|
490
|
+
- [unifi-rmcp](https://github.com/jmagar/runifi) - UniFi controller REST API bridge.
|
|
491
|
+
- [tailscale-rmcp](https://github.com/jmagar/rtailscale) - Tailscale API bridge for devices, users, and tailnet operations.
|
|
492
|
+
- [apprise-rmcp](https://github.com/jmagar/rapprise) - Apprise notification fan-out bridge for many delivery backends.
|
|
493
|
+
- [gotify-rmcp](https://github.com/jmagar/rgotify) - Gotify push notification bridge for sends, messages, apps, and clients.
|
|
494
|
+
- [arcane-rmcp](https://github.com/jmagar/rarcane) - Arcane Docker management bridge for containers and related resources.
|
|
495
|
+
- [yarr](https://github.com/jmagar/yarr) - Media-stack bridge for Sonarr, Radarr, Prowlarr, Plex, and related services.
|
|
496
|
+
- [ytdl-rmcp](https://github.com/jmagar/rytdl) - Media download and metadata workflow server.
|
|
497
|
+
- [synapse-rmcp](https://github.com/jmagar/synapse) - Local Synapse workflow server for scout and flux actions.
|
|
498
|
+
- [cortex](https://github.com/jmagar/cortex) - Syslog and homelab log aggregation MCP server.
|
|
499
|
+
- [axon](https://github.com/jmagar/axon) - RAG, crawl, scrape, extract, and semantic search project.
|
|
500
|
+
- [labby](https://github.com/jmagar/labby) - Homelab control plane and MCP gateway project.
|
|
501
|
+
- [lumen](https://github.com/jmagar/lumen) - Local semantic code search MCP server.
|
|
502
|
+
|
|
503
|
+
## Documentation
|
|
504
|
+
|
|
505
|
+
- `CLAUDE.md` is the curated local operating guide for contributors and agents.
|
|
506
|
+
- `docs/INVENTORY.md` is the curated/generated inventory for actions, CLI
|
|
507
|
+
commands, env vars, HTTP endpoints, and dependencies.
|
|
508
|
+
- `docs/stack/ARCH.md` is the curated architecture guide.
|
|
509
|
+
- `agents/unraid-rs/skills/unraid/SKILL.md` is the agent usage guide.
|
|
510
|
+
- `src/` is the source of truth for current GraphQL queries, config defaults,
|
|
511
|
+
auth behavior, and CLI parsing.
|
|
512
|
+
|
|
513
|
+
## License
|
|
514
|
+
|
|
515
|
+
MIT. See [LICENSE](LICENSE).
|
package/bin/runraid.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { spawnSync } = require("node:child_process");
|
|
6
|
+
const { binaryPath } = require("../lib/platform");
|
|
7
|
+
function fail(message) { process.stderr.write(`unraid-rmcp: ${message}\n`); process.exit(1); }
|
|
8
|
+
const binary = binaryPath();
|
|
9
|
+
if (!fs.existsSync(binary)) {
|
|
10
|
+
const installer = path.resolve(__dirname, "..", "scripts", "install.js");
|
|
11
|
+
const install = spawnSync(process.execPath, [installer], { stdio: "inherit" });
|
|
12
|
+
if (install.status !== 0) fail("binary is not installed; postinstall may have failed");
|
|
13
|
+
}
|
|
14
|
+
const child = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" });
|
|
15
|
+
if (child.error) fail(child.error.message);
|
|
16
|
+
if (child.signal) process.kill(process.pid, child.signal);
|
|
17
|
+
else process.exit(child.status ?? 1);
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
|
|
5
|
+
function packageVersion() {
|
|
6
|
+
return require("../package.json").version;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function binaryVersion() {
|
|
10
|
+
return require("../package.json").binaryVersion || packageVersion();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function targetFor(platform = process.platform, arch = process.arch) {
|
|
14
|
+
if (platform === "linux" && arch === "x64") {
|
|
15
|
+
return { asset: "runraid-x86_64.tar.gz", binary: "runraid" };
|
|
16
|
+
}
|
|
17
|
+
throw new Error(`Unsupported platform ${platform}/${arch}. Supported target: linux/x64.`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Release tags in the unraid-mcp monorepo are component-prefixed: the Rust
|
|
21
|
+
// server publishes `unraid-rs-v<semver>` (see .github/workflows/rust-release.yml),
|
|
22
|
+
// while bare `v<semver>` tags belong to the Python server. Accept a bare or
|
|
23
|
+
// v-prefixed version for backwards compatibility and normalise to the tag the
|
|
24
|
+
// monorepo actually creates.
|
|
25
|
+
const RELEASE_TAG_PREFIX = "unraid-rs-v";
|
|
26
|
+
|
|
27
|
+
function releaseVersion(env = process.env) {
|
|
28
|
+
const raw = env.UNRAID_RMCP_BINARY_VERSION || env.UNRAID_RMCP_VERSION || binaryVersion();
|
|
29
|
+
if (raw.startsWith(RELEASE_TAG_PREFIX)) return raw;
|
|
30
|
+
return `${RELEASE_TAG_PREFIX}${raw.startsWith("v") ? raw.slice(1) : raw}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function releaseBaseUrl(env = process.env) {
|
|
34
|
+
const repo = env.UNRAID_RMCP_REPO || "dinglebear-ai/unraid";
|
|
35
|
+
return env.UNRAID_RMCP_RELEASE_BASE_URL || `https://github.com/${repo}/releases/download`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function downloadUrl(target, env = process.env) {
|
|
39
|
+
return `${releaseBaseUrl(env)}/${releaseVersion(env)}/${target.asset}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function installRoot() {
|
|
43
|
+
return path.resolve(__dirname, "..", "vendor");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function binaryPath(platform = process.platform, arch = process.arch) {
|
|
47
|
+
const target = targetFor(platform, arch);
|
|
48
|
+
return path.join(installRoot(), target.binary);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { binaryPath, binaryVersion, downloadUrl, releaseBaseUrl, installRoot, packageVersion, releaseVersion, targetFor };
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dinglebear/unraid",
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"description": "Rust MCP server and CLI for Unraid GraphQL operations across NAS, Docker, VM, and storage workflows.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/dinglebear-ai/unraid#readme",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/dinglebear-ai/unraid.git",
|
|
10
|
+
"directory": "unraid-rs/packages/unraid-rmcp"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/dinglebear-ai/unraid/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"unraid-rmcp": "bin/runraid.js",
|
|
17
|
+
"runraid": "bin/runraid.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin/",
|
|
21
|
+
"lib/",
|
|
22
|
+
"scripts/",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE*",
|
|
25
|
+
"package.json"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"prepack": "node scripts/sync-readme.js",
|
|
29
|
+
"postinstall": "node scripts/install.js",
|
|
30
|
+
"test": "node --test",
|
|
31
|
+
"check": "node --check bin/runraid.js && node --check scripts/install.js && node --check lib/platform.js && node --check scripts/sync-readme.js && node --check scripts/check-package.js && node scripts/check-package.js",
|
|
32
|
+
"prepublishOnly": "node scripts/check-package.js --release"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"mcp",
|
|
39
|
+
"mcp-server",
|
|
40
|
+
"model-context-protocol",
|
|
41
|
+
"rmcp",
|
|
42
|
+
"rust",
|
|
43
|
+
"unraid",
|
|
44
|
+
"nas",
|
|
45
|
+
"graphql",
|
|
46
|
+
"storage",
|
|
47
|
+
"docker",
|
|
48
|
+
"virtualization",
|
|
49
|
+
"homelab",
|
|
50
|
+
"cli",
|
|
51
|
+
"claude-code",
|
|
52
|
+
"codex",
|
|
53
|
+
"gemini",
|
|
54
|
+
"automation",
|
|
55
|
+
"oauth"
|
|
56
|
+
],
|
|
57
|
+
"mcpName": "ai.dinglebear/unraid",
|
|
58
|
+
"binaryVersion": "0.2.5",
|
|
59
|
+
"author": {
|
|
60
|
+
"name": "dinglebear.ai",
|
|
61
|
+
"url": "https://dinglebear.ai"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const https = require("node:https");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const path = require("node:path");
|
|
8
|
+
const { spawnSync } = require("node:child_process");
|
|
9
|
+
|
|
10
|
+
const packageRoot = path.resolve(__dirname, "..");
|
|
11
|
+
const repoRoot = path.resolve(packageRoot, "..", "..");
|
|
12
|
+
// Canonical home of this package after the monorepo consolidation.
|
|
13
|
+
const CANONICAL_REPO = "dinglebear-ai/unraid";
|
|
14
|
+
const REPOSITORY_DIRECTORY = "unraid-rs/packages/unraid-rmcp";
|
|
15
|
+
const packageJsonPath = path.join(packageRoot, "package.json");
|
|
16
|
+
const packageJson = readJson(packageJsonPath);
|
|
17
|
+
const expectedPackageName = "@dinglebear/unraid";
|
|
18
|
+
const releaseMode = process.argv.includes("--release");
|
|
19
|
+
const skipReleaseAssets = process.argv.includes("--skip-release-assets");
|
|
20
|
+
|
|
21
|
+
const failures = [];
|
|
22
|
+
|
|
23
|
+
function readJson(file) {
|
|
24
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fail(message) {
|
|
28
|
+
failures.push(message);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assert(condition, message) {
|
|
32
|
+
if (!condition) {
|
|
33
|
+
fail(message);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeRepoUrl(url) {
|
|
38
|
+
return String(url || "")
|
|
39
|
+
.replace(/^git\+/, "")
|
|
40
|
+
.replace(/#readme$/, "")
|
|
41
|
+
.replace(/\.git$/, "")
|
|
42
|
+
.replace(/\/$/, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeHomepage(url) {
|
|
46
|
+
return String(url || "").replace(/#readme$/, "").replace(/\/$/, "");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function repoLicenseFiles() {
|
|
50
|
+
return fs
|
|
51
|
+
.readdirSync(repoRoot)
|
|
52
|
+
.filter((entry) => /^licen[cs]e/i.test(entry))
|
|
53
|
+
.filter((entry) => fs.statSync(path.join(repoRoot, entry)).isFile())
|
|
54
|
+
.sort();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function compareFiles(left, right) {
|
|
58
|
+
if (!fs.existsSync(left) || !fs.existsSync(right)) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return fs.readFileSync(left).equals(fs.readFileSync(right));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function run(command, args, options = {}) {
|
|
66
|
+
const result = spawnSync(command, args, {
|
|
67
|
+
cwd: options.cwd || packageRoot,
|
|
68
|
+
env: options.env || process.env,
|
|
69
|
+
encoding: "utf8",
|
|
70
|
+
stdio: options.stdio || ["ignore", "pipe", "pipe"],
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (result.status !== 0) {
|
|
74
|
+
const detail = (result.stderr || result.stdout || `${command} ${args.join(" ")} failed`).trim();
|
|
75
|
+
throw new Error(detail);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function checkSyncedFiles() {
|
|
82
|
+
assert(
|
|
83
|
+
compareFiles(path.join(repoRoot, "README.md"), path.join(packageRoot, "README.md")),
|
|
84
|
+
"package README.md must be byte-identical to the repo README.md; run npm pack or scripts/sync-readme.js",
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const licenses = repoLicenseFiles();
|
|
88
|
+
assert(licenses.length > 0, "repo must have at least one LICENSE file to sync into the npm package");
|
|
89
|
+
|
|
90
|
+
for (const license of licenses) {
|
|
91
|
+
assert(
|
|
92
|
+
compareFiles(path.join(repoRoot, license), path.join(packageRoot, license)),
|
|
93
|
+
`package ${license} must be byte-identical to repo ${license}; run npm pack or scripts/sync-readme.js`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function findNpmPackage(serverJson) {
|
|
99
|
+
return (serverJson.packages || []).find(
|
|
100
|
+
(entry) => entry.registryType === "npm" || entry.identifier === packageJson.name,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function checkMetadata() {
|
|
105
|
+
const serverPath = path.join(repoRoot, "server.json");
|
|
106
|
+
assert(fs.existsSync(serverPath), "repo must contain server.json for package metadata checks");
|
|
107
|
+
|
|
108
|
+
if (!fs.existsSync(serverPath)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const serverJson = readJson(serverPath);
|
|
113
|
+
const npmPackage = findNpmPackage(serverJson);
|
|
114
|
+
const repoUrl = normalizeRepoUrl(packageJson.repository && packageJson.repository.url);
|
|
115
|
+
const serverRepoUrl = normalizeRepoUrl(serverJson.repository && serverJson.repository.url);
|
|
116
|
+
const homepage = normalizeHomepage(packageJson.homepage);
|
|
117
|
+
const serverWebsite = normalizeHomepage(serverJson.websiteUrl);
|
|
118
|
+
|
|
119
|
+
assert(packageJson.name, "package.json must include name");
|
|
120
|
+
assert(
|
|
121
|
+
packageJson.name === expectedPackageName,
|
|
122
|
+
"package.json name must match the dinglebear organization package",
|
|
123
|
+
);
|
|
124
|
+
assert(
|
|
125
|
+
packageJson.publishConfig && packageJson.publishConfig.access === "public",
|
|
126
|
+
"scoped npm package must publish with public access",
|
|
127
|
+
);
|
|
128
|
+
assert(packageJson.version, "package.json must include version");
|
|
129
|
+
assert(packageJson.description, "package.json must include description");
|
|
130
|
+
assert(packageJson.license, "package.json must include license");
|
|
131
|
+
assert(packageJson.author && packageJson.author.name === "dinglebear.ai", "package.json author must be dinglebear.ai");
|
|
132
|
+
assert(packageJson.engines && packageJson.engines.node, "package.json must declare engines.node");
|
|
133
|
+
assert(packageJson.repository && packageJson.repository.type === "git", "package.json repository.type must be git");
|
|
134
|
+
assert(packageJson.repository && packageJson.repository.directory, "package.json repository.directory must point at this package");
|
|
135
|
+
assert(
|
|
136
|
+
packageJson.repository && packageJson.repository.directory === REPOSITORY_DIRECTORY,
|
|
137
|
+
"package.json repository.directory must match its monorepo path",
|
|
138
|
+
);
|
|
139
|
+
assert(packageJson.bugs && packageJson.bugs.url, "package.json must include bugs.url");
|
|
140
|
+
assert(packageJson.mcpName === serverJson.name, "package.json mcpName must match server.json name");
|
|
141
|
+
assert(
|
|
142
|
+
packageJson.description === serverJson.description,
|
|
143
|
+
"package.json description must match server.json description",
|
|
144
|
+
);
|
|
145
|
+
assert(repoUrl === serverRepoUrl, `package repository ${repoUrl} must match server.json repository ${serverRepoUrl}`);
|
|
146
|
+
assert(homepage === serverWebsite, `package homepage ${homepage} must match server.json websiteUrl ${serverWebsite}`);
|
|
147
|
+
// Anchor the URLs to the canonical monorepo. Asserting only that package.json
|
|
148
|
+
// and server.json agree with EACH OTHER let both drift to the pre-consolidation
|
|
149
|
+
// repo (jmagar/runraid) while CI stayed green.
|
|
150
|
+
for (const [label, value] of [["package repository", repoUrl], ["server.json repository", serverRepoUrl], ["package homepage", homepage], ["server.json websiteUrl", serverWebsite]]) {
|
|
151
|
+
assert(
|
|
152
|
+
typeof value === "string" && value.includes(`/${CANONICAL_REPO}`),
|
|
153
|
+
`${label} must point at ${CANONICAL_REPO} (got ${value})`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
assert(serverJson.version === packageJson.version, `package version ${packageJson.version} must match server.json version ${serverJson.version}`);
|
|
157
|
+
|
|
158
|
+
if (npmPackage) {
|
|
159
|
+
assert(npmPackage.identifier === packageJson.name, "server.json npm package identifier must match package name");
|
|
160
|
+
assert(npmPackage.version === packageJson.version, "server.json npm package version must match package version");
|
|
161
|
+
} else {
|
|
162
|
+
fail("server.json must include an npm package entry");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const publisherMeta = serverJson._meta && serverJson._meta["io.modelcontextprotocol.registry/publisher-provided"];
|
|
166
|
+
const distribution = publisherMeta && publisherMeta.distribution;
|
|
167
|
+
if (distribution) {
|
|
168
|
+
if (distribution.npm) {
|
|
169
|
+
assert(
|
|
170
|
+
distribution.npm === `${packageJson.name}@${packageJson.version}`,
|
|
171
|
+
`server.json distribution.npm must be ${packageJson.name}@${packageJson.version}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (distribution.nodePackage) {
|
|
175
|
+
assert(distribution.nodePackage === packageJson.name, "server.json distribution.nodePackage must match package name");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const keyword of ["mcp", "mcp-server", "model-context-protocol"]) {
|
|
180
|
+
assert(Array.isArray(packageJson.keywords) && packageJson.keywords.includes(keyword), `package keywords must include ${keyword}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const field of ["bin/", "scripts/", "README.md", "LICENSE*", "package.json"]) {
|
|
184
|
+
assert(Array.isArray(packageJson.files) && packageJson.files.includes(field), `package files must include ${field}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
assert(packageJson.scripts && packageJson.scripts.prepack === "node scripts/sync-readme.js", "package prepack must sync README/LICENSE files");
|
|
188
|
+
assert(packageJson.scripts && packageJson.scripts.prepublishOnly === "node scripts/check-package.js --release", "package prepublishOnly must run the release gate");
|
|
189
|
+
assert(packageJson.scripts && packageJson.scripts.check && packageJson.scripts.check.includes("node scripts/check-package.js"), "package check must run the package verifier");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function assertRuntimeScriptsDoNotEscapePackage() {
|
|
193
|
+
const runtimeScripts = [packageJson.scripts && packageJson.scripts.postinstall].filter(Boolean);
|
|
194
|
+
for (const script of runtimeScripts) {
|
|
195
|
+
assert(!script.includes("../"), `runtime npm script must not reference files outside the package: ${script}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const runtimeFiles = [];
|
|
199
|
+
if (packageJson.bin) {
|
|
200
|
+
for (const relative of Object.values(packageJson.bin)) {
|
|
201
|
+
runtimeFiles.push(relative);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const relative of ["scripts/install.js", "lib/platform.js"]) {
|
|
205
|
+
const absolute = path.join(packageRoot, relative);
|
|
206
|
+
if (fs.existsSync(absolute)) {
|
|
207
|
+
runtimeFiles.push(relative);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const relative of new Set(runtimeFiles)) {
|
|
212
|
+
const text = fs.readFileSync(path.join(packageRoot, relative), "utf8");
|
|
213
|
+
assert(!text.includes('"..", ".."'), `${relative} must not resolve paths outside the package at runtime`);
|
|
214
|
+
assert(!text.includes("'..', '..'"), `${relative} must not resolve paths outside the package at runtime`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const installer = path.join(packageRoot, "scripts", "install.js");
|
|
218
|
+
if (fs.existsSync(installer)) {
|
|
219
|
+
const text = fs.readFileSync(installer, "utf8");
|
|
220
|
+
assert(text.includes("verifyChecksum"), "postinstall downloader must verify release checksums");
|
|
221
|
+
assert(text.includes("sha256"), "postinstall downloader must validate SHA-256 checksums");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function packTarball(tempDir) {
|
|
226
|
+
run("npm", ["pack", packageRoot, "--pack-destination", tempDir], { cwd: packageRoot });
|
|
227
|
+
const tarballs = fs.readdirSync(tempDir).filter((entry) => entry.endsWith(".tgz"));
|
|
228
|
+
assert(tarballs.length === 1, "npm pack must produce exactly one tarball");
|
|
229
|
+
return path.join(tempDir, tarballs[0]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function tarList(tarball) {
|
|
233
|
+
return run("tar", ["-tzf", tarball]).stdout.trim().split(/\n+/).filter(Boolean);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function tarExtract(tarball, entry) {
|
|
237
|
+
return run("tar", ["-xOzf", tarball, entry]).stdout;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function checkPacklist(tarball) {
|
|
241
|
+
const entries = tarList(tarball);
|
|
242
|
+
const allowedPrefixes = ["package/bin/", "package/lib/", "package/scripts/"];
|
|
243
|
+
const allowedExact = new Set(["package/package.json", "package/README.md"]);
|
|
244
|
+
|
|
245
|
+
for (const license of repoLicenseFiles()) {
|
|
246
|
+
allowedExact.add(`package/${license}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (const required of allowedExact) {
|
|
250
|
+
assert(entries.includes(required), `packed tarball missing ${required}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const forbiddenFragments = [
|
|
254
|
+
"node_modules",
|
|
255
|
+
"vendor/",
|
|
256
|
+
"test/",
|
|
257
|
+
".env",
|
|
258
|
+
"Cargo.",
|
|
259
|
+
"target/",
|
|
260
|
+
"package-lock.json",
|
|
261
|
+
"pnpm-lock.yaml",
|
|
262
|
+
".tgz",
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
for (const entry of entries) {
|
|
266
|
+
const allowed = allowedExact.has(entry) || allowedPrefixes.some((prefix) => entry.startsWith(prefix));
|
|
267
|
+
assert(allowed, `packed tarball contains unexpected file ${entry}`);
|
|
268
|
+
for (const fragment of forbiddenFragments) {
|
|
269
|
+
assert(!entry.includes(fragment), `packed tarball must not contain ${entry}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
assert(
|
|
274
|
+
Buffer.from(tarExtract(tarball, "package/README.md")).equals(fs.readFileSync(path.join(repoRoot, "README.md"))),
|
|
275
|
+
"packed README.md must match repo README.md",
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
for (const license of repoLicenseFiles()) {
|
|
279
|
+
assert(
|
|
280
|
+
Buffer.from(tarExtract(tarball, `package/${license}`)).equals(fs.readFileSync(path.join(repoRoot, license))),
|
|
281
|
+
`packed ${license} must match repo ${license}`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function writeSmokeBinary(destination) {
|
|
287
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
288
|
+
fs.writeFileSync(
|
|
289
|
+
destination,
|
|
290
|
+
"#!/usr/bin/env node\nprocess.stdout.write(`package-smoke-ok ${process.argv.slice(2).join(\" \")}\\n`);\n",
|
|
291
|
+
{ mode: 0o755 },
|
|
292
|
+
);
|
|
293
|
+
fs.chmodSync(destination, 0o755);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function checkInstalledBins(installRoot) {
|
|
297
|
+
const installedPackageRoot = path.join(installRoot, "node_modules", packageJson.name);
|
|
298
|
+
assert(fs.existsSync(installedPackageRoot), "tarball install must create package under node_modules");
|
|
299
|
+
|
|
300
|
+
const env = { ...process.env };
|
|
301
|
+
if (packageJson.name === "soma-rmcp") {
|
|
302
|
+
const fakeBinary = path.join(installRoot, "fake-soma");
|
|
303
|
+
writeSmokeBinary(fakeBinary);
|
|
304
|
+
env.SOMA_BIN = fakeBinary;
|
|
305
|
+
} else {
|
|
306
|
+
const platformPath = path.join(installedPackageRoot, "lib", "platform.js");
|
|
307
|
+
assert(fs.existsSync(platformPath), "downloaded-binary packages must include lib/platform.js");
|
|
308
|
+
if (fs.existsSync(platformPath)) {
|
|
309
|
+
const platform = require(platformPath);
|
|
310
|
+
writeSmokeBinary(platform.binaryPath());
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const [name, relative] of Object.entries(packageJson.bin || {})) {
|
|
315
|
+
const binPath = path.join(installedPackageRoot, relative);
|
|
316
|
+
const result = spawnSync(process.execPath, [binPath, "--package-smoke"], {
|
|
317
|
+
cwd: installRoot,
|
|
318
|
+
env,
|
|
319
|
+
encoding: "utf8",
|
|
320
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
321
|
+
});
|
|
322
|
+
const output = `${result.stdout || ""}${result.stderr || ""}`;
|
|
323
|
+
assert(result.status === 0, `${name} bin smoke exited ${result.status}: ${output.trim()}`);
|
|
324
|
+
assert(output.includes("package-smoke-ok --package-smoke"), `${name} bin smoke did not invoke the installed binary`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function checkTarballInstallSmoke(tarball, tempDir) {
|
|
329
|
+
const installRoot = path.join(tempDir, "consumer");
|
|
330
|
+
fs.mkdirSync(installRoot, { recursive: true });
|
|
331
|
+
run("npm", ["install", "--ignore-scripts", "--prefix", installRoot, tarball], { cwd: tempDir });
|
|
332
|
+
checkInstalledBins(installRoot);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function supportedTargets(platform) {
|
|
336
|
+
const tuples = [
|
|
337
|
+
["linux", "x64"],
|
|
338
|
+
["win32", "x64"],
|
|
339
|
+
["darwin", "x64"],
|
|
340
|
+
["darwin", "arm64"],
|
|
341
|
+
];
|
|
342
|
+
const targets = [];
|
|
343
|
+
for (const [osName, arch] of tuples) {
|
|
344
|
+
try {
|
|
345
|
+
const target = platform.targetFor(osName, arch);
|
|
346
|
+
targets.push({ osName, arch, target });
|
|
347
|
+
} catch (_) {
|
|
348
|
+
// Unsupported target; ignore.
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return targets;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function requestHead(url, redirects = 0) {
|
|
355
|
+
return new Promise((resolve, reject) => {
|
|
356
|
+
const request = https.request(url, { method: "HEAD" }, (response) => {
|
|
357
|
+
if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
|
|
358
|
+
response.resume();
|
|
359
|
+
if (redirects > 8) {
|
|
360
|
+
reject(new Error(`too many redirects for ${url}`));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
requestHead(new URL(response.headers.location, url).toString(), redirects + 1).then(resolve, reject);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
response.resume();
|
|
368
|
+
resolve(response.statusCode);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
request.setTimeout(15000, () => {
|
|
372
|
+
request.destroy(new Error(`timeout checking ${url}`));
|
|
373
|
+
});
|
|
374
|
+
request.on("error", reject);
|
|
375
|
+
request.end();
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function requestText(url, redirects = 0) {
|
|
380
|
+
return new Promise((resolve, reject) => {
|
|
381
|
+
https
|
|
382
|
+
.get(url, (response) => {
|
|
383
|
+
if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
|
|
384
|
+
response.resume();
|
|
385
|
+
if (redirects > 8) {
|
|
386
|
+
reject(new Error(`too many redirects for ${url}`));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
requestText(new URL(response.headers.location, url).toString(), redirects + 1).then(resolve, reject);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
394
|
+
response.resume();
|
|
395
|
+
reject(new Error(`${url} returned ${response.statusCode}`));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
let body = "";
|
|
400
|
+
response.setEncoding("utf8");
|
|
401
|
+
response.on("data", (chunk) => {
|
|
402
|
+
body += chunk;
|
|
403
|
+
});
|
|
404
|
+
response.on("end", () => resolve(body));
|
|
405
|
+
})
|
|
406
|
+
.on("error", reject)
|
|
407
|
+
.setTimeout(15000, function onTimeout() {
|
|
408
|
+
this.destroy(new Error(`timeout checking ${url}`));
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function checksumManifestUrl(assetUrl) {
|
|
414
|
+
return assetUrl.replace(/\/[^/]+$/, "/SHA256SUMS");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function hasChecksumFor(url, asset) {
|
|
418
|
+
const sidecarStatus = await requestHead(`${url}.sha256`);
|
|
419
|
+
if (sidecarStatus >= 200 && sidecarStatus < 300) {
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const manifestUrl = checksumManifestUrl(url);
|
|
424
|
+
const manifest = await requestText(manifestUrl);
|
|
425
|
+
return manifest
|
|
426
|
+
.trim()
|
|
427
|
+
.split(/\r?\n/)
|
|
428
|
+
.some((line) => {
|
|
429
|
+
const parts = line.trim().split(/\s+/);
|
|
430
|
+
const hash = parts[0] && parts[0].toLowerCase();
|
|
431
|
+
const name = parts.slice(1).join(" ").replace(/^\*/, "");
|
|
432
|
+
return /^[a-f0-9]{64}$/.test(hash) && path.basename(name) === asset;
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function checkReleaseAssets() {
|
|
437
|
+
if (skipReleaseAssets || !releaseMode) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const platformPath = path.join(packageRoot, "lib", "platform.js");
|
|
442
|
+
if (!fs.existsSync(platformPath)) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const platform = require(platformPath);
|
|
447
|
+
if (typeof platform.downloadUrl !== "function" || typeof platform.targetFor !== "function") {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
for (const { osName, arch, target } of supportedTargets(platform)) {
|
|
452
|
+
const url = platform.downloadUrl(target);
|
|
453
|
+
const status = await requestHead(url);
|
|
454
|
+
assert(status >= 200 && status < 300, `release asset missing for ${osName}/${arch}: ${url} returned ${status}`);
|
|
455
|
+
assert(await hasChecksumFor(url, target.asset), `release checksum missing for ${osName}/${arch}: ${target.asset}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function main() {
|
|
460
|
+
checkSyncedFiles();
|
|
461
|
+
checkMetadata();
|
|
462
|
+
assertRuntimeScriptsDoNotEscapePackage();
|
|
463
|
+
|
|
464
|
+
const packageTempLabel = packageJson.name.replace(/[^A-Za-z0-9._-]/g, "-");
|
|
465
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `${packageTempLabel}-package-check-`));
|
|
466
|
+
try {
|
|
467
|
+
const tarball = packTarball(tempDir);
|
|
468
|
+
checkPacklist(tarball);
|
|
469
|
+
checkTarballInstallSmoke(tarball, tempDir);
|
|
470
|
+
} finally {
|
|
471
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
await checkReleaseAssets();
|
|
475
|
+
|
|
476
|
+
if (failures.length > 0) {
|
|
477
|
+
for (const failure of failures) {
|
|
478
|
+
process.stderr.write(`package-check: ${failure}\n`);
|
|
479
|
+
}
|
|
480
|
+
process.exitCode = 1;
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
process.stdout.write(`package-check: ${packageJson.name} ok\n`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
main().catch((error) => {
|
|
488
|
+
process.stderr.write(`package-check: ${error.message}\n`);
|
|
489
|
+
process.exitCode = 1;
|
|
490
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const http = require("node:http");
|
|
6
|
+
const https = require("node:https");
|
|
7
|
+
const os = require("node:os");
|
|
8
|
+
const path = require("node:path");
|
|
9
|
+
const { spawnSync } = require("node:child_process");
|
|
10
|
+
const { binaryPath, downloadUrl, installRoot, releaseVersion, targetFor } = require("../lib/platform");
|
|
11
|
+
function log(message) { process.stderr.write(`unraid-rmcp: ${message}\n`); }
|
|
12
|
+
function download(url, destination) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const client = url.startsWith("http:") ? http : https;
|
|
15
|
+
const request = client.get(url, (response) => {
|
|
16
|
+
if ([301, 302, 303, 307, 308].includes(response.statusCode)) { response.resume(); download(response.headers.location, destination).then(resolve, reject); return; }
|
|
17
|
+
if (response.statusCode !== 200) { response.resume(); reject(new Error(`download failed (${response.statusCode}) from ${url}`)); return; }
|
|
18
|
+
const file = fs.createWriteStream(destination, { mode: 0o600 });
|
|
19
|
+
response.pipe(file);
|
|
20
|
+
file.on("finish", () => file.close(resolve));
|
|
21
|
+
file.on("error", reject);
|
|
22
|
+
});
|
|
23
|
+
request.on("error", reject);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function sha256(file) {
|
|
27
|
+
const hash = crypto.createHash("sha256");
|
|
28
|
+
hash.update(fs.readFileSync(file));
|
|
29
|
+
return hash.digest("hex");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function checksumFromText(text, asset) {
|
|
33
|
+
const lines = text.trim().split(/\r?\n/).filter(Boolean);
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
const parts = line.trim().split(/\s+/);
|
|
36
|
+
const hash = parts[0] && parts[0].toLowerCase();
|
|
37
|
+
const name = parts.slice(1).join(" ").replace(/^\*/, "");
|
|
38
|
+
if (/^[a-f0-9]{64}$/.test(hash) && (!asset || !name || path.basename(name) === asset)) {
|
|
39
|
+
return hash;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
throw new Error("checksum file does not contain a SHA-256 entry for " + asset);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function verifyChecksum(url, archive) {
|
|
46
|
+
const asset = path.basename(archive);
|
|
47
|
+
const sidecarFile = archive + ".sha256";
|
|
48
|
+
let expected;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await download(url + ".sha256", sidecarFile);
|
|
52
|
+
expected = checksumFromText(fs.readFileSync(sidecarFile, "utf8"), asset);
|
|
53
|
+
} catch (sidecarError) {
|
|
54
|
+
const manifestUrl = url.replace(/\/[^/]+$/, "/SHA256SUMS");
|
|
55
|
+
const manifestFile = archive + ".SHA256SUMS";
|
|
56
|
+
await download(manifestUrl, manifestFile);
|
|
57
|
+
expected = checksumFromText(fs.readFileSync(manifestFile, "utf8"), asset);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const actual = sha256(archive);
|
|
61
|
+
if (actual !== expected) {
|
|
62
|
+
throw new Error("checksum mismatch for " + asset + ": expected " + expected + ", got " + actual);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
log("verified checksum for " + asset);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function extract(archive, destination) {
|
|
69
|
+
fs.rmSync(destination, { recursive: true, force: true });
|
|
70
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
71
|
+
const result = spawnSync("tar", ["-xzf", archive, "-C", destination], { encoding: "utf8" });
|
|
72
|
+
if (result.status !== 0) throw new Error((result.stderr || result.stdout || "tar extraction failed").trim());
|
|
73
|
+
}
|
|
74
|
+
async function main() {
|
|
75
|
+
if (process.env.UNRAID_RMCP_SKIP_DOWNLOAD === "1") { log("skipping binary download because UNRAID_RMCP_SKIP_DOWNLOAD=1"); return; }
|
|
76
|
+
const target = targetFor();
|
|
77
|
+
const destination = binaryPath();
|
|
78
|
+
if (fs.existsSync(destination)) { log(`${path.basename(destination)} already installed for ${releaseVersion()}`); return; }
|
|
79
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "unraid-rmcp-install-"));
|
|
80
|
+
const archive = path.join(tempDir, target.asset);
|
|
81
|
+
try {
|
|
82
|
+
const url = downloadUrl(target);
|
|
83
|
+
log(`downloading ${url}`);
|
|
84
|
+
await download(url, archive);
|
|
85
|
+
await verifyChecksum(url, archive);
|
|
86
|
+
extract(archive, installRoot());
|
|
87
|
+
fs.chmodSync(destination, 0o755);
|
|
88
|
+
log(`installed ${destination}`);
|
|
89
|
+
} finally { fs.rmSync(tempDir, { recursive: true, force: true }); }
|
|
90
|
+
}
|
|
91
|
+
main().catch((error) => { log(error.message); process.exitCode = 1; });
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
|
|
6
|
+
const packageReadme = path.resolve(__dirname, "..", "README.md");
|
|
7
|
+
const repoReadme = path.resolve(__dirname, "..", "..", "..", "README.md");
|
|
8
|
+
const packageRoot = path.resolve(__dirname, "..");
|
|
9
|
+
const repoRoot = path.resolve(__dirname, "..", "..", "..");
|
|
10
|
+
|
|
11
|
+
if (fs.existsSync(packageReadme) && fs.lstatSync(packageReadme).isSymbolicLink()) {
|
|
12
|
+
fs.unlinkSync(packageReadme);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
fs.copyFileSync(repoReadme, packageReadme);
|
|
16
|
+
|
|
17
|
+
for (const entry of fs.readdirSync(repoRoot)) {
|
|
18
|
+
const source = path.join(repoRoot, entry);
|
|
19
|
+
if (/^licen[cs]e/i.test(entry) && fs.statSync(source).isFile()) {
|
|
20
|
+
const destination = path.join(packageRoot, entry);
|
|
21
|
+
if (fs.existsSync(destination) && fs.lstatSync(destination).isSymbolicLink()) {
|
|
22
|
+
fs.unlinkSync(destination);
|
|
23
|
+
}
|
|
24
|
+
fs.copyFileSync(source, destination);
|
|
25
|
+
}
|
|
26
|
+
}
|