@xiaoyankonling/ssh-mcp 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +256 -0
- package/build/cli/args.js +75 -0
- package/build/config/loader.js +138 -0
- package/build/config/types.js +75 -0
- package/build/index.js +145 -0
- package/build/profile/profile-manager.js +385 -0
- package/build/ssh/command-utils.js +49 -0
- package/build/ssh/connection-manager.js +371 -0
- package/build/tools/exec.js +39 -0
- package/build/tools/profiles.js +159 -0
- package/build/tools/result.js +8 -0
- package/build/tools/sudo-exec.js +38 -0
- package/package.json +65 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Tufan Tunç
|
|
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,256 @@
|
|
|
1
|
+
# SSH MCP Server
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@xiaoyankonling/ssh-mcp)
|
|
4
|
+
[](https://www.npmjs.com/package/@xiaoyankonling/ssh-mcp)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
[](https://github.com/Bianshumeng/ssh-mcp/stargazers)
|
|
8
|
+
[](https://github.com/Bianshumeng/ssh-mcp/forks)
|
|
9
|
+
[](https://github.com/Bianshumeng/ssh-mcp/actions)
|
|
10
|
+
[](https://github.com/Bianshumeng/ssh-mcp/issues)
|
|
11
|
+
|
|
12
|
+
[](https://archestra.ai/mcp-catalog/Bianshumeng__ssh-mcp)
|
|
13
|
+
|
|
14
|
+
**SSH MCP Server** is a local Model Context Protocol (MCP) server that exposes SSH control for Linux and Windows systems, enabling LLMs and other MCP clients to execute shell commands securely via SSH.
|
|
15
|
+
|
|
16
|
+
## Contents
|
|
17
|
+
|
|
18
|
+
- [Quick Start](#quick-start)
|
|
19
|
+
- [Features](#features)
|
|
20
|
+
- [Installation](#installation)
|
|
21
|
+
- [Client Setup](#client-setup)
|
|
22
|
+
- [Testing](#testing)
|
|
23
|
+
- [Disclaimer](#disclaimer)
|
|
24
|
+
- [Support](#support)
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
- [Install](#installation) SSH MCP Server
|
|
29
|
+
- [Configure](#configuration) SSH MCP Server
|
|
30
|
+
- [Set up](#client-setup) your MCP Client (e.g. Claude Desktop, Cursor, etc)
|
|
31
|
+
- Execute remote shell commands on your Linux or Windows server via natural language
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- MCP-compliant server exposing SSH capabilities
|
|
36
|
+
- Execute shell commands on remote Linux and Windows systems
|
|
37
|
+
- Secure authentication via password or SSH key
|
|
38
|
+
- Local profile configuration via YAML/JSON files
|
|
39
|
+
- Runtime profile management tools (`profiles-list/use/reload/note-update`)
|
|
40
|
+
- Profile notes/tags for operational context
|
|
41
|
+
- Built with TypeScript and the official MCP SDK
|
|
42
|
+
- **Configurable timeout protection** with automatic process abortion
|
|
43
|
+
- **Graceful timeout handling** - attempts to kill hanging processes before closing connections
|
|
44
|
+
|
|
45
|
+
### Tools
|
|
46
|
+
|
|
47
|
+
- `exec`: Execute a shell command on the remote server
|
|
48
|
+
- **Parameters:**
|
|
49
|
+
- `command` (required): Shell command to execute on the remote SSH server
|
|
50
|
+
- `description` (optional): Optional description of what this command will do (appended as a comment)
|
|
51
|
+
- **Timeout Configuration:**
|
|
52
|
+
|
|
53
|
+
- `sudo-exec`: Execute a shell command with sudo elevation
|
|
54
|
+
- **Parameters:**
|
|
55
|
+
- `command` (required): Shell command to execute as root using sudo
|
|
56
|
+
- `description` (optional): Optional description of what this command will do (appended as a comment)
|
|
57
|
+
- **Notes:**
|
|
58
|
+
- Requires `--sudoPassword` to be set for password-protected sudo
|
|
59
|
+
- Can be disabled by passing the `--disableSudo` flag at startup if sudo access is not needed or not available
|
|
60
|
+
- For persistent root access, consider using `--suPassword` instead which establishes a root shell
|
|
61
|
+
- Tool will not be available at all if server is started with `--disableSudo`
|
|
62
|
+
- **Timeout Configuration:**
|
|
63
|
+
- Timeout is configured via command line argument `--timeout` (in milliseconds)
|
|
64
|
+
- Default timeout: 60000ms (1 minute)
|
|
65
|
+
- When a command times out, the server automatically attempts to abort the running process before closing the connection
|
|
66
|
+
- **Max Command Length Configuration:**
|
|
67
|
+
- Max command characters are configured via `--maxChars`
|
|
68
|
+
- Default: `1000`
|
|
69
|
+
- No-limit mode: set `--maxChars=none` or any `<= 0` value (e.g. `--maxChars=0`)
|
|
70
|
+
|
|
71
|
+
- `profiles-list`: List profile summaries (`id/name/host/port/note/tags/active`) with sensitive fields masked
|
|
72
|
+
- `profiles-find`: Find profile candidates by keyword across `id/name/host/user/note/tags`
|
|
73
|
+
- `profiles-use`: Switch active profile at runtime, persist `activeProfile`, and recreate SSH connection on next command
|
|
74
|
+
- `profiles-reload`: Reload profile configuration from disk and validate active profile still exists
|
|
75
|
+
- `profiles-create`: Create profile templates dynamically at runtime (note optional but recommended)
|
|
76
|
+
- `profiles-note-update`: Update and persist a concise profile note
|
|
77
|
+
- `profiles-delete-prepare`: Prepare destructive deletion (creates backup and returns confirmation payload)
|
|
78
|
+
- `profiles-delete-confirm`: Finalize deletion with exact `deleteRequestId + confirmationText`
|
|
79
|
+
|
|
80
|
+
## Installation
|
|
81
|
+
|
|
82
|
+
1. **Clone the repository:**
|
|
83
|
+
```bash
|
|
84
|
+
git clone https://github.com/Bianshumeng/ssh-mcp.git
|
|
85
|
+
cd ssh-mcp
|
|
86
|
+
```
|
|
87
|
+
2. **Install dependencies:**
|
|
88
|
+
```bash
|
|
89
|
+
npm install
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Client Setup
|
|
93
|
+
|
|
94
|
+
You can configure your IDE or LLM like Cursor, Windsurf, Claude Desktop to use this MCP Server.
|
|
95
|
+
|
|
96
|
+
### Profile Mode
|
|
97
|
+
|
|
98
|
+
This server now runs in profile mode only.
|
|
99
|
+
|
|
100
|
+
Use `--config` to load multiple SSH profiles from a local YAML/JSON file.
|
|
101
|
+
|
|
102
|
+
**Parameters:**
|
|
103
|
+
- `config`: Path to profile config file (`.yaml`, `.yml`, `.json`)
|
|
104
|
+
- `profile`: Optional profile id override for startup active profile
|
|
105
|
+
- `timeout`: Optional command timeout override in milliseconds
|
|
106
|
+
- `maxChars`: Optional command length override
|
|
107
|
+
- `disableSudo`: Optional flag to disable `sudo-exec`
|
|
108
|
+
|
|
109
|
+
**Important:**
|
|
110
|
+
- Legacy startup args (`--host`, `--user`, `--password`, `--key`, etc.) are no longer supported.
|
|
111
|
+
- Dynamic target management is done through profile tools (`profiles-list/find/use/create/delete-*`).
|
|
112
|
+
|
|
113
|
+
Example:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
npx -y @xiaoyankonling/ssh-mcp -- --config=./examples/ssh-mcp.profiles.yaml --profile=jp-relay
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Profile Configuration Format
|
|
120
|
+
|
|
121
|
+
The full example is available at `examples/ssh-mcp.profiles.yaml`.
|
|
122
|
+
|
|
123
|
+
```yaml
|
|
124
|
+
version: 1
|
|
125
|
+
activeProfile: fi-template
|
|
126
|
+
|
|
127
|
+
defaults:
|
|
128
|
+
timeout: 120000
|
|
129
|
+
maxChars: none
|
|
130
|
+
disableSudo: false
|
|
131
|
+
|
|
132
|
+
profiles:
|
|
133
|
+
- id: fi-template
|
|
134
|
+
name: 芬兰模板机
|
|
135
|
+
host: fi-template.example.com
|
|
136
|
+
port: 9900
|
|
137
|
+
user: root
|
|
138
|
+
auth:
|
|
139
|
+
type: password
|
|
140
|
+
password: "${SSH_TEMPLATE_PASSWORD}"
|
|
141
|
+
sudoPassword: "${SSH_TEMPLATE_SUDO_PASSWORD}"
|
|
142
|
+
note: "模板维护机,做 cloud-init 和预装验证"
|
|
143
|
+
tags: [template, fi]
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Notes:
|
|
147
|
+
- `${ENV_VAR}` placeholders are expanded at load time
|
|
148
|
+
- `auth.type=password` requires `auth.password`
|
|
149
|
+
- `auth.type=key` requires `auth.keyPath` and the file must exist
|
|
150
|
+
- `activeProfile` must match one of `profiles[].id`
|
|
151
|
+
- Profile list output and tool responses never expose plaintext passwords
|
|
152
|
+
|
|
153
|
+
### Dynamic Template Management Workflow
|
|
154
|
+
|
|
155
|
+
1. Create template at runtime:
|
|
156
|
+
- call `profiles-create`
|
|
157
|
+
- by default, created profile is activated immediately
|
|
158
|
+
2. Keep note short and precise:
|
|
159
|
+
- if `note` is omitted, tool can derive a concise note from `contextSummary`
|
|
160
|
+
3. Safe deletion (two-step):
|
|
161
|
+
- call `profiles-delete-prepare` first
|
|
162
|
+
- review returned profile + backup path + confirmation text with user
|
|
163
|
+
- only then call `profiles-delete-confirm`
|
|
164
|
+
|
|
165
|
+
`profiles-delete-confirm` requires exact confirmation text in the form:
|
|
166
|
+
|
|
167
|
+
```text
|
|
168
|
+
DELETE <profileId>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
This guards against accidental profile removal and ensures a backup exists before delete.
|
|
172
|
+
|
|
173
|
+
### Claude Code
|
|
174
|
+
|
|
175
|
+
You can add this MCP server to Claude Code using the `claude mcp add` command. This is the recommended method for Claude Code.
|
|
176
|
+
|
|
177
|
+
**Basic Installation:**
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
claude mcp add --transport stdio ssh-mcp -- npx -y @xiaoyankonling/ssh-mcp -- --config=/path/to/ssh-mcp.profiles.yaml
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**Installation Examples:**
|
|
184
|
+
|
|
185
|
+
**With Config File:**
|
|
186
|
+
```bash
|
|
187
|
+
claude mcp add --transport stdio ssh-mcp -- npx -y @xiaoyankonling/ssh-mcp -- --config=./examples/ssh-mcp.profiles.yaml
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**With Startup Profile Override:**
|
|
191
|
+
```bash
|
|
192
|
+
claude mcp add --transport stdio ssh-mcp -- npx -y @xiaoyankonling/ssh-mcp -- --config=./examples/ssh-mcp.profiles.yaml --profile=jp-relay
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**With Custom Timeout and No Character Limit:**
|
|
196
|
+
```bash
|
|
197
|
+
claude mcp add --transport stdio ssh-mcp -- npx -y @xiaoyankonling/ssh-mcp -- --config=./examples/ssh-mcp.profiles.yaml --timeout=120000 --maxChars=none
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**With Sudo Tool Disabled:**
|
|
201
|
+
```bash
|
|
202
|
+
claude mcp add --transport stdio ssh-mcp -- npx -y @xiaoyankonling/ssh-mcp -- --config=./examples/ssh-mcp.profiles.yaml --disableSudo
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Installation Scopes:**
|
|
206
|
+
|
|
207
|
+
You can specify the scope when adding the server:
|
|
208
|
+
|
|
209
|
+
- **Local scope** (default): For personal use in the current project
|
|
210
|
+
```bash
|
|
211
|
+
claude mcp add --transport stdio ssh-mcp --scope local -- npx -y @xiaoyankonling/ssh-mcp -- --config=/path/to/ssh-mcp.profiles.yaml
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
- **Project scope**: Share with your team via `.mcp.json` file
|
|
215
|
+
```bash
|
|
216
|
+
claude mcp add --transport stdio ssh-mcp --scope project -- npx -y @xiaoyankonling/ssh-mcp -- --config=./config/ssh-mcp.profiles.yaml
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- **User scope**: Available across all your projects
|
|
220
|
+
```bash
|
|
221
|
+
claude mcp add --transport stdio ssh-mcp --scope user -- npx -y @xiaoyankonling/ssh-mcp -- --config=/absolute/path/to/ssh-mcp.profiles.yaml
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
**Verify Installation:**
|
|
226
|
+
|
|
227
|
+
After adding the server, restart Claude Code and ask Cascade to execute a command:
|
|
228
|
+
```
|
|
229
|
+
"Can you run 'ls -la' on the remote server?"
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
For more information about MCP in Claude Code, see the [official documentation](https://docs.claude.com/en/docs/claude-code/mcp).
|
|
233
|
+
|
|
234
|
+
## Testing
|
|
235
|
+
|
|
236
|
+
You can use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) for visual debugging of this MCP Server.
|
|
237
|
+
|
|
238
|
+
```sh
|
|
239
|
+
npm run inspect
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Disclaimer
|
|
243
|
+
|
|
244
|
+
SSH MCP Server is provided under the [MIT License](./LICENSE). Use at your own risk. This project is not affiliated with or endorsed by any SSH or MCP provider.
|
|
245
|
+
|
|
246
|
+
## Contributing
|
|
247
|
+
|
|
248
|
+
We welcome contributions! Please see our [Contributing Guidelines](./CONTRIBUTING.md) for more information.
|
|
249
|
+
|
|
250
|
+
## Code of Conduct
|
|
251
|
+
|
|
252
|
+
This project follows a [Code of Conduct](./CODE_OF_CONDUCT.md) to ensure a welcoming environment for everyone.
|
|
253
|
+
|
|
254
|
+
## Support
|
|
255
|
+
|
|
256
|
+
If you find SSH MCP Server helpful, consider starring the repository or contributing! Pull requests and feedback are welcome.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { DEFAULT_MAX_CHARS, DEFAULT_TIMEOUT_MS, parseMaxChars } from '../ssh/command-utils.js';
|
|
3
|
+
const LEGACY_ARG_KEYS = [
|
|
4
|
+
'host',
|
|
5
|
+
'user',
|
|
6
|
+
'port',
|
|
7
|
+
'password',
|
|
8
|
+
'key',
|
|
9
|
+
'suPassword',
|
|
10
|
+
'sudoPassword',
|
|
11
|
+
];
|
|
12
|
+
export function parseArgv(argv = process.argv.slice(2)) {
|
|
13
|
+
const config = {};
|
|
14
|
+
for (const arg of argv) {
|
|
15
|
+
if (!arg.startsWith('--'))
|
|
16
|
+
continue;
|
|
17
|
+
const equalIndex = arg.indexOf('=');
|
|
18
|
+
if (equalIndex === -1) {
|
|
19
|
+
config[arg.slice(2)] = null;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
config[arg.slice(2, equalIndex)] = arg.slice(equalIndex + 1);
|
|
23
|
+
}
|
|
24
|
+
return config;
|
|
25
|
+
}
|
|
26
|
+
function normalizeOptionalString(value) {
|
|
27
|
+
if (typeof value !== 'string')
|
|
28
|
+
return undefined;
|
|
29
|
+
const trimmed = value.trim();
|
|
30
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
31
|
+
}
|
|
32
|
+
function parsePositiveInt(input, fallback) {
|
|
33
|
+
if (typeof input !== 'string' || input.trim() === '')
|
|
34
|
+
return fallback;
|
|
35
|
+
const parsed = Number.parseInt(input, 10);
|
|
36
|
+
if (Number.isNaN(parsed) || parsed <= 0)
|
|
37
|
+
return fallback;
|
|
38
|
+
return parsed;
|
|
39
|
+
}
|
|
40
|
+
function hasLegacyTargetArgs(config) {
|
|
41
|
+
return LEGACY_ARG_KEYS.some((key) => config[key] !== undefined);
|
|
42
|
+
}
|
|
43
|
+
export function validateConfig(config) {
|
|
44
|
+
const configPath = normalizeOptionalString(config.config);
|
|
45
|
+
if (!configPath) {
|
|
46
|
+
throw new Error('Configuration error:\nMissing required --config=<path>. Legacy --host/--user startup is no longer supported.');
|
|
47
|
+
}
|
|
48
|
+
if (hasLegacyTargetArgs(config)) {
|
|
49
|
+
throw new Error('Configuration error:\nLegacy target args (--host/--user/--password/--key/...) are no longer supported. Use --config and manage targets via profiles tools.');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function resolveRuntimeOptions(config, defaults) {
|
|
53
|
+
const timeoutMs = parsePositiveInt(config.timeout, defaults?.timeout ?? DEFAULT_TIMEOUT_MS);
|
|
54
|
+
const maxCharsSource = config.maxChars ?? defaults?.maxChars;
|
|
55
|
+
const maxChars = parseMaxChars(maxCharsSource, DEFAULT_MAX_CHARS);
|
|
56
|
+
const disableSudo = config.disableSudo !== undefined
|
|
57
|
+
? true
|
|
58
|
+
: Boolean(defaults?.disableSudo ?? false);
|
|
59
|
+
return {
|
|
60
|
+
timeoutMs,
|
|
61
|
+
maxChars,
|
|
62
|
+
disableSudo,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function determineStartupMode(config) {
|
|
66
|
+
validateConfig(config);
|
|
67
|
+
const configPath = normalizeOptionalString(config.config);
|
|
68
|
+
const profileIdOverride = normalizeOptionalString(config.profile);
|
|
69
|
+
return {
|
|
70
|
+
mode: 'profile',
|
|
71
|
+
configPath: path.resolve(configPath),
|
|
72
|
+
profileIdOverride,
|
|
73
|
+
runtime: resolveRuntimeOptions(config),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { access, readFile, writeFile } from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
4
|
+
import { profilesConfigSchema, } from './types.js';
|
|
5
|
+
const ENV_PATTERN = /\$\{([A-Z0-9_]+)\}/g;
|
|
6
|
+
function inferFormat(filePath) {
|
|
7
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
8
|
+
if (ext === '.json')
|
|
9
|
+
return 'json';
|
|
10
|
+
if (ext === '.yaml' || ext === '.yml')
|
|
11
|
+
return 'yaml';
|
|
12
|
+
throw new Error(`Unsupported config file extension: ${ext || '<none>'}. Use .yaml/.yml/.json`);
|
|
13
|
+
}
|
|
14
|
+
function assertObject(value) {
|
|
15
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
16
|
+
throw new Error('Config root must be an object');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function deepClone(value) {
|
|
20
|
+
return JSON.parse(JSON.stringify(value));
|
|
21
|
+
}
|
|
22
|
+
function expandEnvString(input) {
|
|
23
|
+
return input.replace(ENV_PATTERN, (_, envName) => {
|
|
24
|
+
const envValue = process.env[envName];
|
|
25
|
+
if (envValue === undefined) {
|
|
26
|
+
throw new Error(`Missing required environment variable: ${envName}`);
|
|
27
|
+
}
|
|
28
|
+
return envValue;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function expandEnv(value) {
|
|
32
|
+
if (typeof value === 'string') {
|
|
33
|
+
return expandEnvString(value);
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(value)) {
|
|
36
|
+
return value.map((item) => expandEnv(item));
|
|
37
|
+
}
|
|
38
|
+
if (value && typeof value === 'object') {
|
|
39
|
+
const expanded = {};
|
|
40
|
+
for (const [key, item] of Object.entries(value)) {
|
|
41
|
+
expanded[key] = expandEnv(item);
|
|
42
|
+
}
|
|
43
|
+
return expanded;
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
function ensureActiveProfileExists(config) {
|
|
48
|
+
const exists = config.profiles.some((profile) => profile.id === config.activeProfile);
|
|
49
|
+
if (!exists) {
|
|
50
|
+
throw new Error(`activeProfile "${config.activeProfile}" does not exist in profiles`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function resolveKeyPath(keyPath, configFilePath) {
|
|
54
|
+
if (path.isAbsolute(keyPath)) {
|
|
55
|
+
return keyPath;
|
|
56
|
+
}
|
|
57
|
+
const baseDir = path.dirname(configFilePath);
|
|
58
|
+
return path.resolve(baseDir, keyPath);
|
|
59
|
+
}
|
|
60
|
+
async function validateProfileAuthFields(config, filePath) {
|
|
61
|
+
for (const profile of config.profiles) {
|
|
62
|
+
if (profile.auth.type === 'password') {
|
|
63
|
+
if (!profile.auth.password || profile.auth.password.trim() === '') {
|
|
64
|
+
throw new Error(`Profile "${profile.id}" requires auth.password`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (profile.auth.type === 'key') {
|
|
68
|
+
if (!profile.auth.keyPath || profile.auth.keyPath.trim() === '') {
|
|
69
|
+
throw new Error(`Profile "${profile.id}" requires auth.keyPath`);
|
|
70
|
+
}
|
|
71
|
+
const resolvedPath = resolveKeyPath(profile.auth.keyPath, filePath);
|
|
72
|
+
try {
|
|
73
|
+
await access(resolvedPath);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new Error(`Profile "${profile.id}" keyPath does not exist: ${resolvedPath}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function validateRawProfilesConfig(rawConfig, filePath) {
|
|
82
|
+
const expandedConfig = expandEnv(deepClone(rawConfig));
|
|
83
|
+
const parsedConfig = profilesConfigSchema.parse(expandedConfig);
|
|
84
|
+
ensureActiveProfileExists(parsedConfig);
|
|
85
|
+
await validateProfileAuthFields(parsedConfig, filePath);
|
|
86
|
+
return parsedConfig;
|
|
87
|
+
}
|
|
88
|
+
function parseRawContent(content, format) {
|
|
89
|
+
if (format === 'json') {
|
|
90
|
+
const parsed = JSON.parse(content);
|
|
91
|
+
assertObject(parsed);
|
|
92
|
+
return parsed;
|
|
93
|
+
}
|
|
94
|
+
const parsed = parseYaml(content);
|
|
95
|
+
assertObject(parsed);
|
|
96
|
+
return parsed;
|
|
97
|
+
}
|
|
98
|
+
export async function loadProfilesConfig(filePath) {
|
|
99
|
+
const format = inferFormat(filePath);
|
|
100
|
+
const content = await readFile(filePath, 'utf8');
|
|
101
|
+
const rawConfig = parseRawContent(content, format);
|
|
102
|
+
const parsedConfig = await validateRawProfilesConfig(rawConfig, filePath);
|
|
103
|
+
return {
|
|
104
|
+
filePath,
|
|
105
|
+
format,
|
|
106
|
+
config: parsedConfig,
|
|
107
|
+
rawConfig,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function serializeRawConfig(rawConfig, format) {
|
|
111
|
+
if (format === 'json') {
|
|
112
|
+
return JSON.stringify(rawConfig, null, 2) + '\n';
|
|
113
|
+
}
|
|
114
|
+
return stringifyYaml(rawConfig);
|
|
115
|
+
}
|
|
116
|
+
export async function saveRawProfilesConfig(loaded) {
|
|
117
|
+
const output = serializeRawConfig(loaded.rawConfig, loaded.format);
|
|
118
|
+
await writeFile(loaded.filePath, output, 'utf8');
|
|
119
|
+
}
|
|
120
|
+
export function profileSummary(profile, activeProfileId) {
|
|
121
|
+
const authSummary = profile.auth.type === 'password'
|
|
122
|
+
? { type: 'password', password: '***' }
|
|
123
|
+
: {
|
|
124
|
+
type: 'key',
|
|
125
|
+
keyPath: path.basename(profile.auth.keyPath),
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
id: profile.id,
|
|
129
|
+
name: profile.name,
|
|
130
|
+
host: profile.host,
|
|
131
|
+
port: profile.port,
|
|
132
|
+
user: profile.user,
|
|
133
|
+
note: profile.note ?? '',
|
|
134
|
+
tags: profile.tags ?? [],
|
|
135
|
+
auth: authSummary,
|
|
136
|
+
active: profile.id === activeProfileId,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const intFromStringSchema = z.string().regex(/^-?\d+$/).transform((value) => Number.parseInt(value, 10));
|
|
3
|
+
const intLikeSchema = z.union([
|
|
4
|
+
z.number().int(),
|
|
5
|
+
intFromStringSchema,
|
|
6
|
+
]).transform((value) => value);
|
|
7
|
+
const positiveIntLikeSchema = intLikeSchema.refine((value) => value > 0, { message: 'must be a positive integer' });
|
|
8
|
+
const booleanLikeSchema = z.union([z.boolean(), z.string()]).transform((value, ctx) => {
|
|
9
|
+
if (typeof value === 'boolean')
|
|
10
|
+
return value;
|
|
11
|
+
const lowered = value.trim().toLowerCase();
|
|
12
|
+
if (['true', '1', 'yes', 'on'].includes(lowered))
|
|
13
|
+
return true;
|
|
14
|
+
if (['false', '0', 'no', 'off'].includes(lowered))
|
|
15
|
+
return false;
|
|
16
|
+
ctx.addIssue({
|
|
17
|
+
code: z.ZodIssueCode.custom,
|
|
18
|
+
message: 'must be a boolean-like value',
|
|
19
|
+
});
|
|
20
|
+
return z.NEVER;
|
|
21
|
+
});
|
|
22
|
+
export const maxCharsConfigSchema = z.union([
|
|
23
|
+
z.literal('none'),
|
|
24
|
+
intLikeSchema,
|
|
25
|
+
]);
|
|
26
|
+
const passwordAuthSchema = z.object({
|
|
27
|
+
type: z.literal('password'),
|
|
28
|
+
password: z.string().min(1, 'password auth requires a non-empty password'),
|
|
29
|
+
}).strict();
|
|
30
|
+
const keyAuthSchema = z.object({
|
|
31
|
+
type: z.literal('key'),
|
|
32
|
+
keyPath: z.string().min(1, 'key auth requires a non-empty keyPath'),
|
|
33
|
+
}).strict();
|
|
34
|
+
export const profileAuthSchema = z.discriminatedUnion('type', [
|
|
35
|
+
passwordAuthSchema,
|
|
36
|
+
keyAuthSchema,
|
|
37
|
+
]);
|
|
38
|
+
export const profileDefaultsSchema = z.object({
|
|
39
|
+
timeout: positiveIntLikeSchema.optional(),
|
|
40
|
+
maxChars: maxCharsConfigSchema.optional(),
|
|
41
|
+
disableSudo: booleanLikeSchema.optional(),
|
|
42
|
+
}).default({});
|
|
43
|
+
const portSchema = positiveIntLikeSchema.refine((value) => value <= 65535, {
|
|
44
|
+
message: 'port must be between 1 and 65535',
|
|
45
|
+
});
|
|
46
|
+
export const profileSchema = z.object({
|
|
47
|
+
id: z.string().min(1, 'profile id is required'),
|
|
48
|
+
name: z.string().min(1, 'profile name is required'),
|
|
49
|
+
host: z.string().min(1, 'profile host is required'),
|
|
50
|
+
port: portSchema.default(22),
|
|
51
|
+
user: z.string().min(1, 'profile user is required'),
|
|
52
|
+
auth: profileAuthSchema,
|
|
53
|
+
suPassword: z.string().min(1).optional(),
|
|
54
|
+
sudoPassword: z.string().min(1).optional(),
|
|
55
|
+
note: z.string().default(''),
|
|
56
|
+
tags: z.array(z.string()).default([]),
|
|
57
|
+
}).strict();
|
|
58
|
+
export const profilesConfigSchema = z.object({
|
|
59
|
+
version: z.literal(1),
|
|
60
|
+
activeProfile: z.string().min(1, 'activeProfile is required'),
|
|
61
|
+
defaults: profileDefaultsSchema.optional().default({}),
|
|
62
|
+
profiles: z.array(profileSchema).min(1, 'at least one profile is required'),
|
|
63
|
+
}).strict().superRefine((value, ctx) => {
|
|
64
|
+
const seenIds = new Set();
|
|
65
|
+
for (const profile of value.profiles) {
|
|
66
|
+
if (seenIds.has(profile.id)) {
|
|
67
|
+
ctx.addIssue({
|
|
68
|
+
code: z.ZodIssueCode.custom,
|
|
69
|
+
message: `duplicate profile id: ${profile.id}`,
|
|
70
|
+
path: ['profiles'],
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
seenIds.add(profile.id);
|
|
74
|
+
}
|
|
75
|
+
});
|