@koller-nexus/vps-ops-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 koller-nexus / William Koller
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,219 @@
1
+ # vps-ops-mcp
2
+
3
+ Servidor [MCP](https://modelcontextprotocol.io) (stdio) que opera uma VPS por SSH. O Cursor e o Codex sobem o processo com `bun` e chamam ferramentas de leitura (saúde do host, debug Unix, Docker, Compose, Swarm, firewall) e de mutação (restart, stop, start, prune), com confirmação explícita nas mutações.
4
+
5
+ O transporte é stdio. Não abra o servidor como um processo longo na mão: o cliente (Cursor ou Codex) é quem o inicia.
6
+
7
+ ## Contribuição
8
+
9
+ Veja [CONTRIBUTING.md](./CONTRIBUTING.md) (branches, pull requests, verificação) e [ISSUE.md](./ISSUE.md) (como abrir uma issue).
10
+
11
+ ## Requisitos
12
+
13
+ - [Bun](https://bun.sh)
14
+ - Python 3 (usado pelos scripts de registro)
15
+ - Cliente OpenSSH (`ssh` no `PATH`)
16
+ - Chave privada SSH legível, com acesso ao usuário remoto
17
+ - No host remoto: Docker (e `sudo -n` para `ufw`, `fail2ban`, `sshd -T`, `ss` e `dmesg`, se for usar essas ferramentas)
18
+
19
+ ## Configuração
20
+
21
+ ```bash
22
+ cp .env.example .env
23
+ ```
24
+
25
+ Edite `.env`. O arquivo está no `.gitignore`.
26
+
27
+ | Variável | Obrigatória | Padrão | Função |
28
+ | --- | --- | --- | --- |
29
+ | `VPS_SSH_KEY_PATH` | sim | — | Caminho absoluto da chave privada. O processo recusa subir se o arquivo não existir ou não for legível. |
30
+ | `VPS_HOST` | não | `vps.example.invalid` | Host SSH (placeholder; set your own host). |
31
+ | `VPS_USER` | não | `ubuntu` | Usuário SSH. |
32
+ | `VPS_PORT` | não | `22` | Porta SSH. |
33
+ | `VPS_COMPOSE_DIR` | não | — | Diretório absoluto do Compose **na VPS**. Sem isso, as ferramentas de Compose exigem o argumento `dir`. |
34
+ | `VPS_COMMAND_TIMEOUT_MS` | não | `30000` | Timeout do comando remoto. Estouro devolve `exit_code` 124. |
35
+ | `VPS_LOG_MAX_BYTES` | não | `200000` | Teto de `stdout`/`stderr`. O excesso é cortado e `truncated` fica `true`. |
36
+ | `VPS_ALLOW_MUTATIONS` | não | `true` | `false`, `0`, `no` ou `off` desliga todas as mutações. |
37
+ | `VPS_SSH_KEY_PASSPHRASE` | não | — | Evite. Prefira `ssh-agent`. Os scripts de registro **não** copiam esta variável para o cliente. |
38
+
39
+ `VPS_COMPOSE_DIR` precisa ser absoluto e casar com `/^[a-zA-Z0-9/_.-]+$/` (começa com `/`).
40
+
41
+ ## Instalação
42
+
43
+ ```bash
44
+ bun install
45
+ ```
46
+
47
+ Pacote npm (requer [Bun](https://bun.sh); o registry MCP aponta para este artefato):
48
+
49
+ ```bash
50
+ bunx @koller-nexus/vps-ops-mcp
51
+ ```
52
+
53
+ Nome no MCP Registry: `io.github.koller-nexus/vps-ops-mcp`. O registry só publica metadados depois do pacote existir no npm público.
54
+
55
+ ## Registrar nos clientes
56
+
57
+ Os scripts gravam a configuração do MCP com as variáveis **já exportadas no shell**. Eles não leem `.env` sozinhos. Sem exportar, entram os padrões do script (host, usuário, porta e um caminho de chave local).
58
+
59
+ Faça isto uma vez, na raiz do repositório, antes de cada script:
60
+
61
+ ```bash
62
+ set -a
63
+ source .env
64
+ set +a
65
+ ```
66
+
67
+ Cada execução faz backup do arquivo de destino (`*.bak.YYYYMMDDHHMMSS`) e substitui só o servidor `vps-ops`. Os outros servidores MCP permanecem.
68
+
69
+ Variáveis opcionais dos scripts:
70
+
71
+ | Variável | Padrão | Função |
72
+ | --- | --- | --- |
73
+ | `MCP_PROJECT_DIR` | raiz deste repositório | De onde sai o caminho de `src/index.ts`. |
74
+ | `CURSOR_MCP_JSON` | `~/.cursor/mcp.json` | Arquivo do Cursor a atualizar. |
75
+ | `CODEX_CONFIG` | `~/.codex/config.toml` | Arquivo do Codex a atualizar. |
76
+
77
+ `VPS_COMPOSE_DIR` só entra na config do cliente se estiver definida e não vazia.
78
+
79
+ ### Cursor
80
+
81
+ Registro global (vale em qualquer workspace):
82
+
83
+ ```bash
84
+ ./scripts/register-cursor-mcp.sh
85
+ ```
86
+
87
+ O script escreve em `~/.cursor/mcp.json`, no formato:
88
+
89
+ ```json
90
+ {
91
+ "mcpServers": {
92
+ "vps-ops": {
93
+ "command": "bun",
94
+ "args": ["/caminho/absoluto/vps-ops-mcp/src/index.ts"],
95
+ "env": {
96
+ "VPS_HOST": "seu.host",
97
+ "VPS_USER": "ubuntu",
98
+ "VPS_PORT": "22",
99
+ "VPS_SSH_KEY_PATH": "/caminho/absoluto/chave",
100
+ "VPS_COMMAND_TIMEOUT_MS": "30000",
101
+ "VPS_LOG_MAX_BYTES": "200000",
102
+ "VPS_ALLOW_MUTATIONS": "true"
103
+ }
104
+ }
105
+ }
106
+ }
107
+ ```
108
+
109
+ Para limitar a um projeto, aponte o script para o `mcp.json` desse projeto:
110
+
111
+ ```bash
112
+ CURSOR_MCP_JSON="/caminho/do/projeto/.cursor/mcp.json" ./scripts/register-cursor-mcp.sh
113
+ ```
114
+
115
+ Depois: recarregue a janela do Cursor (Command Palette → **Developer: Reload Window**) ou reinicie o servidor em **Settings → MCP**. O servidor aparece como `vps-ops`.
116
+
117
+ ### Codex
118
+
119
+ ```bash
120
+ ./scripts/register-codex-mcp.sh
121
+ ```
122
+
123
+ O script escreve em `~/.codex/config.toml`:
124
+
125
+ ```toml
126
+ [mcp_servers.vps-ops]
127
+ command = "bun"
128
+ args = ["/caminho/absoluto/vps-ops-mcp/src/index.ts"]
129
+
130
+ [mcp_servers.vps-ops.env]
131
+ VPS_HOST = "seu.host"
132
+ VPS_USER = "ubuntu"
133
+ VPS_PORT = "22"
134
+ VPS_SSH_KEY_PATH = "/caminho/absoluto/chave"
135
+ VPS_COMMAND_TIMEOUT_MS = "30000"
136
+ VPS_LOG_MAX_BYTES = "200000"
137
+ VPS_ALLOW_MUTATIONS = "true"
138
+ ```
139
+
140
+ Feche e abra a sessão do Codex para ele reler o `config.toml`. Se o CLI estiver no `PATH`, `codex mcp list` deve mostrar `vps-ops`.
141
+
142
+ ## Verificar
143
+
144
+ Teste o SSH fora do MCP (os mesmos flags que o servidor usa):
145
+
146
+ ```bash
147
+ ssh -i "$VPS_SSH_KEY_PATH" \
148
+ -o BatchMode=yes \
149
+ -o IdentitiesOnly=yes \
150
+ -o StrictHostKeyChecking=accept-new \
151
+ -p "${VPS_PORT:-22}" \
152
+ "${VPS_USER}@${VPS_HOST}" \
153
+ 'uname -a'
154
+ ```
155
+
156
+ No Cursor ou no Codex, peça para chamar `vps_ping`. A resposta é JSON:
157
+
158
+ ```json
159
+ {
160
+ "exit_code": 0,
161
+ "stdout": "...",
162
+ "stderr": "",
163
+ "duration_ms": 0,
164
+ "truncated": false
165
+ }
166
+ ```
167
+
168
+ `exit_code` diferente de 0 marca a chamada como erro no MCP. Se o processo sair na hora com `VPS_SSH_KEY_PATH is required` ou `missing or unreadable`, a variável não chegou no `env` do cliente — rode o script de registro de novo com o `.env` exportado.
169
+
170
+ ## Ferramentas
171
+
172
+ Toda chamada devolve `exit_code`, `stdout`, `stderr`, `duration_ms` e `truncated`.
173
+
174
+ ### Leitura
175
+
176
+ | Ferramenta | Argumentos | O que faz |
177
+ | --- | --- | --- |
178
+ | `vps_ping` | — | `uname -a`, `uptime`, `hostname`. |
179
+ | `vps_resources` | — | `df -h`, `free -h`, load average. |
180
+ | `vps_journal` | `unit`, `n?` (1–500, padrão 100) | `journalctl -u`. Unidade da lista (`docker`, `sshd`, `fail2ban`, `ufw`, `cron`, com ou sem `.service`) ou um nome seguro terminado em `.service`. |
181
+ | `docker_ps` | — | `docker ps -a` em JSON lines. |
182
+ | `docker_inspect` | `name` | `docker inspect`. |
183
+ | `docker_logs` | `name`, `n?` (1–1000, padrão 200), `since?` | `docker logs --tail --timestamps`. |
184
+ | `docker_stats` | — | `docker stats --no-stream`. |
185
+ | `docker_service_ls` | — | `docker service ls` em JSON lines (Swarm). |
186
+ | `docker_node_ls` | — | `docker node ls` em JSON lines (Swarm). |
187
+ | `compose_ps` | `dir?` | `docker compose ps` em `dir` ou `VPS_COMPOSE_DIR`. |
188
+ | `host_listen` | — | `ss -lntup` (`sudo -n`, senão sem sudo). |
189
+ | `host_failed_units` | — | `systemctl --failed --no-pager --full`. |
190
+ | `host_top` | — | Top 30 processos por memória (`ps aux --sort=-%mem`). |
191
+ | `host_dmesg` | `n?` (1–200, padrão 100) | `dmesg -T` + `tail` (`sudo -n`, senão sem sudo). |
192
+ | `host_firewall` | — | `ufw status verbose` (`sudo -n`, senão sem sudo). |
193
+ | `host_fail2ban` | `jail?` | `fail2ban-client status` (`sudo -n`). |
194
+ | `ssh_hardening_check` | — | `sshd -T` filtrado: porta, password, root login, pubkey. |
195
+
196
+ Nomes de container, serviço, imagem e jail precisam casar com `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`.
197
+
198
+ ### Mutação
199
+
200
+ Exigem `confirm: true`. Com `VPS_ALLOW_MUTATIONS=false`, todas são recusadas.
201
+
202
+ | Ferramenta | Argumentos extra | Comando remoto |
203
+ | --- | --- | --- |
204
+ | `docker_restart` | `name` | `docker restart` |
205
+ | `docker_stop` | `name` | `docker stop` |
206
+ | `docker_start` | `name` | `docker start` |
207
+ | `compose_up` | `dir?`, `services?` | `docker compose up -d` |
208
+ | `compose_restart` | `dir?`, `services?` | `docker compose restart` |
209
+ | `compose_pull_up` | `dir?`, `services?` | `docker compose pull` e depois `up -d` |
210
+ | `docker_rm` | `name`, `force_name` | `docker rm -f`. `force_name` tem de ser igual a `name`. |
211
+ | `disk_cleanup_docker` | `confirm_volumes?` | `docker system prune -f`. Volumes só com `confirm_volumes: true`. |
212
+
213
+ ## Segurança
214
+
215
+ - Comandos remotos são fixos. Não existe ferramenta de shell livre.
216
+ - Argumentos de nome e caminho passam por allowlist e são citados no shell.
217
+ - SSH usa `BatchMode=yes`, `IdentitiesOnly=yes` e `StrictHostKeyChecking=accept-new`.
218
+ - Mutação sem `confirm: true` é recusada. `docker_rm` pede o nome duas vezes. Prune de volumes pede `confirm_volumes: true`.
219
+ - Para um cliente só de leitura, registre com `VPS_ALLOW_MUTATIONS=false`.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@koller-nexus/vps-ops-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Stdio MCP server that inspects and (with confirmation) mutates one VPS over SSH.",
5
+ "mcpName": "io.github.koller-nexus/vps-ops-mcp",
6
+ "module": "src/index.ts",
7
+ "type": "module",
8
+ "bin": {
9
+ "vps-ops-mcp": "src/index.ts"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "server.json",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/koller-nexus/vps-ops-mcp.git"
20
+ },
21
+ "license": "MIT",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "engines": {
26
+ "bun": ">=1.2.0"
27
+ },
28
+ "scripts": {
29
+ "test": "bun test",
30
+ "build": "bun build src/index.ts --outdir dist --target bun"
31
+ },
32
+ "devDependencies": {
33
+ "@types/bun": "latest"
34
+ },
35
+ "peerDependencies": {
36
+ "typescript": "^7"
37
+ },
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.30.0",
40
+ "zod": "^4.6.5"
41
+ }
42
+ }
package/server.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.koller-nexus/vps-ops-mcp",
4
+ "title": "VPS Ops",
5
+ "description": "Stdio MCP server that inspects and (with confirmation) mutates one VPS over SSH.",
6
+ "repository": {
7
+ "url": "https://github.com/koller-nexus/vps-ops-mcp",
8
+ "source": "github"
9
+ },
10
+ "version": "0.1.0",
11
+ "websiteUrl": "https://github.com/koller-nexus/vps-ops-mcp",
12
+ "packages": [
13
+ {
14
+ "registryType": "npm",
15
+ "identifier": "@koller-nexus/vps-ops-mcp",
16
+ "version": "0.1.0",
17
+ "runtimeHint": "bunx",
18
+ "transport": {
19
+ "type": "stdio"
20
+ },
21
+ "environmentVariables": [
22
+ {
23
+ "name": "VPS_SSH_KEY_PATH",
24
+ "description": "Absolute path to a readable SSH private key",
25
+ "isRequired": true,
26
+ "isSecret": true,
27
+ "format": "filepath"
28
+ },
29
+ {
30
+ "name": "VPS_HOST",
31
+ "description": "SSH hostname of the single target VPS",
32
+ "isRequired": false,
33
+ "format": "string"
34
+ },
35
+ {
36
+ "name": "VPS_USER",
37
+ "description": "SSH username",
38
+ "isRequired": false,
39
+ "format": "string"
40
+ },
41
+ {
42
+ "name": "VPS_PORT",
43
+ "description": "SSH port",
44
+ "isRequired": false,
45
+ "format": "number"
46
+ },
47
+ {
48
+ "name": "VPS_COMPOSE_DIR",
49
+ "description": "Absolute Compose directory on the remote host",
50
+ "isRequired": false,
51
+ "format": "string"
52
+ },
53
+ {
54
+ "name": "VPS_COMMAND_TIMEOUT_MS",
55
+ "description": "Remote command timeout in milliseconds",
56
+ "isRequired": false,
57
+ "format": "number"
58
+ },
59
+ {
60
+ "name": "VPS_LOG_MAX_BYTES",
61
+ "description": "Stdout/stderr byte cap for remote commands",
62
+ "isRequired": false,
63
+ "format": "number"
64
+ },
65
+ {
66
+ "name": "VPS_ALLOW_MUTATIONS",
67
+ "description": "Set false to reject every mutation tool",
68
+ "isRequired": false,
69
+ "format": "boolean"
70
+ },
71
+ {
72
+ "name": "VPS_SSH_KEY_PASSPHRASE",
73
+ "description": "Optional key passphrase; prefer ssh-agent instead",
74
+ "isRequired": false,
75
+ "isSecret": true,
76
+ "format": "string"
77
+ }
78
+ ]
79
+ }
80
+ ]
81
+ }
@@ -0,0 +1,125 @@
1
+ import { expect, test } from "bun:test";
2
+ import { resolve } from "node:path";
3
+
4
+ const agentsPath = resolve(import.meta.dir, "..", "AGENTS.md");
5
+
6
+ const REQUIRED_HEADINGS = [
7
+ "# VPS Ops MCP Agent Construction Guide",
8
+ "## Purpose",
9
+ "## Authority",
10
+ "## Skill routing",
11
+ "## Product shape",
12
+ "## Operator capabilities",
13
+ "## Proof",
14
+ "## Delivery and operations",
15
+ "## Public release",
16
+ "## Construction sequence",
17
+ "## Anti-patterns",
18
+ "## Human setup",
19
+ ] as const;
20
+
21
+ const US1_TOKENS = [
22
+ "constitution wins",
23
+ "mcp-builder",
24
+ ".agents/skills/mcp-builder/SKILL.md",
25
+ "devops-engineer",
26
+ ".agents/skills/devops-engineer/SKILL.md",
27
+ "opensource-pipeline",
28
+ ".agents/skills/opensource-pipeline/SKILL.md",
29
+ "speckit-",
30
+ "test-driven-development",
31
+ "verification-before-completion",
32
+ "client-launched",
33
+ "README.md",
34
+ ] as const;
35
+
36
+ const US2_TOKENS = [
37
+ "vps_",
38
+ "confirm:true",
39
+ "VPS_ALLOW_MUTATIONS",
40
+ "readOnlyHint",
41
+ "destructiveHint",
42
+ "idempotentHint",
43
+ "openWorldHint",
44
+ "next corrective",
45
+ "bun test",
46
+ "timeout",
47
+ "byte cap",
48
+ ] as const;
49
+
50
+ const US3_TOKENS = [
51
+ "explicit human approval",
52
+ "rollback",
53
+ "MUST NOT apply undocumented host",
54
+ ] as const;
55
+
56
+ const US4_TOKENS = ["sanitizer", "staging", "MUST NOT commit secrets"] as const;
57
+
58
+ async function loadAgents(): Promise<string> {
59
+ const file = Bun.file(agentsPath);
60
+ if (!(await file.exists())) {
61
+ throw new Error("AGENTS.md is missing");
62
+ }
63
+ return file.text();
64
+ }
65
+
66
+ function sectionBetween(text: string, start: string, end: string): string {
67
+ const from = text.indexOf(start);
68
+ const to = text.indexOf(end);
69
+ expect(from).toBeGreaterThan(-1);
70
+ expect(to).toBeGreaterThan(from);
71
+ return text.slice(from, to);
72
+ }
73
+
74
+ function expectTokens(text: string, tokens: readonly string[]): void {
75
+ for (const token of tokens) {
76
+ expect(text.includes(token), token).toBe(true);
77
+ }
78
+ }
79
+
80
+ test("AGENTS.md exists", async () => {
81
+ expect(await Bun.file(agentsPath).exists()).toBe(true);
82
+ });
83
+
84
+ test("AGENTS.md headings appear in contract order", async () => {
85
+ const text = await loadAgents();
86
+ let last = -1;
87
+ for (const heading of REQUIRED_HEADINGS) {
88
+ const idx = text.indexOf(heading);
89
+ expect(idx).toBeGreaterThan(last);
90
+ last = idx;
91
+ }
92
+ });
93
+
94
+ test("AGENTS.md includes US1 construction tokens", async () => {
95
+ expectTokens(await loadAgents(), US1_TOKENS);
96
+ });
97
+
98
+ test("AGENTS.md includes US2 operator and proof tokens", async () => {
99
+ expectTokens(await loadAgents(), US2_TOKENS);
100
+ });
101
+
102
+ test("Proof section forbids a live host as automated proof", async () => {
103
+ const proof = sectionBetween(
104
+ await loadAgents(),
105
+ "## Proof",
106
+ "## Delivery and operations"
107
+ );
108
+ expect(proof.includes("MUST NOT")).toBe(true);
109
+ expect(proof.includes("live host")).toBe(true);
110
+ });
111
+
112
+ test("AGENTS.md includes US3 delivery tokens", async () => {
113
+ expectTokens(await loadAgents(), US3_TOKENS);
114
+ });
115
+
116
+ test("AGENTS.md includes US4 public-release tokens", async () => {
117
+ expectTokens(await loadAgents(), US4_TOKENS);
118
+ });
119
+
120
+ test("AGENTS.md has no live secrets or host identity", async () => {
121
+ const text = await loadAgents();
122
+ expect(text).not.toMatch(/\b\d{1,3}(?:\.\d{1,3}){3}\b/);
123
+ expect(text).not.toMatch(/\/Users\/\S+\.(pem|key)|BEGIN (OPENSSH |RSA )?PRIVATE KEY/i);
124
+ expect(text).not.toMatch(/VPS_SSH_KEY_PASSPHRASE=\S+/);
125
+ });
@@ -0,0 +1,101 @@
1
+ import { afterEach, test, expect } from "bun:test";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { loadConfig } from "./config.js";
6
+
7
+ const KEYS = [
8
+ "VPS_HOST",
9
+ "VPS_USER",
10
+ "VPS_PORT",
11
+ "VPS_SSH_KEY_PATH",
12
+ "VPS_COMPOSE_DIR",
13
+ "VPS_SSH_KEY_PASSPHRASE",
14
+ "VPS_COMMAND_TIMEOUT_MS",
15
+ "VPS_LOG_MAX_BYTES",
16
+ "VPS_ALLOW_MUTATIONS",
17
+ ] as const;
18
+
19
+ const saved = new Map<string, string | undefined>();
20
+
21
+ function snapshotEnv(): void {
22
+ for (const key of KEYS) {
23
+ saved.set(key, process.env[key]);
24
+ delete process.env[key];
25
+ }
26
+ }
27
+
28
+ function restoreEnv(): void {
29
+ for (const key of KEYS) {
30
+ const value = saved.get(key);
31
+ if (value === undefined) delete process.env[key];
32
+ else process.env[key] = value;
33
+ }
34
+ }
35
+
36
+ function writeKeyFile(): string {
37
+ const dir = mkdtempSync(join(tmpdir(), "vps-ops-"));
38
+ const path = join(dir, "id_test");
39
+ writeFileSync(path, "dummy-key\n", { mode: 0o600 });
40
+ return path;
41
+ }
42
+
43
+ afterEach(() => {
44
+ restoreEnv();
45
+ });
46
+
47
+ test("requires VPS_SSH_KEY_PATH", () => {
48
+ snapshotEnv();
49
+ expect(() => loadConfig()).toThrow(/VPS_SSH_KEY_PATH is required/);
50
+ });
51
+
52
+ test("rejects unreadable SSH key path", () => {
53
+ snapshotEnv();
54
+ process.env.VPS_SSH_KEY_PATH = "/tmp/vps-ops-missing-key";
55
+ expect(() => loadConfig()).toThrow(/missing or unreadable/);
56
+ });
57
+
58
+ test("loads defaults when only the key path is set", () => {
59
+ snapshotEnv();
60
+ const keyPath = writeKeyFile();
61
+ process.env.VPS_SSH_KEY_PATH = keyPath;
62
+
63
+ const config = loadConfig();
64
+ expect(config.host).toBe("vps.example.invalid");
65
+ expect(config.user).toBe("ubuntu");
66
+ expect(config.port).toBe(22);
67
+ expect(config.sshKeyPath).toBe(keyPath);
68
+ expect(config.commandTimeoutMs).toBe(30_000);
69
+ expect(config.logMaxBytes).toBe(200_000);
70
+ expect(config.allowMutations).toBe(true);
71
+ expect(config.composeDir).toBeUndefined();
72
+ });
73
+
74
+ test("honors overrides and treats off as mutations disabled", () => {
75
+ snapshotEnv();
76
+ const keyPath = writeKeyFile();
77
+ process.env.VPS_SSH_KEY_PATH = keyPath;
78
+ process.env.VPS_HOST = "10.0.0.8";
79
+ process.env.VPS_USER = "ops";
80
+ process.env.VPS_PORT = "2222";
81
+ process.env.VPS_COMPOSE_DIR = "/opt/stack";
82
+ process.env.VPS_COMMAND_TIMEOUT_MS = "5000";
83
+ process.env.VPS_LOG_MAX_BYTES = "1000";
84
+ process.env.VPS_ALLOW_MUTATIONS = "off";
85
+
86
+ const config = loadConfig();
87
+ expect(config.host).toBe("10.0.0.8");
88
+ expect(config.user).toBe("ops");
89
+ expect(config.port).toBe(2222);
90
+ expect(config.composeDir).toBe("/opt/stack");
91
+ expect(config.commandTimeoutMs).toBe(5000);
92
+ expect(config.logMaxBytes).toBe(1000);
93
+ expect(config.allowMutations).toBe(false);
94
+ });
95
+
96
+ test("rejects invalid integer env", () => {
97
+ snapshotEnv();
98
+ process.env.VPS_SSH_KEY_PATH = writeKeyFile();
99
+ process.env.VPS_PORT = "-1";
100
+ expect(() => loadConfig()).toThrow(/Invalid VPS_PORT/);
101
+ });
package/src/config.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { accessSync, constants } from "node:fs";
2
+
3
+ export interface VpsConfig {
4
+ host: string;
5
+ user: string;
6
+ port: number;
7
+ sshKeyPath: string;
8
+ sshKeyPassphrase?: string;
9
+ composeDir?: string;
10
+ commandTimeoutMs: number;
11
+ logMaxBytes: number;
12
+ allowMutations: boolean;
13
+ }
14
+
15
+ function envBool(name: string, defaultValue: boolean): boolean {
16
+ const v = process.env[name];
17
+ if (v === undefined || v === "") return defaultValue;
18
+ return !["0", "false", "no", "off"].includes(v.toLowerCase());
19
+ }
20
+
21
+ function envInt(name: string, defaultValue: number): number {
22
+ const v = process.env[name];
23
+ if (v === undefined || v === "") return defaultValue;
24
+ const n = Number.parseInt(v, 10);
25
+ if (!Number.isFinite(n) || n < 0) {
26
+ throw new Error(`Invalid ${name}=${v}; expected non-negative integer`);
27
+ }
28
+ return n;
29
+ }
30
+
31
+ export function loadConfig(): VpsConfig {
32
+ const host = process.env.VPS_HOST?.trim() || "vps.example.invalid";
33
+ const user = process.env.VPS_USER?.trim() || "ubuntu";
34
+ const port = envInt("VPS_PORT", 22);
35
+ const sshKeyPath = process.env.VPS_SSH_KEY_PATH?.trim();
36
+
37
+ if (!sshKeyPath) {
38
+ throw new Error(
39
+ "VPS_SSH_KEY_PATH is required. Set it to the absolute path of your SSH private key."
40
+ );
41
+ }
42
+
43
+ try {
44
+ accessSync(sshKeyPath, constants.R_OK);
45
+ } catch {
46
+ throw new Error(
47
+ `VPS_SSH_KEY_PATH is missing or unreadable: ${sshKeyPath}`
48
+ );
49
+ }
50
+
51
+ const composeDir = process.env.VPS_COMPOSE_DIR?.trim() || undefined;
52
+ const sshKeyPassphrase =
53
+ process.env.VPS_SSH_KEY_PASSPHRASE?.trim() || undefined;
54
+
55
+ return {
56
+ host,
57
+ user,
58
+ port,
59
+ sshKeyPath,
60
+ sshKeyPassphrase,
61
+ composeDir,
62
+ commandTimeoutMs: envInt("VPS_COMMAND_TIMEOUT_MS", 30_000),
63
+ logMaxBytes: envInt("VPS_LOG_MAX_BYTES", 200_000),
64
+ allowMutations: envBool("VPS_ALLOW_MUTATIONS", true),
65
+ };
66
+ }