@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 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
+ [![NPM Version](https://img.shields.io/npm/v/@xiaoyankonling/ssh-mcp)](https://www.npmjs.com/package/@xiaoyankonling/ssh-mcp)
4
+ [![Downloads](https://img.shields.io/npm/dm/@xiaoyankonling/ssh-mcp)](https://www.npmjs.com/package/@xiaoyankonling/ssh-mcp)
5
+ [![Node Version](https://img.shields.io/node/v/@xiaoyankonling/ssh-mcp)](https://nodejs.org/)
6
+ [![License](https://img.shields.io/github/license/Bianshumeng/ssh-mcp)](./LICENSE)
7
+ [![GitHub Stars](https://img.shields.io/github/stars/Bianshumeng/ssh-mcp?style=social)](https://github.com/Bianshumeng/ssh-mcp/stargazers)
8
+ [![GitHub Forks](https://img.shields.io/github/forks/Bianshumeng/ssh-mcp?style=social)](https://github.com/Bianshumeng/ssh-mcp/forks)
9
+ [![Build Status](https://github.com/Bianshumeng/ssh-mcp/actions/workflows/publish.yml/badge.svg)](https://github.com/Bianshumeng/ssh-mcp/actions)
10
+ [![GitHub issues](https://img.shields.io/github/issues/Bianshumeng/ssh-mcp)](https://github.com/Bianshumeng/ssh-mcp/issues)
11
+
12
+ [![Trust Score](https://archestra.ai/mcp-catalog/api/badge/quality/Bianshumeng/ssh-mcp)](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
+ });