@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 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);
@@ -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
+ }