@akms/mcp-ssh 0.0.3 → 0.1.1
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/README.md +75 -4
- package/lib/cli.cjs +2629 -248
- package/lib/cli.cjs.map +1 -1
- package/lib/cli.js +2626 -245
- package/lib/cli.js.map +1 -1
- package/lib/index.cjs +340 -89
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.cts +58 -5
- package/lib/index.d.ts +58 -5
- package/lib/index.js +339 -88
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
package/lib/cli.cjs
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
var stdio_js = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
5
|
-
var
|
|
6
|
-
var
|
|
5
|
+
var fs6 = require('fs');
|
|
6
|
+
var os5 = require('os');
|
|
7
|
+
var path = require('path');
|
|
8
|
+
var child_process = require('child_process');
|
|
7
9
|
var crypto = require('crypto');
|
|
8
|
-
var
|
|
10
|
+
var stream = require('stream');
|
|
9
11
|
var string_decoder = require('string_decoder');
|
|
10
12
|
var ssh2 = require('ssh2');
|
|
11
13
|
var timers = require('timers');
|
|
@@ -14,9 +16,9 @@ var zod = require('zod');
|
|
|
14
16
|
|
|
15
17
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
16
18
|
|
|
17
|
-
var
|
|
18
|
-
var
|
|
19
|
-
var
|
|
19
|
+
var fs6__default = /*#__PURE__*/_interopDefault(fs6);
|
|
20
|
+
var os5__default = /*#__PURE__*/_interopDefault(os5);
|
|
21
|
+
var path__default = /*#__PURE__*/_interopDefault(path);
|
|
20
22
|
var ssh2__default = /*#__PURE__*/_interopDefault(ssh2);
|
|
21
23
|
|
|
22
24
|
// src/_defs/defaults.ts
|
|
@@ -32,7 +34,8 @@ var SESSION_SWEEP_INTERVAL_MS = 6e4;
|
|
|
32
34
|
var CWD_MARKER = "__AKMS_SSH_CWD__";
|
|
33
35
|
var MAX_DIRECTORY_ENTRIES = 1e3;
|
|
34
36
|
var SERVER_NAME = "akms-mcp-ssh";
|
|
35
|
-
var
|
|
37
|
+
var PACKAGE_NAME = "@akms/mcp-ssh";
|
|
38
|
+
var SERVER_VERSION = "0.1.1";
|
|
36
39
|
|
|
37
40
|
// src/_defs/command-rules.ts
|
|
38
41
|
var CRITICAL_PATH_PATTERN = new RegExp(
|
|
@@ -454,6 +457,22 @@ var SINGLE_HOST_ENV = {
|
|
|
454
457
|
DESCRIPTION: "SSH_DESCRIPTION",
|
|
455
458
|
/** Directory new sessions start in. */
|
|
456
459
|
CWD: "SSH_CWD",
|
|
460
|
+
/**
|
|
461
|
+
* Command whose stdin/stdout carry the SSH stream, like OpenSSH's `ProxyCommand`.
|
|
462
|
+
* `%h` / `%p` / `%r` expand to the first hop's host, port and user.
|
|
463
|
+
*/
|
|
464
|
+
PROXY_COMMAND: "SSH_PROXY_COMMAND",
|
|
465
|
+
/** Presence of this variable routes the connection through a jump host. */
|
|
466
|
+
JUMP_HOST: "SSH_JUMP_HOST",
|
|
467
|
+
JUMP_PORT: "SSH_JUMP_PORT",
|
|
468
|
+
/** Defaults to `SSH_USER`. */
|
|
469
|
+
JUMP_USER: "SSH_JUMP_USER",
|
|
470
|
+
/** Path to the jump host's private key file; defaults to `SSH_KEY`. */
|
|
471
|
+
JUMP_KEY: "SSH_JUMP_KEY",
|
|
472
|
+
JUMP_PASSPHRASE: "SSH_JUMP_PASSPHRASE",
|
|
473
|
+
JUMP_PASSWORD: "SSH_JUMP_PASSWORD",
|
|
474
|
+
/** Pinned jump host key, `SHA256:…`. */
|
|
475
|
+
JUMP_HOST_KEY: "SSH_JUMP_HOST_KEY",
|
|
457
476
|
READONLY: "SSH_READONLY",
|
|
458
477
|
ALLOW_SUDO: "SSH_ALLOW_SUDO",
|
|
459
478
|
/** Comma-separated binary names. */
|
|
@@ -580,20 +599,38 @@ function formatBytes(bytes) {
|
|
|
580
599
|
const megabytes = bytes / (1024 * 1024);
|
|
581
600
|
return `${megabytes.toFixed(1)} MB`;
|
|
582
601
|
}
|
|
602
|
+
function findExecutable(name) {
|
|
603
|
+
const searchPath = process.env.PATH ?? "";
|
|
604
|
+
const directories = searchPath.split(path__default.default.delimiter).filter((directory) => directory !== "");
|
|
605
|
+
let suffixes = [""];
|
|
606
|
+
if (process.platform === "win32") {
|
|
607
|
+
const pathExt = process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD";
|
|
608
|
+
suffixes = pathExt.split(";").filter((suffix) => suffix !== "");
|
|
609
|
+
}
|
|
610
|
+
for (const directory of directories) {
|
|
611
|
+
for (const suffix of suffixes) {
|
|
612
|
+
const candidate = path__default.default.join(directory, `${name}${suffix}`);
|
|
613
|
+
if (fs6__default.default.existsSync(candidate) == true && fs6__default.default.statSync(candidate).isFile() == true) {
|
|
614
|
+
return candidate;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
583
620
|
function expandHomePath(inputPath) {
|
|
584
621
|
let expanded = inputPath;
|
|
585
622
|
if (inputPath === "~") {
|
|
586
|
-
expanded =
|
|
623
|
+
expanded = os5__default.default.homedir();
|
|
587
624
|
} else if (inputPath.startsWith("~/") == true || inputPath.startsWith("~\\") == true) {
|
|
588
|
-
expanded =
|
|
625
|
+
expanded = path__default.default.join(os5__default.default.homedir(), inputPath.slice(2));
|
|
589
626
|
}
|
|
590
|
-
return
|
|
627
|
+
return path__default.default.resolve(expanded);
|
|
591
628
|
}
|
|
592
629
|
function isAbsoluteRemotePath(remotePath) {
|
|
593
630
|
return remotePath.startsWith("/");
|
|
594
631
|
}
|
|
595
632
|
function normalizeRemotePath(remotePath) {
|
|
596
|
-
return
|
|
633
|
+
return path__default.default.posix.normalize(remotePath);
|
|
597
634
|
}
|
|
598
635
|
function isRemotePathWithin(candidate, allowedPrefix) {
|
|
599
636
|
const normalizedCandidate = normalizeRemotePath(candidate);
|
|
@@ -606,6 +643,195 @@ function isRemotePathWithin(candidate, allowedPrefix) {
|
|
|
606
643
|
}
|
|
607
644
|
return normalizedCandidate.startsWith(`${normalizedPrefix}/`);
|
|
608
645
|
}
|
|
646
|
+
function readEnv(env, name) {
|
|
647
|
+
const value = env[name];
|
|
648
|
+
if (value == null) {
|
|
649
|
+
return void 0;
|
|
650
|
+
}
|
|
651
|
+
const trimmed = value.trim();
|
|
652
|
+
if (trimmed === "") {
|
|
653
|
+
return void 0;
|
|
654
|
+
}
|
|
655
|
+
return trimmed;
|
|
656
|
+
}
|
|
657
|
+
function readBooleanEnv(env, name) {
|
|
658
|
+
const value = readEnv(env, name);
|
|
659
|
+
if (value == null) {
|
|
660
|
+
return void 0;
|
|
661
|
+
}
|
|
662
|
+
const normalized = value.toLowerCase();
|
|
663
|
+
if (TRUTHY_ENV_VALUES.includes(normalized) == true) {
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
if (FALSY_ENV_VALUES.includes(normalized) == false) {
|
|
667
|
+
throw new Error(`${name} must be one of ${[...TRUTHY_ENV_VALUES, ...FALSY_ENV_VALUES].join(", ")} (got "${value}")`);
|
|
668
|
+
}
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
function readIntegerEnv(env, name) {
|
|
672
|
+
const value = readEnv(env, name);
|
|
673
|
+
if (value == null) {
|
|
674
|
+
return void 0;
|
|
675
|
+
}
|
|
676
|
+
const parsed = Number(value);
|
|
677
|
+
if (Number.isInteger(parsed) == false || parsed <= 0) {
|
|
678
|
+
throw new Error(`${name} must be a positive integer (got "${value}")`);
|
|
679
|
+
}
|
|
680
|
+
return parsed;
|
|
681
|
+
}
|
|
682
|
+
function readListEnv(env, name) {
|
|
683
|
+
const value = readEnv(env, name);
|
|
684
|
+
if (value == null) {
|
|
685
|
+
return void 0;
|
|
686
|
+
}
|
|
687
|
+
const items = value.split(",").map((item) => item.trim()).filter((item) => item !== "");
|
|
688
|
+
if (items.length === 0) {
|
|
689
|
+
return void 0;
|
|
690
|
+
}
|
|
691
|
+
return items;
|
|
692
|
+
}
|
|
693
|
+
function compileDenyPatterns(sources) {
|
|
694
|
+
return sources.map((source) => {
|
|
695
|
+
try {
|
|
696
|
+
return new RegExp(source, "i");
|
|
697
|
+
} catch (ex) {
|
|
698
|
+
const _logger = $logger.child({ context: "compileDenyPatterns" });
|
|
699
|
+
_logger.error(ex, "denyPatterns entry is not a valid regular expression");
|
|
700
|
+
let detail = String(ex);
|
|
701
|
+
if (ex instanceof Error) {
|
|
702
|
+
detail = ex.message;
|
|
703
|
+
}
|
|
704
|
+
throw new Error(`${SINGLE_HOST_ENV.DENY_PATTERNS} has an invalid entry /${source}/: ${detail}`);
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
function loadHostProfile(env = process.env) {
|
|
709
|
+
const host = readEnv(env, SINGLE_HOST_ENV.HOST);
|
|
710
|
+
if (host == null) {
|
|
711
|
+
const _startupLogger = $logger.child({ context: "loadHostProfile" });
|
|
712
|
+
_startupLogger.warn("no host is registered; set the connection variables and restart", {
|
|
713
|
+
env_vars: [SINGLE_HOST_ENV.HOST, SINGLE_HOST_ENV.USER]
|
|
714
|
+
});
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
const username = readEnv(env, SINGLE_HOST_ENV.USER);
|
|
718
|
+
if (username == null) {
|
|
719
|
+
throw new Error(`${SINGLE_HOST_ENV.HOST} is set, so ${SINGLE_HOST_ENV.USER} is required`);
|
|
720
|
+
}
|
|
721
|
+
const name = readEnv(env, SINGLE_HOST_ENV.NAME) ?? host;
|
|
722
|
+
const _logger = $logger.child({ context: "loadHostProfile", profile: name });
|
|
723
|
+
const allowCommands = readListEnv(env, SINGLE_HOST_ENV.ALLOW_COMMANDS) ?? [];
|
|
724
|
+
if (allowCommands.length > 0 && allowCommands.includes("pwd") == false) {
|
|
725
|
+
allowCommands.push("pwd");
|
|
726
|
+
}
|
|
727
|
+
const policy = {
|
|
728
|
+
readonly: readBooleanEnv(env, SINGLE_HOST_ENV.READONLY) ?? false,
|
|
729
|
+
allowSudo: readBooleanEnv(env, SINGLE_HOST_ENV.ALLOW_SUDO) ?? true,
|
|
730
|
+
execTimeoutMs: readIntegerEnv(env, SINGLE_HOST_ENV.EXEC_TIMEOUT_MS) ?? DEFAULT_EXEC_TIMEOUT_MS,
|
|
731
|
+
connectTimeoutMs: readIntegerEnv(env, SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS) ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
|
732
|
+
maxOutputCharacters: readIntegerEnv(env, SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS) ?? DEFAULT_MAX_OUTPUT_CHARACTERS,
|
|
733
|
+
maxReadFileBytes: readIntegerEnv(env, SINGLE_HOST_ENV.MAX_READ_FILE_BYTES) ?? DEFAULT_MAX_READ_FILE_BYTES,
|
|
734
|
+
allowCommands,
|
|
735
|
+
denyPatterns: compileDenyPatterns(readListEnv(env, SINGLE_HOST_ENV.DENY_PATTERNS) ?? []),
|
|
736
|
+
allowedPaths: readListEnv(env, SINGLE_HOST_ENV.ALLOWED_PATHS) ?? []
|
|
737
|
+
};
|
|
738
|
+
const profile = {
|
|
739
|
+
name,
|
|
740
|
+
host,
|
|
741
|
+
port: readIntegerEnv(env, SINGLE_HOST_ENV.PORT) ?? DEFAULT_SSH_PORT,
|
|
742
|
+
username,
|
|
743
|
+
description: readEnv(env, SINGLE_HOST_ENV.DESCRIPTION),
|
|
744
|
+
passphrase: readEnv(env, SINGLE_HOST_ENV.PASSPHRASE),
|
|
745
|
+
password: readEnv(env, SINGLE_HOST_ENV.PASSWORD),
|
|
746
|
+
hostKeyFingerprint: readEnv(env, SINGLE_HOST_ENV.HOST_KEY),
|
|
747
|
+
defaultCwd: readEnv(env, SINGLE_HOST_ENV.CWD),
|
|
748
|
+
policy
|
|
749
|
+
};
|
|
750
|
+
const keyPath = readEnv(env, SINGLE_HOST_ENV.KEY);
|
|
751
|
+
if (keyPath != null) {
|
|
752
|
+
const expandedKeyPath = expandHomePath(keyPath);
|
|
753
|
+
profile.privateKeyPath = expandedKeyPath;
|
|
754
|
+
if (fs6__default.default.existsSync(expandedKeyPath) == false) {
|
|
755
|
+
_logger.warn(`${SINGLE_HOST_ENV.KEY} does not exist; connections will fail`, {
|
|
756
|
+
private_key_path: expandedKeyPath
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
profile.proxyCommand = readEnv(env, SINGLE_HOST_ENV.PROXY_COMMAND);
|
|
761
|
+
const jumpHost = readEnv(env, SINGLE_HOST_ENV.JUMP_HOST);
|
|
762
|
+
if (jumpHost == null) {
|
|
763
|
+
const strayJumpVariables = [
|
|
764
|
+
SINGLE_HOST_ENV.JUMP_PORT,
|
|
765
|
+
SINGLE_HOST_ENV.JUMP_USER,
|
|
766
|
+
SINGLE_HOST_ENV.JUMP_KEY,
|
|
767
|
+
SINGLE_HOST_ENV.JUMP_PASSPHRASE,
|
|
768
|
+
SINGLE_HOST_ENV.JUMP_PASSWORD,
|
|
769
|
+
SINGLE_HOST_ENV.JUMP_HOST_KEY
|
|
770
|
+
].filter((variable) => readEnv(env, variable) != null);
|
|
771
|
+
if (strayJumpVariables.length > 0) {
|
|
772
|
+
throw new Error(`${strayJumpVariables.join(", ")} is set, so ${SINGLE_HOST_ENV.JUMP_HOST} is required`);
|
|
773
|
+
}
|
|
774
|
+
} else {
|
|
775
|
+
profile.jump = {
|
|
776
|
+
host: jumpHost,
|
|
777
|
+
port: readIntegerEnv(env, SINGLE_HOST_ENV.JUMP_PORT) ?? DEFAULT_SSH_PORT,
|
|
778
|
+
username: readEnv(env, SINGLE_HOST_ENV.JUMP_USER) ?? username,
|
|
779
|
+
password: readEnv(env, SINGLE_HOST_ENV.JUMP_PASSWORD),
|
|
780
|
+
hostKeyFingerprint: readEnv(env, SINGLE_HOST_ENV.JUMP_HOST_KEY)
|
|
781
|
+
};
|
|
782
|
+
const jumpKeyPath = readEnv(env, SINGLE_HOST_ENV.JUMP_KEY);
|
|
783
|
+
if (jumpKeyPath != null) {
|
|
784
|
+
profile.jump.privateKeyPath = expandHomePath(jumpKeyPath);
|
|
785
|
+
profile.jump.passphrase = readEnv(env, SINGLE_HOST_ENV.JUMP_PASSPHRASE);
|
|
786
|
+
if (fs6__default.default.existsSync(profile.jump.privateKeyPath) == false) {
|
|
787
|
+
_logger.warn(`${SINGLE_HOST_ENV.JUMP_KEY} does not exist; connections will fail`, {
|
|
788
|
+
private_key_path: profile.jump.privateKeyPath
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
} else {
|
|
792
|
+
profile.jump.privateKeyPath = profile.privateKeyPath;
|
|
793
|
+
profile.jump.passphrase = readEnv(env, SINGLE_HOST_ENV.JUMP_PASSPHRASE) ?? profile.passphrase;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
_logger.info("host profile loaded", {
|
|
797
|
+
host,
|
|
798
|
+
port: profile.port,
|
|
799
|
+
username,
|
|
800
|
+
readonly: policy.readonly,
|
|
801
|
+
jump_host: profile.jump?.host,
|
|
802
|
+
via_proxy_command: profile.proxyCommand != null
|
|
803
|
+
});
|
|
804
|
+
return profile;
|
|
805
|
+
}
|
|
806
|
+
function toHostSummary(profile) {
|
|
807
|
+
let authMethod = "agent";
|
|
808
|
+
if (profile.privateKeyPath != null) {
|
|
809
|
+
authMethod = "privateKey";
|
|
810
|
+
} else if (profile.password != null) {
|
|
811
|
+
authMethod = "password";
|
|
812
|
+
}
|
|
813
|
+
const summary = {
|
|
814
|
+
name: profile.name,
|
|
815
|
+
host: profile.host,
|
|
816
|
+
port: profile.port,
|
|
817
|
+
username: profile.username,
|
|
818
|
+
description: profile.description,
|
|
819
|
+
authMethod,
|
|
820
|
+
viaProxyCommand: profile.proxyCommand != null,
|
|
821
|
+
readonly: profile.policy.readonly,
|
|
822
|
+
allowSudo: profile.policy.allowSudo,
|
|
823
|
+
allowedPaths: profile.policy.allowedPaths,
|
|
824
|
+
allowCommands: profile.policy.allowCommands
|
|
825
|
+
};
|
|
826
|
+
if (profile.jump != null) {
|
|
827
|
+
summary.jump = {
|
|
828
|
+
host: profile.jump.host,
|
|
829
|
+
port: profile.jump.port,
|
|
830
|
+
username: profile.jump.username
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
return summary;
|
|
834
|
+
}
|
|
609
835
|
|
|
610
836
|
// src/modules/guard/command-guard.ts
|
|
611
837
|
var WRAPPER_BINARY_SET = new Set(COMMAND_WRAPPER_BINARIES);
|
|
@@ -1043,20 +1269,20 @@ function inspectRemotePath(remotePath, policy, access) {
|
|
|
1043
1269
|
function toRealPath(inputPath) {
|
|
1044
1270
|
const _logger = $logger.child({ context: "toRealPath" });
|
|
1045
1271
|
try {
|
|
1046
|
-
return
|
|
1272
|
+
return fs6__default.default.realpathSync.native(inputPath);
|
|
1047
1273
|
} catch (ex) {
|
|
1048
1274
|
_logger.debug("path does not resolve, falling back to its parent directory", {
|
|
1049
1275
|
reason: ex instanceof Error ? ex.name : "unknown"
|
|
1050
1276
|
});
|
|
1051
1277
|
}
|
|
1052
1278
|
try {
|
|
1053
|
-
const parentRealPath =
|
|
1054
|
-
return
|
|
1279
|
+
const parentRealPath = fs6__default.default.realpathSync.native(path__default.default.dirname(inputPath));
|
|
1280
|
+
return path__default.default.join(parentRealPath, path__default.default.basename(inputPath));
|
|
1055
1281
|
} catch (ex) {
|
|
1056
1282
|
_logger.debug("parent directory does not resolve either, comparing lexically", {
|
|
1057
1283
|
reason: ex instanceof Error ? ex.name : "unknown"
|
|
1058
1284
|
});
|
|
1059
|
-
return
|
|
1285
|
+
return path__default.default.resolve(inputPath);
|
|
1060
1286
|
}
|
|
1061
1287
|
}
|
|
1062
1288
|
function toComparablePath(inputPath) {
|
|
@@ -1136,42 +1362,56 @@ function resolveAgentPath() {
|
|
|
1136
1362
|
}
|
|
1137
1363
|
return void 0;
|
|
1138
1364
|
}
|
|
1139
|
-
function readPrivateKey(
|
|
1365
|
+
function readPrivateKey(label, keyPath) {
|
|
1140
1366
|
const cached = PRIVATE_KEY_CACHE.get(keyPath);
|
|
1141
1367
|
if (cached != null) {
|
|
1142
1368
|
return cached;
|
|
1143
1369
|
}
|
|
1144
1370
|
try {
|
|
1145
|
-
const contents =
|
|
1371
|
+
const contents = fs6__default.default.readFileSync(keyPath);
|
|
1146
1372
|
PRIVATE_KEY_CACHE.set(keyPath, contents);
|
|
1147
1373
|
return contents;
|
|
1148
1374
|
} catch (ex) {
|
|
1149
|
-
const _logger = $logger.child({ context: "readPrivateKey", profile:
|
|
1150
|
-
_logger.error(ex, "cannot read the
|
|
1151
|
-
throw new Error(`cannot read the private key configured for
|
|
1375
|
+
const _logger = $logger.child({ context: "readPrivateKey", profile: label });
|
|
1376
|
+
_logger.error(ex, "cannot read the private key file");
|
|
1377
|
+
throw new Error(`cannot read the private key configured for '${label}' (see the server log for the path)`);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
function applyAuth(connectConfig, credentials, label) {
|
|
1381
|
+
if (credentials.privateKeyPath != null) {
|
|
1382
|
+
connectConfig.privateKey = readPrivateKey(label, credentials.privateKeyPath);
|
|
1383
|
+
}
|
|
1384
|
+
if (credentials.passphrase != null) {
|
|
1385
|
+
connectConfig.passphrase = credentials.passphrase;
|
|
1386
|
+
}
|
|
1387
|
+
if (credentials.password != null) {
|
|
1388
|
+
connectConfig.password = credentials.password;
|
|
1389
|
+
}
|
|
1390
|
+
if (connectConfig.privateKey == null && connectConfig.password == null) {
|
|
1391
|
+
connectConfig.agent = resolveAgentPath();
|
|
1152
1392
|
}
|
|
1153
1393
|
}
|
|
1154
1394
|
function toHostKeyFingerprint(hostKey) {
|
|
1155
1395
|
const digest = crypto.createHash("sha256").update(hostKey).digest("base64").replace(/=+$/, "");
|
|
1156
1396
|
return `SHA256:${digest}`;
|
|
1157
1397
|
}
|
|
1158
|
-
function buildHostVerifier(
|
|
1398
|
+
function buildHostVerifier(hop, onHostKeySeen) {
|
|
1159
1399
|
return (hostKey) => {
|
|
1160
1400
|
const fingerprint = toHostKeyFingerprint(hostKey);
|
|
1161
1401
|
onHostKeySeen(fingerprint);
|
|
1162
|
-
const _logger = $logger.child({ context: "hostVerifier", profile:
|
|
1163
|
-
if (
|
|
1164
|
-
_logger.info("host key accepted unpinned;
|
|
1402
|
+
const _logger = $logger.child({ context: "hostVerifier", profile: hop.label, host: hop.host });
|
|
1403
|
+
if (hop.pinnedFingerprint == null) {
|
|
1404
|
+
_logger.info("host key accepted unpinned; pin the fingerprint in the profile to detect a changed key", {
|
|
1165
1405
|
host_key_fingerprint: fingerprint
|
|
1166
1406
|
});
|
|
1167
1407
|
return true;
|
|
1168
1408
|
}
|
|
1169
|
-
if (
|
|
1409
|
+
if (hop.pinnedFingerprint === fingerprint) {
|
|
1170
1410
|
_logger.debug("host key matches the pinned fingerprint");
|
|
1171
1411
|
return true;
|
|
1172
1412
|
}
|
|
1173
1413
|
_logger.error(null, "host key does not match the pinned fingerprint", {
|
|
1174
|
-
expected:
|
|
1414
|
+
expected: hop.pinnedFingerprint,
|
|
1175
1415
|
actual: fingerprint
|
|
1176
1416
|
});
|
|
1177
1417
|
return false;
|
|
@@ -1184,21 +1424,152 @@ function buildConnectConfig(profile, onHostKeySeen) {
|
|
|
1184
1424
|
username: profile.username,
|
|
1185
1425
|
readyTimeout: profile.policy.connectTimeoutMs,
|
|
1186
1426
|
keepaliveInterval: KEEPALIVE_INTERVAL_MS,
|
|
1187
|
-
hostVerifier: buildHostVerifier(
|
|
1427
|
+
hostVerifier: buildHostVerifier(
|
|
1428
|
+
{ label: profile.name, host: profile.host, pinnedFingerprint: profile.hostKeyFingerprint },
|
|
1429
|
+
onHostKeySeen
|
|
1430
|
+
)
|
|
1188
1431
|
};
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1432
|
+
applyAuth(connectConfig, profile, profile.name);
|
|
1433
|
+
return connectConfig;
|
|
1434
|
+
}
|
|
1435
|
+
function expandProxyCommand(template, hop) {
|
|
1436
|
+
return template.replace(/%[hpr%]/g, (token) => {
|
|
1437
|
+
if (token === "%h") {
|
|
1438
|
+
return hop.host;
|
|
1439
|
+
}
|
|
1440
|
+
if (token === "%p") {
|
|
1441
|
+
return String(hop.port);
|
|
1442
|
+
}
|
|
1443
|
+
if (token === "%r") {
|
|
1444
|
+
return hop.username;
|
|
1445
|
+
}
|
|
1446
|
+
return "%";
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
function openProxyCommand(profile, hop) {
|
|
1450
|
+
const command = expandProxyCommand(profile.proxyCommand ?? "", hop);
|
|
1451
|
+
const _logger = $logger.child({ context: "openProxyCommand", profile: profile.name });
|
|
1452
|
+
_logger.debug("starting the proxy command", { command });
|
|
1453
|
+
const child = child_process.spawn(command, { shell: true, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
1454
|
+
const stderrDecoder = new string_decoder.StringDecoder("utf8");
|
|
1455
|
+
child.stderr.on("data", (chunk) => {
|
|
1456
|
+
const text = stderrDecoder.write(chunk).trim();
|
|
1457
|
+
if (text !== "") {
|
|
1458
|
+
_logger.warn("proxy command stderr", { text });
|
|
1459
|
+
}
|
|
1460
|
+
});
|
|
1461
|
+
child.on("exit", (code, signal) => {
|
|
1462
|
+
_logger.debug("proxy command exited", { exit_code: code, signal });
|
|
1463
|
+
});
|
|
1464
|
+
const sock = stream.Duplex.from({ readable: child.stdout, writable: child.stdin });
|
|
1465
|
+
sock.on("error", (error) => {
|
|
1466
|
+
_logger.debug("proxy command stream error", { error_message: error.message });
|
|
1467
|
+
});
|
|
1468
|
+
child.on("error", (error) => {
|
|
1469
|
+
sock.destroy(error);
|
|
1470
|
+
});
|
|
1471
|
+
let closed = false;
|
|
1472
|
+
return {
|
|
1473
|
+
sock,
|
|
1474
|
+
close: () => {
|
|
1475
|
+
if (closed == true) {
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
closed = true;
|
|
1479
|
+
if (child.exitCode != null || child.signalCode != null) {
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
if (process.platform === "win32" && child.pid != null) {
|
|
1483
|
+
const killer = child_process.spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
1484
|
+
killer.on("error", (error) => {
|
|
1485
|
+
_logger.error(error, "failed to kill the proxy command's process tree");
|
|
1486
|
+
});
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
child.kill();
|
|
1490
|
+
}
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
function openJumpTransport(profile, jump, proxy, onHostKeySeen) {
|
|
1494
|
+
const label = `${profile.name} jump host`;
|
|
1495
|
+
const endpoint = `${jump.username}@${jump.host}:${jump.port}`;
|
|
1496
|
+
const _logger = $logger.child({ context: "openJumpTransport", profile: profile.name, jump_host: jump.host });
|
|
1497
|
+
const jumpConfig = {
|
|
1498
|
+
host: jump.host,
|
|
1499
|
+
port: jump.port,
|
|
1500
|
+
username: jump.username,
|
|
1501
|
+
readyTimeout: profile.policy.connectTimeoutMs,
|
|
1502
|
+
keepaliveInterval: KEEPALIVE_INTERVAL_MS,
|
|
1503
|
+
hostVerifier: buildHostVerifier(
|
|
1504
|
+
{ label, host: jump.host, pinnedFingerprint: jump.hostKeyFingerprint },
|
|
1505
|
+
onHostKeySeen
|
|
1506
|
+
)
|
|
1507
|
+
};
|
|
1508
|
+
applyAuth(jumpConfig, jump, label);
|
|
1509
|
+
if (proxy != null) {
|
|
1510
|
+
jumpConfig.sock = proxy.sock;
|
|
1511
|
+
}
|
|
1512
|
+
return new Promise((resolve, reject) => {
|
|
1513
|
+
const jumpClient = new Client();
|
|
1514
|
+
let settled = false;
|
|
1515
|
+
jumpClient.on("error", (error) => {
|
|
1516
|
+
if (settled == false) {
|
|
1517
|
+
settled = true;
|
|
1518
|
+
jumpClient.destroy();
|
|
1519
|
+
reject(new Error(`jump host ${endpoint}: ${error.message}`));
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
_logger.error(error, "jump host connection error after ready");
|
|
1523
|
+
});
|
|
1524
|
+
jumpClient.on("close", () => {
|
|
1525
|
+
_logger.debug("jump host connection closed");
|
|
1526
|
+
proxy?.close();
|
|
1527
|
+
if (settled == false) {
|
|
1528
|
+
settled = true;
|
|
1529
|
+
reject(new Error(`jump host ${endpoint}: connection closed before the handshake completed`));
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
jumpClient.once("ready", () => {
|
|
1533
|
+
if (settled == true) {
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
jumpClient.forwardOut("127.0.0.1", 0, profile.host, profile.port, (error, stream) => {
|
|
1537
|
+
if (settled == true) {
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
settled = true;
|
|
1541
|
+
if (error != null) {
|
|
1542
|
+
jumpClient.end();
|
|
1543
|
+
reject(new Error(`jump host ${endpoint} refused the forward to ${profile.host}:${profile.port}: ${error.message}`));
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
_logger.info("jump host connected, target channel forwarded");
|
|
1547
|
+
resolve({
|
|
1548
|
+
sock: stream,
|
|
1549
|
+
close: () => {
|
|
1550
|
+
jumpClient.end();
|
|
1551
|
+
}
|
|
1552
|
+
});
|
|
1553
|
+
});
|
|
1554
|
+
});
|
|
1555
|
+
jumpClient.connect(jumpConfig);
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
async function openTransport(profile, onJumpHostKeySeen) {
|
|
1559
|
+
let proxy = null;
|
|
1560
|
+
if (profile.proxyCommand != null) {
|
|
1561
|
+
proxy = openProxyCommand(profile, profile.jump ?? profile);
|
|
1194
1562
|
}
|
|
1195
|
-
if (profile.
|
|
1196
|
-
|
|
1563
|
+
if (profile.jump == null) {
|
|
1564
|
+
return proxy;
|
|
1197
1565
|
}
|
|
1198
|
-
|
|
1199
|
-
|
|
1566
|
+
try {
|
|
1567
|
+
const jumpTransport = await openJumpTransport(profile, profile.jump, proxy, onJumpHostKeySeen);
|
|
1568
|
+
return jumpTransport;
|
|
1569
|
+
} catch (ex) {
|
|
1570
|
+
proxy?.close();
|
|
1571
|
+
throw ex;
|
|
1200
1572
|
}
|
|
1201
|
-
return connectConfig;
|
|
1202
1573
|
}
|
|
1203
1574
|
function buildRemoteCommand(command, cwd, trackCwd) {
|
|
1204
1575
|
const lines = [];
|
|
@@ -1240,6 +1611,7 @@ var SshConnection = class {
|
|
|
1240
1611
|
/** Set when the peer closed a previously established connection. */
|
|
1241
1612
|
this.droppedAfterReady = false;
|
|
1242
1613
|
this.observedFingerprint = null;
|
|
1614
|
+
this.observedJumpFingerprint = null;
|
|
1243
1615
|
}
|
|
1244
1616
|
get isConnected() {
|
|
1245
1617
|
return this.client != null;
|
|
@@ -1248,6 +1620,10 @@ var SshConnection = class {
|
|
|
1248
1620
|
get hostKeyFingerprint() {
|
|
1249
1621
|
return this.observedFingerprint;
|
|
1250
1622
|
}
|
|
1623
|
+
/** Host key the jump host presented; null without a jump host or before its handshake. */
|
|
1624
|
+
get jumpHostKeyFingerprint() {
|
|
1625
|
+
return this.observedJumpFingerprint;
|
|
1626
|
+
}
|
|
1251
1627
|
/** True when a connection was established and then lost, so shell state is gone. */
|
|
1252
1628
|
get wasDropped() {
|
|
1253
1629
|
return this.droppedAfterReady;
|
|
@@ -1255,8 +1631,11 @@ var SshConnection = class {
|
|
|
1255
1631
|
/**
|
|
1256
1632
|
* Opens the connection, or joins the in-flight attempt when one is already running.
|
|
1257
1633
|
*
|
|
1634
|
+
* With a proxy command or a jump host the carrier is opened first and handed to ssh2
|
|
1635
|
+
* as the socket; it is torn down with the target connection, whichever side closes.
|
|
1636
|
+
*
|
|
1258
1637
|
* @throws {Error} If the connection was closed, authentication fails, the host is
|
|
1259
|
-
* unreachable,
|
|
1638
|
+
* unreachable, the handshake times out, or the carrier cannot be opened.
|
|
1260
1639
|
*/
|
|
1261
1640
|
async connect() {
|
|
1262
1641
|
if (this.disposed == true) {
|
|
@@ -1279,43 +1658,66 @@ var SshConnection = class {
|
|
|
1279
1658
|
const connectConfig = buildConnectConfig(this.profile, (fingerprint) => {
|
|
1280
1659
|
this.observedFingerprint = fingerprint;
|
|
1281
1660
|
});
|
|
1282
|
-
this.connecting =
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1661
|
+
this.connecting = (async () => {
|
|
1662
|
+
const transport = await openTransport(this.profile, (fingerprint) => {
|
|
1663
|
+
this.observedJumpFingerprint = fingerprint;
|
|
1664
|
+
});
|
|
1665
|
+
if (this.disposed == true) {
|
|
1666
|
+
transport?.close();
|
|
1667
|
+
throw new Error(`ssh connection for '${this.profile.name}' was closed during the handshake`);
|
|
1668
|
+
}
|
|
1669
|
+
if (transport != null) {
|
|
1670
|
+
connectConfig.sock = transport.sock;
|
|
1671
|
+
}
|
|
1672
|
+
await new Promise((resolve, reject) => {
|
|
1673
|
+
const client = new Client();
|
|
1674
|
+
this.pendingClient = client;
|
|
1675
|
+
let settled = false;
|
|
1676
|
+
client.on("error", (error) => {
|
|
1677
|
+
if (settled == false) {
|
|
1678
|
+
settled = true;
|
|
1679
|
+
this.pendingClient = null;
|
|
1680
|
+
client.destroy();
|
|
1681
|
+
reject(error);
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
if (this.client !== client) {
|
|
1685
|
+
_logger.debug("ssh connection error during teardown", { error_message: error.message });
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
_logger.error(error, "ssh connection error after ready");
|
|
1689
|
+
});
|
|
1690
|
+
client.on("close", () => {
|
|
1691
|
+
transport?.close();
|
|
1692
|
+
if (this.client === client) {
|
|
1693
|
+
_logger.debug("ssh connection closed by the peer");
|
|
1694
|
+
this.client = null;
|
|
1695
|
+
this.droppedAfterReady = true;
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
if (settled == false) {
|
|
1699
|
+
settled = true;
|
|
1700
|
+
this.pendingClient = null;
|
|
1701
|
+
reject(new Error("connection closed before the handshake completed"));
|
|
1702
|
+
}
|
|
1703
|
+
});
|
|
1704
|
+
client.once("ready", () => {
|
|
1705
|
+
if (settled == true) {
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1288
1708
|
settled = true;
|
|
1289
1709
|
this.pendingClient = null;
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
this.client = null;
|
|
1300
|
-
this.droppedAfterReady = true;
|
|
1301
|
-
}
|
|
1302
|
-
});
|
|
1303
|
-
client.once("ready", () => {
|
|
1304
|
-
if (settled == true) {
|
|
1305
|
-
return;
|
|
1306
|
-
}
|
|
1307
|
-
settled = true;
|
|
1308
|
-
this.pendingClient = null;
|
|
1309
|
-
if (this.disposed == true) {
|
|
1310
|
-
client.destroy();
|
|
1311
|
-
reject(new Error(`ssh connection for '${this.profile.name}' was closed during the handshake`));
|
|
1312
|
-
return;
|
|
1313
|
-
}
|
|
1314
|
-
this.client = client;
|
|
1315
|
-
resolve();
|
|
1710
|
+
if (this.disposed == true) {
|
|
1711
|
+
client.destroy();
|
|
1712
|
+
reject(new Error(`ssh connection for '${this.profile.name}' was closed during the handshake`));
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
this.client = client;
|
|
1716
|
+
resolve();
|
|
1717
|
+
});
|
|
1718
|
+
client.connect(connectConfig);
|
|
1316
1719
|
});
|
|
1317
|
-
|
|
1318
|
-
});
|
|
1720
|
+
})();
|
|
1319
1721
|
try {
|
|
1320
1722
|
await this.connecting;
|
|
1321
1723
|
_logger.info("ssh connection established");
|
|
@@ -1917,7 +2319,7 @@ async function writeRemoteFile(connection, args) {
|
|
|
1917
2319
|
}
|
|
1918
2320
|
async function uploadFile(connection, localPath, remotePath) {
|
|
1919
2321
|
requirePermittedPath(connection, remotePath, "write");
|
|
1920
|
-
const localStats =
|
|
2322
|
+
const localStats = fs6__default.default.statSync(localPath);
|
|
1921
2323
|
if (localStats.isFile() == false) {
|
|
1922
2324
|
throw new Error(`local path is not a file: ${localPath}`);
|
|
1923
2325
|
}
|
|
@@ -1954,7 +2356,7 @@ async function downloadFile(connection, remotePath, localPath) {
|
|
|
1954
2356
|
});
|
|
1955
2357
|
});
|
|
1956
2358
|
});
|
|
1957
|
-
const localStats =
|
|
2359
|
+
const localStats = fs6__default.default.statSync(localPath);
|
|
1958
2360
|
const _logger = $logger.child({
|
|
1959
2361
|
context: "downloadFile",
|
|
1960
2362
|
profile: connection.profile.name,
|
|
@@ -1964,147 +2366,2123 @@ async function downloadFile(connection, remotePath, localPath) {
|
|
|
1964
2366
|
_logger.info("file downloaded", { bytes: localStats.size });
|
|
1965
2367
|
return localStats.size;
|
|
1966
2368
|
}
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
2369
|
+
|
|
2370
|
+
// src/modules/manage/connection-check.ts
|
|
2371
|
+
function describeAuth(profile) {
|
|
2372
|
+
if (profile.privateKeyPath != null) {
|
|
2373
|
+
return `key ${profile.privateKeyPath}`;
|
|
1971
2374
|
}
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
return void 0;
|
|
2375
|
+
if (profile.password != null) {
|
|
2376
|
+
return "password";
|
|
1975
2377
|
}
|
|
1976
|
-
return
|
|
2378
|
+
return "ssh agent";
|
|
1977
2379
|
}
|
|
1978
|
-
function
|
|
1979
|
-
const
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
2380
|
+
async function runConnectionCheck(profile, output) {
|
|
2381
|
+
const endpoint = `${profile.username}@${profile.host}:${profile.port}`;
|
|
2382
|
+
const auth = describeAuth(profile);
|
|
2383
|
+
output.write(`Checking ${profile.name} \u2192 ${endpoint}
|
|
2384
|
+
`);
|
|
2385
|
+
if (profile.proxyCommand != null) {
|
|
2386
|
+
output.write(` via proxy command: ${profile.proxyCommand}
|
|
2387
|
+
`);
|
|
1986
2388
|
}
|
|
1987
|
-
if (
|
|
1988
|
-
|
|
2389
|
+
if (profile.jump != null) {
|
|
2390
|
+
output.write(` via jump host: ${profile.jump.username}@${profile.jump.host}:${profile.jump.port}
|
|
2391
|
+
`);
|
|
1989
2392
|
}
|
|
1990
|
-
|
|
2393
|
+
output.write("\n");
|
|
2394
|
+
const connection = new SshConnection(profile);
|
|
2395
|
+
const startedAt = Date.now();
|
|
2396
|
+
let connected = false;
|
|
2397
|
+
let detail = "";
|
|
2398
|
+
try {
|
|
2399
|
+
await connection.connect();
|
|
2400
|
+
connected = true;
|
|
2401
|
+
} catch (ex) {
|
|
2402
|
+
detail = String(ex);
|
|
2403
|
+
if (ex instanceof Error) {
|
|
2404
|
+
detail = ex.message;
|
|
2405
|
+
}
|
|
2406
|
+
} finally {
|
|
2407
|
+
connection.disconnect();
|
|
2408
|
+
}
|
|
2409
|
+
const durationMs = Date.now() - startedAt;
|
|
2410
|
+
if (connected == true) {
|
|
2411
|
+
output.write(` OK ${endpoint} (${auth}, ${durationMs}ms)
|
|
2412
|
+
`);
|
|
2413
|
+
} else {
|
|
2414
|
+
output.write(` FAILED ${endpoint} (${auth})
|
|
2415
|
+
`);
|
|
2416
|
+
output.write(` ${detail}
|
|
2417
|
+
`);
|
|
2418
|
+
}
|
|
2419
|
+
if (connection.hostKeyFingerprint != null) {
|
|
2420
|
+
let pinNote = `not pinned \u2014 set ${SINGLE_HOST_ENV.HOST_KEY} to it to detect a changed key`;
|
|
2421
|
+
if (profile.hostKeyFingerprint != null) {
|
|
2422
|
+
pinNote = "pinned";
|
|
2423
|
+
}
|
|
2424
|
+
output.write(` host key ${connection.hostKeyFingerprint} (${pinNote})
|
|
2425
|
+
`);
|
|
2426
|
+
}
|
|
2427
|
+
if (connection.jumpHostKeyFingerprint != null) {
|
|
2428
|
+
let pinNote = `not pinned \u2014 set ${SINGLE_HOST_ENV.JUMP_HOST_KEY} to it to detect a changed key`;
|
|
2429
|
+
if (profile.jump?.hostKeyFingerprint != null) {
|
|
2430
|
+
pinNote = "pinned";
|
|
2431
|
+
}
|
|
2432
|
+
output.write(` jump host key ${connection.jumpHostKeyFingerprint} (${pinNote})
|
|
2433
|
+
`);
|
|
2434
|
+
}
|
|
2435
|
+
return {
|
|
2436
|
+
connected,
|
|
2437
|
+
hostKeyFingerprint: connection.hostKeyFingerprint,
|
|
2438
|
+
jumpHostKeyFingerprint: connection.jumpHostKeyFingerprint
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
// src/modules/manage/entry.ts
|
|
2443
|
+
var MANAGED_ENV_NAMES = [
|
|
2444
|
+
SINGLE_HOST_ENV.HOST,
|
|
2445
|
+
SINGLE_HOST_ENV.PORT,
|
|
2446
|
+
SINGLE_HOST_ENV.USER,
|
|
2447
|
+
SINGLE_HOST_ENV.KEY,
|
|
2448
|
+
SINGLE_HOST_ENV.PASSPHRASE,
|
|
2449
|
+
SINGLE_HOST_ENV.PASSWORD,
|
|
2450
|
+
SINGLE_HOST_ENV.DESCRIPTION,
|
|
2451
|
+
SINGLE_HOST_ENV.JUMP_HOST,
|
|
2452
|
+
SINGLE_HOST_ENV.JUMP_PORT,
|
|
2453
|
+
SINGLE_HOST_ENV.JUMP_USER,
|
|
2454
|
+
SINGLE_HOST_ENV.PROXY_COMMAND,
|
|
2455
|
+
SINGLE_HOST_ENV.READONLY,
|
|
2456
|
+
SINGLE_HOST_ENV.ALLOWED_PATHS,
|
|
2457
|
+
SINGLE_HOST_ENV.HOST_KEY,
|
|
2458
|
+
SINGLE_HOST_ENV.JUMP_HOST_KEY
|
|
2459
|
+
];
|
|
2460
|
+
var JUMP_ENV_PREFIX = "SSH_JUMP_";
|
|
2461
|
+
var SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,48}$/;
|
|
2462
|
+
function isOurEntry(entry) {
|
|
2463
|
+
if (entry.args.includes(PACKAGE_NAME) == true) {
|
|
2464
|
+
return true;
|
|
2465
|
+
}
|
|
2466
|
+
const commandBase = entry.command.replace(/\\/g, "/").split("/").pop() ?? "";
|
|
2467
|
+
return commandBase === SERVER_NAME || commandBase.startsWith(`${SERVER_NAME}.`) == true;
|
|
2468
|
+
}
|
|
2469
|
+
function launchCommand() {
|
|
2470
|
+
if (process.platform === "win32") {
|
|
2471
|
+
return { command: "cmd", args: ["/c", "npx", "-y", PACKAGE_NAME] };
|
|
2472
|
+
}
|
|
2473
|
+
return { command: "npx", args: ["-y", PACKAGE_NAME] };
|
|
2474
|
+
}
|
|
2475
|
+
function answersToEntry(answers) {
|
|
2476
|
+
const env = {};
|
|
2477
|
+
env[SINGLE_HOST_ENV.HOST] = answers.host;
|
|
2478
|
+
if (answers.port !== DEFAULT_SSH_PORT) {
|
|
2479
|
+
env[SINGLE_HOST_ENV.PORT] = String(answers.port);
|
|
2480
|
+
}
|
|
2481
|
+
env[SINGLE_HOST_ENV.USER] = answers.username;
|
|
2482
|
+
if (answers.auth === "key" && answers.keyPath != null) {
|
|
2483
|
+
env[SINGLE_HOST_ENV.KEY] = answers.keyPath;
|
|
2484
|
+
if (answers.passphrase != null && answers.passphrase !== "") {
|
|
2485
|
+
env[SINGLE_HOST_ENV.PASSPHRASE] = answers.passphrase;
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
if (answers.auth === "password" && answers.password != null) {
|
|
2489
|
+
env[SINGLE_HOST_ENV.PASSWORD] = answers.password;
|
|
2490
|
+
}
|
|
2491
|
+
if (answers.hostKeyFingerprint != null) {
|
|
2492
|
+
env[SINGLE_HOST_ENV.HOST_KEY] = answers.hostKeyFingerprint;
|
|
2493
|
+
}
|
|
2494
|
+
if (answers.description != null && answers.description !== "") {
|
|
2495
|
+
env[SINGLE_HOST_ENV.DESCRIPTION] = answers.description;
|
|
2496
|
+
}
|
|
2497
|
+
if (answers.jumpHost != null) {
|
|
2498
|
+
env[SINGLE_HOST_ENV.JUMP_HOST] = answers.jumpHost;
|
|
2499
|
+
if (answers.jumpPort != null && answers.jumpPort !== DEFAULT_SSH_PORT) {
|
|
2500
|
+
env[SINGLE_HOST_ENV.JUMP_PORT] = String(answers.jumpPort);
|
|
2501
|
+
}
|
|
2502
|
+
if (answers.jumpUser != null && answers.jumpUser !== answers.username) {
|
|
2503
|
+
env[SINGLE_HOST_ENV.JUMP_USER] = answers.jumpUser;
|
|
2504
|
+
}
|
|
2505
|
+
if (answers.jumpHostKeyFingerprint != null) {
|
|
2506
|
+
env[SINGLE_HOST_ENV.JUMP_HOST_KEY] = answers.jumpHostKeyFingerprint;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
if (answers.proxyCommand != null && answers.proxyCommand !== "") {
|
|
2510
|
+
env[SINGLE_HOST_ENV.PROXY_COMMAND] = answers.proxyCommand;
|
|
2511
|
+
}
|
|
2512
|
+
if (answers.readonly == true) {
|
|
2513
|
+
env[SINGLE_HOST_ENV.READONLY] = "true";
|
|
2514
|
+
}
|
|
2515
|
+
if (answers.allowedPaths.length > 0) {
|
|
2516
|
+
env[SINGLE_HOST_ENV.ALLOWED_PATHS] = answers.allowedPaths.join(",");
|
|
2517
|
+
}
|
|
2518
|
+
for (const [name, value] of Object.entries(answers.extraEnv)) {
|
|
2519
|
+
if (MANAGED_ENV_NAMES.includes(name) == true) {
|
|
2520
|
+
continue;
|
|
2521
|
+
}
|
|
2522
|
+
if (answers.jumpHost == null && name.startsWith(JUMP_ENV_PREFIX) == true) {
|
|
2523
|
+
continue;
|
|
2524
|
+
}
|
|
2525
|
+
env[name] = value;
|
|
2526
|
+
}
|
|
2527
|
+
const launch = launchCommand();
|
|
2528
|
+
return {
|
|
2529
|
+
command: launch.command,
|
|
2530
|
+
args: launch.args,
|
|
2531
|
+
env
|
|
2532
|
+
};
|
|
2533
|
+
}
|
|
2534
|
+
function readPort(value) {
|
|
2535
|
+
if (value == null) {
|
|
2536
|
+
return DEFAULT_SSH_PORT;
|
|
2537
|
+
}
|
|
2538
|
+
const parsed = Number(value);
|
|
2539
|
+
if (Number.isInteger(parsed) == false || parsed <= 0) {
|
|
2540
|
+
return DEFAULT_SSH_PORT;
|
|
2541
|
+
}
|
|
2542
|
+
return parsed;
|
|
2543
|
+
}
|
|
2544
|
+
function isTruthy(value) {
|
|
2545
|
+
if (value == null) {
|
|
2546
|
+
return false;
|
|
2547
|
+
}
|
|
2548
|
+
return TRUTHY_ENV_VALUES.includes(value.trim().toLowerCase());
|
|
2549
|
+
}
|
|
2550
|
+
function entryToAnswers(name, entry) {
|
|
2551
|
+
const env = entry.env;
|
|
2552
|
+
const host = env[SINGLE_HOST_ENV.HOST] ?? "";
|
|
2553
|
+
const username = env[SINGLE_HOST_ENV.USER] ?? "";
|
|
2554
|
+
let auth = "agent";
|
|
2555
|
+
if (env[SINGLE_HOST_ENV.KEY] != null) {
|
|
2556
|
+
auth = "key";
|
|
2557
|
+
} else if (env[SINGLE_HOST_ENV.PASSWORD] != null) {
|
|
2558
|
+
auth = "password";
|
|
2559
|
+
}
|
|
2560
|
+
let allowedPaths = [];
|
|
2561
|
+
const allowedPathsValue = env[SINGLE_HOST_ENV.ALLOWED_PATHS];
|
|
2562
|
+
if (allowedPathsValue != null) {
|
|
2563
|
+
allowedPaths = allowedPathsValue.split(",").map((item) => item.trim()).filter((item) => item !== "");
|
|
2564
|
+
}
|
|
2565
|
+
const extraEnv = {};
|
|
2566
|
+
for (const [variable, value] of Object.entries(env)) {
|
|
2567
|
+
if (MANAGED_ENV_NAMES.includes(variable) == false) {
|
|
2568
|
+
extraEnv[variable] = value;
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
const answers = {
|
|
2572
|
+
name,
|
|
2573
|
+
host,
|
|
2574
|
+
port: readPort(env[SINGLE_HOST_ENV.PORT]),
|
|
2575
|
+
username,
|
|
2576
|
+
auth,
|
|
2577
|
+
keyPath: env[SINGLE_HOST_ENV.KEY],
|
|
2578
|
+
passphrase: env[SINGLE_HOST_ENV.PASSPHRASE],
|
|
2579
|
+
password: env[SINGLE_HOST_ENV.PASSWORD],
|
|
2580
|
+
description: env[SINGLE_HOST_ENV.DESCRIPTION],
|
|
2581
|
+
proxyCommand: env[SINGLE_HOST_ENV.PROXY_COMMAND],
|
|
2582
|
+
readonly: isTruthy(env[SINGLE_HOST_ENV.READONLY]),
|
|
2583
|
+
allowedPaths,
|
|
2584
|
+
hostKeyFingerprint: env[SINGLE_HOST_ENV.HOST_KEY],
|
|
2585
|
+
extraEnv
|
|
2586
|
+
};
|
|
2587
|
+
const jumpHost = env[SINGLE_HOST_ENV.JUMP_HOST];
|
|
2588
|
+
if (jumpHost != null) {
|
|
2589
|
+
answers.jumpHost = jumpHost;
|
|
2590
|
+
answers.jumpPort = readPort(env[SINGLE_HOST_ENV.JUMP_PORT]);
|
|
2591
|
+
answers.jumpUser = env[SINGLE_HOST_ENV.JUMP_USER] ?? username;
|
|
2592
|
+
answers.jumpHostKeyFingerprint = env[SINGLE_HOST_ENV.JUMP_HOST_KEY];
|
|
2593
|
+
}
|
|
2594
|
+
return answers;
|
|
2595
|
+
}
|
|
2596
|
+
function summarizeEntry(entry) {
|
|
2597
|
+
const env = entry.env;
|
|
2598
|
+
const host = env[SINGLE_HOST_ENV.HOST] ?? "?";
|
|
2599
|
+
const port = env[SINGLE_HOST_ENV.PORT] ?? String(DEFAULT_SSH_PORT);
|
|
2600
|
+
const username = env[SINGLE_HOST_ENV.USER] ?? "?";
|
|
2601
|
+
let auth = "agent";
|
|
2602
|
+
if (env[SINGLE_HOST_ENV.KEY] != null) {
|
|
2603
|
+
auth = "key";
|
|
2604
|
+
} else if (env[SINGLE_HOST_ENV.PASSWORD] != null) {
|
|
2605
|
+
auth = "password";
|
|
2606
|
+
}
|
|
2607
|
+
const notes = [];
|
|
2608
|
+
if (env[SINGLE_HOST_ENV.JUMP_HOST] != null) {
|
|
2609
|
+
notes.push(`jump ${env[SINGLE_HOST_ENV.JUMP_HOST]}`);
|
|
2610
|
+
}
|
|
2611
|
+
if (env[SINGLE_HOST_ENV.PROXY_COMMAND] != null) {
|
|
2612
|
+
notes.push("proxy");
|
|
2613
|
+
}
|
|
2614
|
+
if (isTruthy(env[SINGLE_HOST_ENV.READONLY]) == true) {
|
|
2615
|
+
notes.push("readonly");
|
|
2616
|
+
}
|
|
2617
|
+
if (env[SINGLE_HOST_ENV.ALLOWED_PATHS] != null) {
|
|
2618
|
+
notes.push(`paths ${env[SINGLE_HOST_ENV.ALLOWED_PATHS]}`);
|
|
2619
|
+
}
|
|
2620
|
+
if (env[SINGLE_HOST_ENV.HOST_KEY] != null) {
|
|
2621
|
+
notes.push("pinned");
|
|
2622
|
+
}
|
|
2623
|
+
return {
|
|
2624
|
+
endpoint: `${username}@${host}:${port}`,
|
|
2625
|
+
auth,
|
|
2626
|
+
notes: notes.join(", ")
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
// src/modules/manage/prompter.ts
|
|
2631
|
+
var PromptClosedError = class extends Error {
|
|
2632
|
+
constructor() {
|
|
2633
|
+
super("input closed before the prompt was answered");
|
|
2634
|
+
this.name = "PromptClosedError";
|
|
2635
|
+
}
|
|
2636
|
+
};
|
|
2637
|
+
var ESC = "\x1B";
|
|
2638
|
+
var KEY_UP = [`${ESC}[A`, `${ESC}OA`, "k"];
|
|
2639
|
+
var KEY_DOWN = [`${ESC}[B`, `${ESC}OB`, "j"];
|
|
2640
|
+
var KEY_HOME = [`${ESC}[H`, `${ESC}OH`, `${ESC}[1~`];
|
|
2641
|
+
var KEY_END = [`${ESC}[F`, `${ESC}OF`, `${ESC}[4~`];
|
|
2642
|
+
var KEY_ENTER = ["\r", "\n"];
|
|
2643
|
+
var KEY_BACKSPACE = ["\x7F", "\b"];
|
|
2644
|
+
var KEY_CTRL_C = "";
|
|
2645
|
+
var KEY_CTRL_D = "";
|
|
2646
|
+
function splitKeys(chunk) {
|
|
2647
|
+
const keys = [];
|
|
2648
|
+
let index = 0;
|
|
2649
|
+
while (index < chunk.length) {
|
|
2650
|
+
const isSequence = chunk[index] === ESC && (chunk[index + 1] === "[" || chunk[index + 1] === "O");
|
|
2651
|
+
if (isSequence == true) {
|
|
2652
|
+
let end = index + 2;
|
|
2653
|
+
while (end < chunk.length && /[0-9;]/.test(chunk[end]) == true) {
|
|
2654
|
+
end += 1;
|
|
2655
|
+
}
|
|
2656
|
+
keys.push(chunk.slice(index, end + 1));
|
|
2657
|
+
index = end + 1;
|
|
2658
|
+
continue;
|
|
2659
|
+
}
|
|
2660
|
+
const codePoint = chunk.codePointAt(index) ?? 0;
|
|
2661
|
+
const character = String.fromCodePoint(codePoint);
|
|
2662
|
+
keys.push(character);
|
|
2663
|
+
index += character.length;
|
|
2664
|
+
}
|
|
2665
|
+
return keys;
|
|
2666
|
+
}
|
|
2667
|
+
function charWidth(codePoint) {
|
|
2668
|
+
if (codePoint < 4352) {
|
|
2669
|
+
return 1;
|
|
2670
|
+
}
|
|
2671
|
+
const isWide = codePoint <= 4447 || codePoint === 9001 || codePoint === 9002 || codePoint >= 11904 && codePoint <= 42191 && codePoint !== 12351 || codePoint >= 44032 && codePoint <= 55203 || codePoint >= 63744 && codePoint <= 64255 || codePoint >= 65072 && codePoint <= 65103 || codePoint >= 65280 && codePoint <= 65376 || codePoint >= 65504 && codePoint <= 65510 || codePoint >= 127744 && codePoint <= 128591 || codePoint >= 129280 && codePoint <= 129535 || codePoint >= 131072 && codePoint <= 262141;
|
|
2672
|
+
if (isWide == true) {
|
|
2673
|
+
return 2;
|
|
2674
|
+
}
|
|
2675
|
+
return 1;
|
|
2676
|
+
}
|
|
2677
|
+
function displayWidth(text) {
|
|
2678
|
+
let width = 0;
|
|
2679
|
+
for (const character of text) {
|
|
2680
|
+
width += charWidth(character.codePointAt(0) ?? 0);
|
|
2681
|
+
}
|
|
2682
|
+
return width;
|
|
2683
|
+
}
|
|
2684
|
+
function padToWidth(text, width) {
|
|
2685
|
+
const missing = width - displayWidth(text);
|
|
2686
|
+
if (missing <= 0) {
|
|
2687
|
+
return text;
|
|
2688
|
+
}
|
|
2689
|
+
return `${text}${" ".repeat(missing)}`;
|
|
2690
|
+
}
|
|
2691
|
+
function groupLabelWidths(items) {
|
|
2692
|
+
const widths = items.map(() => 0);
|
|
2693
|
+
let groupStart = 0;
|
|
2694
|
+
for (let index = 0; index <= items.length; index += 1) {
|
|
2695
|
+
const atBoundary = index === items.length || "value" in items[index] == false;
|
|
2696
|
+
if (atBoundary == false) {
|
|
2697
|
+
continue;
|
|
2698
|
+
}
|
|
2699
|
+
let groupWidth = 0;
|
|
2700
|
+
for (let member = groupStart; member < index; member += 1) {
|
|
2701
|
+
const item = items[member];
|
|
2702
|
+
groupWidth = Math.max(groupWidth, displayWidth(item.label));
|
|
2703
|
+
}
|
|
2704
|
+
for (let member = groupStart; member < index; member += 1) {
|
|
2705
|
+
widths[member] = groupWidth;
|
|
2706
|
+
}
|
|
2707
|
+
groupStart = index + 1;
|
|
2708
|
+
}
|
|
2709
|
+
return widths;
|
|
2710
|
+
}
|
|
2711
|
+
function supportsUnicode() {
|
|
2712
|
+
if (process.platform !== "win32") {
|
|
2713
|
+
return true;
|
|
2714
|
+
}
|
|
2715
|
+
return process.env.WT_SESSION != null || process.env.TERM_PROGRAM === "vscode" || process.env.TERMINAL_EMULATOR === "JetBrains-JediTerm" || process.env.ConEmuTask != null;
|
|
2716
|
+
}
|
|
2717
|
+
function createStyle(enabled) {
|
|
2718
|
+
const paint = (open, close) => {
|
|
2719
|
+
return (text) => {
|
|
2720
|
+
if (enabled == false || text === "") {
|
|
2721
|
+
return text;
|
|
2722
|
+
}
|
|
2723
|
+
return `${ESC}[${open}m${text}${ESC}[${close}m`;
|
|
2724
|
+
};
|
|
2725
|
+
};
|
|
2726
|
+
return {
|
|
2727
|
+
accent: paint(36, 39),
|
|
2728
|
+
success: paint(32, 39),
|
|
2729
|
+
dim: paint(2, 22),
|
|
2730
|
+
bold: paint(1, 22)
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
var Prompter = class {
|
|
2734
|
+
constructor(input, output) {
|
|
2735
|
+
this.input = input;
|
|
2736
|
+
this.output = output;
|
|
2737
|
+
this.lineBuffer = "";
|
|
2738
|
+
this.pendingLines = [];
|
|
2739
|
+
this.lineWaiter = null;
|
|
2740
|
+
this.inputEnded = false;
|
|
2741
|
+
/** Set while a key-driven prompt (select, secret) owns the input. */
|
|
2742
|
+
this.keyHandler = null;
|
|
2743
|
+
const ttyInput = input;
|
|
2744
|
+
const ttyOutput = output;
|
|
2745
|
+
this.interactive = ttyInput.isTTY == true && ttyInput.setRawMode != null && ttyOutput.isTTY == true;
|
|
2746
|
+
this.style = createStyle(this.interactive == true && process.env.NO_COLOR == null);
|
|
2747
|
+
if (supportsUnicode() == true) {
|
|
2748
|
+
this.symbols = { question: "?", pointer: "\u276F", tick: "\u2714", rule: "\u2500" };
|
|
2749
|
+
} else {
|
|
2750
|
+
this.symbols = { question: "?", pointer: ">", tick: "\u221A", rule: "-" };
|
|
2751
|
+
}
|
|
2752
|
+
input.setEncoding("utf8");
|
|
2753
|
+
input.on("data", (chunk) => {
|
|
2754
|
+
this.onData(chunk);
|
|
2755
|
+
});
|
|
2756
|
+
input.on("end", () => {
|
|
2757
|
+
this.inputEnded = true;
|
|
2758
|
+
if (this.lineWaiter != null) {
|
|
2759
|
+
const waiter = this.lineWaiter;
|
|
2760
|
+
this.lineWaiter = null;
|
|
2761
|
+
waiter(null);
|
|
2762
|
+
}
|
|
2763
|
+
if (this.keyHandler != null) {
|
|
2764
|
+
this.keyHandler(KEY_CTRL_D);
|
|
2765
|
+
}
|
|
2766
|
+
});
|
|
2767
|
+
}
|
|
2768
|
+
/** Stops reading so the process can exit once the command is done. */
|
|
2769
|
+
close() {
|
|
2770
|
+
if (this.interactive == true) {
|
|
2771
|
+
this.input.setRawMode?.(false);
|
|
2772
|
+
this.output.write(`${ESC}[?25h`);
|
|
2773
|
+
}
|
|
2774
|
+
this.input.pause();
|
|
2775
|
+
}
|
|
2776
|
+
write(text) {
|
|
2777
|
+
this.output.write(text);
|
|
2778
|
+
}
|
|
2779
|
+
/** Styling for callers that print around the prompts; plain text off a TTY. */
|
|
2780
|
+
get paint() {
|
|
2781
|
+
return this.style;
|
|
2782
|
+
}
|
|
2783
|
+
/**
|
|
2784
|
+
* Free-text prompt. Enter accepts `defaultValue` when one is shown.
|
|
2785
|
+
*
|
|
2786
|
+
* @throws {PromptClosedError} If the input closes before a line arrives.
|
|
2787
|
+
*/
|
|
2788
|
+
async ask(question, defaultValue) {
|
|
2789
|
+
let suffix = "";
|
|
2790
|
+
if (defaultValue != null && defaultValue !== "") {
|
|
2791
|
+
suffix = ` ${this.style.dim(`(${defaultValue})`)}`;
|
|
2792
|
+
}
|
|
2793
|
+
const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)}${suffix} `;
|
|
2794
|
+
this.output.write(prompt);
|
|
2795
|
+
const line = await this.readLine();
|
|
2796
|
+
if (line == null) {
|
|
2797
|
+
throw new PromptClosedError();
|
|
2798
|
+
}
|
|
2799
|
+
let answer = line.trim();
|
|
2800
|
+
if (answer === "" && defaultValue != null) {
|
|
2801
|
+
answer = defaultValue;
|
|
2802
|
+
}
|
|
2803
|
+
this.collapseAnswered(prompt, line, question, answer);
|
|
2804
|
+
return answer;
|
|
2805
|
+
}
|
|
2806
|
+
/** Like `ask`, re-asking until something non-empty is entered. */
|
|
2807
|
+
async askRequired(question, defaultValue) {
|
|
2808
|
+
for (; ; ) {
|
|
2809
|
+
const answer = await this.ask(question, defaultValue);
|
|
2810
|
+
if (answer !== "") {
|
|
2811
|
+
return answer;
|
|
2812
|
+
}
|
|
2813
|
+
this.output.write(` ${this.style.dim("A value is required.")}
|
|
2814
|
+
`);
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
async askYesNo(question, defaultValue) {
|
|
2818
|
+
let hint = "y/N";
|
|
2819
|
+
if (defaultValue == true) {
|
|
2820
|
+
hint = "Y/n";
|
|
2821
|
+
}
|
|
2822
|
+
for (; ; ) {
|
|
2823
|
+
const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)} ${this.style.dim(`(${hint})`)} `;
|
|
2824
|
+
this.output.write(prompt);
|
|
2825
|
+
const line = await this.readLine();
|
|
2826
|
+
if (line == null) {
|
|
2827
|
+
throw new PromptClosedError();
|
|
2828
|
+
}
|
|
2829
|
+
const normalized = line.trim().toLowerCase();
|
|
2830
|
+
let answer = null;
|
|
2831
|
+
if (normalized === "") {
|
|
2832
|
+
answer = defaultValue;
|
|
2833
|
+
} else if (normalized === "y" || normalized === "yes") {
|
|
2834
|
+
answer = true;
|
|
2835
|
+
} else if (normalized === "n" || normalized === "no") {
|
|
2836
|
+
answer = false;
|
|
2837
|
+
}
|
|
2838
|
+
if (answer != null) {
|
|
2839
|
+
let shown = "no";
|
|
2840
|
+
if (answer == true) {
|
|
2841
|
+
shown = "yes";
|
|
2842
|
+
}
|
|
2843
|
+
this.collapseAnswered(prompt, line, question, shown);
|
|
2844
|
+
return answer;
|
|
2845
|
+
}
|
|
2846
|
+
this.output.write(` ${this.style.dim("Answer y or n.")}
|
|
2847
|
+
`);
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
/**
|
|
2851
|
+
* Picks one of `items`. On a TTY: ↑/↓ (or k/j) move, 1–9 jump, Enter picks, Esc returns
|
|
2852
|
+
* `cancelValue`. Off a TTY: a numbered list, answered by number, key or label.
|
|
2853
|
+
*
|
|
2854
|
+
* @throws {PromptClosedError} On Ctrl+C or when the input closes.
|
|
2855
|
+
*/
|
|
2856
|
+
async select(question, items, options) {
|
|
2857
|
+
const choices = items.filter((item) => "value" in item);
|
|
2858
|
+
if (choices.length === 0) {
|
|
2859
|
+
throw new Error("select needs at least one choice");
|
|
2860
|
+
}
|
|
2861
|
+
let startIndex = items.findIndex((item) => "value" in item && item.value === options?.defaultValue);
|
|
2862
|
+
if (startIndex < 0) {
|
|
2863
|
+
startIndex = items.findIndex((item) => "value" in item);
|
|
2864
|
+
}
|
|
2865
|
+
if (this.interactive == true) {
|
|
2866
|
+
const picked2 = await this.selectWithKeys(question, items, startIndex, options?.cancelValue);
|
|
2867
|
+
return picked2;
|
|
2868
|
+
}
|
|
2869
|
+
const picked = await this.selectWithLines(question, items, startIndex);
|
|
2870
|
+
return picked;
|
|
2871
|
+
}
|
|
2872
|
+
/**
|
|
2873
|
+
* Reads a value without echoing it on a TTY. Empty means "none", or "keep" when the
|
|
2874
|
+
* caller says a value already exists.
|
|
2875
|
+
*
|
|
2876
|
+
* @throws {PromptClosedError} On Ctrl+C or when the input closes.
|
|
2877
|
+
*/
|
|
2878
|
+
async askSecret(question) {
|
|
2879
|
+
if (this.interactive == false) {
|
|
2880
|
+
const answer = await this.ask(question);
|
|
2881
|
+
return answer;
|
|
2882
|
+
}
|
|
2883
|
+
const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)} `;
|
|
2884
|
+
this.output.write(prompt);
|
|
2885
|
+
const ttyInput = this.input;
|
|
2886
|
+
return new Promise((resolve, reject) => {
|
|
2887
|
+
let buffer = "";
|
|
2888
|
+
const finish = () => {
|
|
2889
|
+
this.keyHandler = null;
|
|
2890
|
+
ttyInput.setRawMode?.(false);
|
|
2891
|
+
this.output.write(`\r${ESC}[2K`);
|
|
2892
|
+
};
|
|
2893
|
+
this.keyHandler = (key) => {
|
|
2894
|
+
if (key === KEY_CTRL_C || key === KEY_CTRL_D) {
|
|
2895
|
+
finish();
|
|
2896
|
+
reject(new PromptClosedError());
|
|
2897
|
+
return;
|
|
2898
|
+
}
|
|
2899
|
+
if (KEY_ENTER.includes(key) == true) {
|
|
2900
|
+
finish();
|
|
2901
|
+
let shown = this.style.dim("(empty)");
|
|
2902
|
+
if (buffer !== "") {
|
|
2903
|
+
shown = "\u2022".repeat(Math.min(buffer.length, 8));
|
|
2904
|
+
}
|
|
2905
|
+
this.writeAnswered(question, shown);
|
|
2906
|
+
resolve(buffer);
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
if (KEY_BACKSPACE.includes(key) == true) {
|
|
2910
|
+
buffer = Array.from(buffer).slice(0, -1).join("");
|
|
2911
|
+
return;
|
|
2912
|
+
}
|
|
2913
|
+
if (key.startsWith(ESC) == true) {
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
buffer += key;
|
|
2917
|
+
};
|
|
2918
|
+
ttyInput.setRawMode?.(true);
|
|
2919
|
+
});
|
|
2920
|
+
}
|
|
2921
|
+
// ── line mode ──────────────────────────────────────────────────────────
|
|
2922
|
+
readLine() {
|
|
2923
|
+
const queued = this.pendingLines.shift();
|
|
2924
|
+
if (queued != null) {
|
|
2925
|
+
return Promise.resolve(queued);
|
|
2926
|
+
}
|
|
2927
|
+
if (this.inputEnded == true) {
|
|
2928
|
+
return Promise.resolve(null);
|
|
2929
|
+
}
|
|
2930
|
+
return new Promise((resolve) => {
|
|
2931
|
+
this.lineWaiter = resolve;
|
|
2932
|
+
});
|
|
2933
|
+
}
|
|
2934
|
+
onData(chunk) {
|
|
2935
|
+
if (this.keyHandler != null) {
|
|
2936
|
+
for (const key of splitKeys(chunk)) {
|
|
2937
|
+
if (this.keyHandler == null) {
|
|
2938
|
+
break;
|
|
2939
|
+
}
|
|
2940
|
+
this.keyHandler(key);
|
|
2941
|
+
}
|
|
2942
|
+
return;
|
|
2943
|
+
}
|
|
2944
|
+
this.lineBuffer += chunk;
|
|
2945
|
+
let newlineIndex = this.lineBuffer.indexOf("\n");
|
|
2946
|
+
while (newlineIndex >= 0) {
|
|
2947
|
+
const line = this.lineBuffer.slice(0, newlineIndex).replace(/\r$/, "");
|
|
2948
|
+
this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1);
|
|
2949
|
+
if (this.lineWaiter != null) {
|
|
2950
|
+
const waiter = this.lineWaiter;
|
|
2951
|
+
this.lineWaiter = null;
|
|
2952
|
+
waiter(line);
|
|
2953
|
+
} else {
|
|
2954
|
+
this.pendingLines.push(line);
|
|
2955
|
+
}
|
|
2956
|
+
newlineIndex = this.lineBuffer.indexOf("\n");
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
async selectWithLines(question, items, startIndex) {
|
|
2960
|
+
const choices = items.filter((item) => "value" in item);
|
|
2961
|
+
const labelWidths = groupLabelWidths(items);
|
|
2962
|
+
this.output.write(`${this.symbols.question} ${question}
|
|
2963
|
+
`);
|
|
2964
|
+
let number = 0;
|
|
2965
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
2966
|
+
const item = items[index];
|
|
2967
|
+
if ("value" in item == false) {
|
|
2968
|
+
this.output.write("\n");
|
|
2969
|
+
continue;
|
|
2970
|
+
}
|
|
2971
|
+
number += 1;
|
|
2972
|
+
let line = ` ${String(number).padStart(2)}) ${padToWidth(item.label, labelWidths[index])}`;
|
|
2973
|
+
if (item.hint != null && item.hint !== "") {
|
|
2974
|
+
line += ` ${item.hint}`;
|
|
2975
|
+
}
|
|
2976
|
+
this.output.write(`${line.trimEnd()}
|
|
2977
|
+
`);
|
|
2978
|
+
}
|
|
2979
|
+
const defaultChoice = items[startIndex];
|
|
2980
|
+
const defaultNumber = choices.indexOf(defaultChoice) + 1;
|
|
2981
|
+
for (; ; ) {
|
|
2982
|
+
this.output.write(`Choice (${defaultNumber}): `);
|
|
2983
|
+
const line = await this.readLine();
|
|
2984
|
+
if (line == null) {
|
|
2985
|
+
throw new PromptClosedError();
|
|
2986
|
+
}
|
|
2987
|
+
const answer = line.trim();
|
|
2988
|
+
if (answer === "") {
|
|
2989
|
+
return defaultChoice.value;
|
|
2990
|
+
}
|
|
2991
|
+
const index = Number(answer);
|
|
2992
|
+
if (Number.isInteger(index) == true && index >= 1 && index <= choices.length) {
|
|
2993
|
+
return choices[index - 1].value;
|
|
2994
|
+
}
|
|
2995
|
+
const lowered = answer.toLowerCase();
|
|
2996
|
+
const matched = choices.find((choice) => {
|
|
2997
|
+
const key = choice.key ?? choice.value;
|
|
2998
|
+
return key.toLowerCase() === lowered || choice.label.toLowerCase() === lowered;
|
|
2999
|
+
});
|
|
3000
|
+
if (matched != null) {
|
|
3001
|
+
return matched.value;
|
|
3002
|
+
}
|
|
3003
|
+
this.output.write(` Pick a number from 1 to ${choices.length}.
|
|
3004
|
+
`);
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
// ── TTY rendering ──────────────────────────────────────────────────────
|
|
3008
|
+
columns() {
|
|
3009
|
+
const columns = this.output.columns;
|
|
3010
|
+
if (columns == null || columns < 20) {
|
|
3011
|
+
return 80;
|
|
3012
|
+
}
|
|
3013
|
+
return columns;
|
|
3014
|
+
}
|
|
3015
|
+
/** Joins styled parts, cutting the text (not the styles) once `width` columns are used. */
|
|
3016
|
+
fitLine(parts, width) {
|
|
3017
|
+
let used = 0;
|
|
3018
|
+
let line = "";
|
|
3019
|
+
for (const part of parts) {
|
|
3020
|
+
let text = "";
|
|
3021
|
+
for (const character of part.text) {
|
|
3022
|
+
const characterWidth = charWidth(character.codePointAt(0) ?? 0);
|
|
3023
|
+
if (used + characterWidth > width) {
|
|
3024
|
+
break;
|
|
3025
|
+
}
|
|
3026
|
+
used += characterWidth;
|
|
3027
|
+
text += character;
|
|
3028
|
+
}
|
|
3029
|
+
if (part.paint != null) {
|
|
3030
|
+
line += part.paint(text);
|
|
3031
|
+
} else {
|
|
3032
|
+
line += text;
|
|
3033
|
+
}
|
|
3034
|
+
if (used >= width) {
|
|
3035
|
+
break;
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
return line;
|
|
3039
|
+
}
|
|
3040
|
+
/** The one-line record an answered prompt leaves behind on a TTY. */
|
|
3041
|
+
writeAnswered(question, answer) {
|
|
3042
|
+
this.output.write(`${this.style.success(this.symbols.tick)} ${question} ${this.style.dim("\xB7")} ${this.style.accent(answer)}
|
|
3043
|
+
`);
|
|
3044
|
+
}
|
|
3045
|
+
/**
|
|
3046
|
+
* Replaces a cooked-mode prompt and its echoed answer with the one-line record. The
|
|
3047
|
+
* rows are worked out from the widths, since a long answer wraps.
|
|
3048
|
+
*/
|
|
3049
|
+
collapseAnswered(prompt, typed, question, answer) {
|
|
3050
|
+
if (this.interactive == false) {
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
const plainPrompt = prompt.replace(/\u001b\[[0-9;]*m/g, "");
|
|
3054
|
+
const used = displayWidth(plainPrompt) + displayWidth(typed);
|
|
3055
|
+
const rows = Math.max(1, Math.ceil(used / this.columns()));
|
|
3056
|
+
this.output.write(`${ESC}[${rows}A\r${ESC}[J`);
|
|
3057
|
+
let shown = answer;
|
|
3058
|
+
if (shown === "") {
|
|
3059
|
+
shown = this.style.dim("(empty)");
|
|
3060
|
+
}
|
|
3061
|
+
this.writeAnswered(question, shown);
|
|
3062
|
+
}
|
|
3063
|
+
selectWithKeys(question, items, startIndex, cancelValue) {
|
|
3064
|
+
const ttyInput = this.input;
|
|
3065
|
+
const selectableIndexes = items.flatMap((item, index) => {
|
|
3066
|
+
if ("value" in item) {
|
|
3067
|
+
return [index];
|
|
3068
|
+
}
|
|
3069
|
+
return [];
|
|
3070
|
+
});
|
|
3071
|
+
const labelWidths = groupLabelWidths(items);
|
|
3072
|
+
const ruleWidth = Math.min(Math.max(...labelWidths), 24);
|
|
3073
|
+
let active = startIndex;
|
|
3074
|
+
let renderedLines = 0;
|
|
3075
|
+
const draw = (lines) => {
|
|
3076
|
+
let text = "";
|
|
3077
|
+
if (renderedLines > 1) {
|
|
3078
|
+
text += `${ESC}[${renderedLines - 1}A`;
|
|
3079
|
+
}
|
|
3080
|
+
text += `\r${ESC}[J${lines.join("\n")}`;
|
|
3081
|
+
this.output.write(text);
|
|
3082
|
+
renderedLines = lines.length;
|
|
3083
|
+
};
|
|
3084
|
+
const buildLines = () => {
|
|
3085
|
+
const width = this.columns() - 1;
|
|
3086
|
+
const lines = [
|
|
3087
|
+
this.fitLine([
|
|
3088
|
+
{ text: this.symbols.question, paint: this.style.accent },
|
|
3089
|
+
{ text: " " },
|
|
3090
|
+
{ text: question, paint: this.style.bold }
|
|
3091
|
+
], width)
|
|
3092
|
+
];
|
|
3093
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
3094
|
+
const item = items[index];
|
|
3095
|
+
if ("value" in item == false) {
|
|
3096
|
+
lines.push(this.fitLine([{ text: ` ${this.symbols.rule.repeat(ruleWidth)}`, paint: this.style.dim }], width));
|
|
3097
|
+
continue;
|
|
3098
|
+
}
|
|
3099
|
+
const isActive = index === active;
|
|
3100
|
+
const parts = [];
|
|
3101
|
+
if (isActive == true) {
|
|
3102
|
+
parts.push({ text: `${this.symbols.pointer} `, paint: this.style.accent });
|
|
3103
|
+
parts.push({ text: padToWidth(item.label, labelWidths[index]), paint: this.style.accent });
|
|
3104
|
+
} else {
|
|
3105
|
+
parts.push({ text: " " });
|
|
3106
|
+
parts.push({ text: padToWidth(item.label, labelWidths[index]) });
|
|
3107
|
+
}
|
|
3108
|
+
if (item.hint != null && item.hint !== "") {
|
|
3109
|
+
parts.push({ text: ` ${item.hint}`, paint: this.style.dim });
|
|
3110
|
+
}
|
|
3111
|
+
lines.push(this.fitLine(parts, width));
|
|
3112
|
+
}
|
|
3113
|
+
let footer = "\u2191\u2193 move \xB7 enter select";
|
|
3114
|
+
if (cancelValue != null) {
|
|
3115
|
+
footer += " \xB7 esc back";
|
|
3116
|
+
}
|
|
3117
|
+
lines.push(this.fitLine([{ text: footer, paint: this.style.dim }], width));
|
|
3118
|
+
return lines;
|
|
3119
|
+
};
|
|
3120
|
+
const move = (step) => {
|
|
3121
|
+
const position = selectableIndexes.indexOf(active);
|
|
3122
|
+
const next = (position + step + selectableIndexes.length) % selectableIndexes.length;
|
|
3123
|
+
active = selectableIndexes[next];
|
|
3124
|
+
};
|
|
3125
|
+
return new Promise((resolve, reject) => {
|
|
3126
|
+
const finish = () => {
|
|
3127
|
+
this.keyHandler = null;
|
|
3128
|
+
ttyInput.setRawMode?.(false);
|
|
3129
|
+
draw([]);
|
|
3130
|
+
this.output.write(`${ESC}[?25h`);
|
|
3131
|
+
};
|
|
3132
|
+
this.keyHandler = (key) => {
|
|
3133
|
+
if (key === KEY_CTRL_C || key === KEY_CTRL_D) {
|
|
3134
|
+
finish();
|
|
3135
|
+
reject(new PromptClosedError());
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
3138
|
+
if (KEY_ENTER.includes(key) == true) {
|
|
3139
|
+
const picked = items[active];
|
|
3140
|
+
finish();
|
|
3141
|
+
this.writeAnswered(question, picked.label);
|
|
3142
|
+
resolve(picked.value);
|
|
3143
|
+
return;
|
|
3144
|
+
}
|
|
3145
|
+
if (key === ESC) {
|
|
3146
|
+
if (cancelValue == null) {
|
|
3147
|
+
return;
|
|
3148
|
+
}
|
|
3149
|
+
finish();
|
|
3150
|
+
resolve(cancelValue);
|
|
3151
|
+
return;
|
|
3152
|
+
}
|
|
3153
|
+
if (KEY_UP.includes(key) == true) {
|
|
3154
|
+
move(-1);
|
|
3155
|
+
} else if (KEY_DOWN.includes(key) == true) {
|
|
3156
|
+
move(1);
|
|
3157
|
+
} else if (KEY_HOME.includes(key) == true) {
|
|
3158
|
+
active = selectableIndexes[0];
|
|
3159
|
+
} else if (KEY_END.includes(key) == true) {
|
|
3160
|
+
active = selectableIndexes[selectableIndexes.length - 1];
|
|
3161
|
+
} else if (/^[1-9]$/.test(key) == true && Number(key) <= selectableIndexes.length) {
|
|
3162
|
+
active = selectableIndexes[Number(key) - 1];
|
|
3163
|
+
} else {
|
|
3164
|
+
return;
|
|
3165
|
+
}
|
|
3166
|
+
draw(buildLines());
|
|
3167
|
+
};
|
|
3168
|
+
ttyInput.setRawMode?.(true);
|
|
3169
|
+
this.output.write(`${ESC}[?25l`);
|
|
3170
|
+
draw(buildLines());
|
|
3171
|
+
});
|
|
3172
|
+
}
|
|
3173
|
+
};
|
|
3174
|
+
var PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
|
3175
|
+
var NON_KEY_FILE_NAMES = ["config", "known_hosts", "known_hosts.old", "authorized_keys", "environment"];
|
|
3176
|
+
var MAX_KEY_FILE_BYTES = 64 * 1024;
|
|
3177
|
+
var CLEAR_ANSWER = "-";
|
|
3178
|
+
var OTHER_KEY_PATH = "\0other";
|
|
3179
|
+
function findPrivateKeys() {
|
|
3180
|
+
const sshDirectory = path__default.default.join(os5__default.default.homedir(), ".ssh");
|
|
3181
|
+
let names = [];
|
|
3182
|
+
try {
|
|
3183
|
+
names = fs6__default.default.readdirSync(sshDirectory);
|
|
3184
|
+
} catch {
|
|
3185
|
+
return [];
|
|
3186
|
+
}
|
|
3187
|
+
const keys = [];
|
|
3188
|
+
for (const name of names) {
|
|
3189
|
+
if (name.endsWith(".pub") == true || NON_KEY_FILE_NAMES.includes(name) == true) {
|
|
3190
|
+
continue;
|
|
3191
|
+
}
|
|
3192
|
+
const fullPath = path__default.default.join(sshDirectory, name);
|
|
3193
|
+
let head = "";
|
|
3194
|
+
try {
|
|
3195
|
+
const stat = fs6__default.default.statSync(fullPath);
|
|
3196
|
+
if (stat.isFile() == false || stat.size > MAX_KEY_FILE_BYTES) {
|
|
3197
|
+
continue;
|
|
3198
|
+
}
|
|
3199
|
+
const descriptor = fs6__default.default.openSync(fullPath, "r");
|
|
3200
|
+
const buffer = Buffer.alloc(64);
|
|
3201
|
+
const bytesRead = fs6__default.default.readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
3202
|
+
fs6__default.default.closeSync(descriptor);
|
|
3203
|
+
head = buffer.subarray(0, bytesRead).toString("utf8");
|
|
3204
|
+
} catch {
|
|
3205
|
+
continue;
|
|
3206
|
+
}
|
|
3207
|
+
const isPemKey = head.startsWith("-----BEGIN") == true && head.includes("PRIVATE KEY") == true;
|
|
3208
|
+
const isPuttyKey = head.startsWith("PuTTY-User-Key-File") == true;
|
|
3209
|
+
if (isPemKey == false && isPuttyKey == false) {
|
|
3210
|
+
continue;
|
|
3211
|
+
}
|
|
3212
|
+
let hint = "";
|
|
3213
|
+
try {
|
|
3214
|
+
const publicKey = fs6__default.default.readFileSync(`${fullPath}.pub`, "utf8").trim().split(/\s+/);
|
|
3215
|
+
hint = publicKey[0].replace(/^ssh-/, "");
|
|
3216
|
+
if (publicKey.length > 2) {
|
|
3217
|
+
hint += ` \xB7 ${publicKey.slice(2).join(" ")}`;
|
|
3218
|
+
}
|
|
3219
|
+
} catch {
|
|
3220
|
+
}
|
|
3221
|
+
keys.push({ path: `~/.ssh/${name}`, hint });
|
|
3222
|
+
}
|
|
3223
|
+
const rank = (key) => {
|
|
3224
|
+
const index = PREFERRED_KEY_NAMES.indexOf(path__default.default.basename(key.path));
|
|
3225
|
+
if (index < 0) {
|
|
3226
|
+
return PREFERRED_KEY_NAMES.length;
|
|
3227
|
+
}
|
|
3228
|
+
return index;
|
|
3229
|
+
};
|
|
3230
|
+
return keys.sort((left, right) => rank(left) - rank(right) || left.path.localeCompare(right.path));
|
|
3231
|
+
}
|
|
3232
|
+
async function askPort(prompter, question, current) {
|
|
3233
|
+
for (; ; ) {
|
|
3234
|
+
const answer = await prompter.ask(question, String(current));
|
|
3235
|
+
const parsed = Number(answer);
|
|
3236
|
+
if (Number.isInteger(parsed) == true && parsed > 0 && parsed <= 65535) {
|
|
3237
|
+
return parsed;
|
|
3238
|
+
}
|
|
3239
|
+
prompter.write(" Enter a port number between 1 and 65535.\n");
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
async function askOptional(prompter, question, current) {
|
|
3243
|
+
const answer = await prompter.ask(question, current);
|
|
3244
|
+
if (answer === "" || answer === CLEAR_ANSWER) {
|
|
3245
|
+
return void 0;
|
|
3246
|
+
}
|
|
3247
|
+
return answer;
|
|
3248
|
+
}
|
|
3249
|
+
async function askOptionalSecret(prompter, question, current) {
|
|
3250
|
+
let suffix = "(empty for none)";
|
|
3251
|
+
if (current != null) {
|
|
3252
|
+
suffix = `(enter keeps current, ${CLEAR_ANSWER} clears)`;
|
|
3253
|
+
}
|
|
3254
|
+
const answer = await prompter.askSecret(`${question} ${prompter.paint.dim(suffix)}`);
|
|
3255
|
+
if (answer === CLEAR_ANSWER) {
|
|
3256
|
+
return void 0;
|
|
3257
|
+
}
|
|
3258
|
+
if (answer === "") {
|
|
3259
|
+
return current;
|
|
3260
|
+
}
|
|
3261
|
+
return answer;
|
|
3262
|
+
}
|
|
3263
|
+
async function askKeyPath(prompter, current) {
|
|
3264
|
+
const found = findPrivateKeys();
|
|
3265
|
+
let picked = OTHER_KEY_PATH;
|
|
3266
|
+
if (found.length > 0 || current != null) {
|
|
3267
|
+
const items = [];
|
|
3268
|
+
if (current != null && found.some((key) => key.path === current) == false) {
|
|
3269
|
+
items.push({ value: current, label: current, hint: "current" });
|
|
3270
|
+
}
|
|
3271
|
+
for (const key of found) {
|
|
3272
|
+
items.push({ value: key.path, label: key.path, hint: key.hint });
|
|
3273
|
+
}
|
|
3274
|
+
items.push({ separator: true });
|
|
3275
|
+
items.push({ value: OTHER_KEY_PATH, key: "other", label: "Another path\u2026" });
|
|
3276
|
+
picked = await prompter.select("Private key", items, { defaultValue: current ?? found[0]?.path });
|
|
3277
|
+
}
|
|
3278
|
+
if (picked !== OTHER_KEY_PATH) {
|
|
3279
|
+
return picked;
|
|
3280
|
+
}
|
|
3281
|
+
for (; ; ) {
|
|
3282
|
+
const typed = await prompter.askRequired("Private key path");
|
|
3283
|
+
if (fs6__default.default.existsSync(expandHomePath(typed)) == true) {
|
|
3284
|
+
return typed;
|
|
3285
|
+
}
|
|
3286
|
+
prompter.write(` ${expandHomePath(typed)} does not exist.
|
|
3287
|
+
`);
|
|
3288
|
+
const useAnyway = await prompter.askYesNo("Use this path anyway?", false);
|
|
3289
|
+
if (useAnyway == true) {
|
|
3290
|
+
return typed;
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
async function askHostQuestions(prompter, existing) {
|
|
3295
|
+
if (existing != null) {
|
|
3296
|
+
prompter.write(prompter.paint.dim(` Enter keeps the value in brackets; ${CLEAR_ANSWER} clears an optional one.
|
|
3297
|
+
|
|
3298
|
+
`));
|
|
3299
|
+
}
|
|
3300
|
+
const host = await prompter.askRequired("Host (hostname or IP)", existing?.host);
|
|
3301
|
+
const port = await askPort(prompter, "Port", existing?.port ?? DEFAULT_SSH_PORT);
|
|
3302
|
+
const username = await prompter.askRequired("Login user", existing?.username);
|
|
3303
|
+
let name = "";
|
|
3304
|
+
const defaultName = existing?.name ?? `ssh-${host}`.replace(/[^A-Za-z0-9_-]+/g, "-").slice(0, 48);
|
|
3305
|
+
for (; ; ) {
|
|
3306
|
+
name = await prompter.askRequired("Server name the agent sees", defaultName);
|
|
3307
|
+
if (SERVER_NAME_PATTERN.test(name) == true) {
|
|
3308
|
+
break;
|
|
3309
|
+
}
|
|
3310
|
+
prompter.write(" Use letters, digits, '-' and '_' only (max 48) \u2014 the name becomes part of every tool name.\n");
|
|
3311
|
+
}
|
|
3312
|
+
const auth = await prompter.select(
|
|
3313
|
+
"Authentication",
|
|
3314
|
+
[
|
|
3315
|
+
{ value: "key", label: "Private key file", hint: "only the path is stored \u2014 recommended" },
|
|
3316
|
+
{ value: "password", label: "Password", hint: "stored in the config file as plain text" },
|
|
3317
|
+
{ value: "agent", label: "SSH agent", hint: "keys already loaded in ssh-agent" }
|
|
3318
|
+
],
|
|
3319
|
+
{ defaultValue: existing?.auth ?? "key" }
|
|
3320
|
+
);
|
|
3321
|
+
let keyPath;
|
|
3322
|
+
let passphrase;
|
|
3323
|
+
let password;
|
|
3324
|
+
if (auth === "key") {
|
|
3325
|
+
keyPath = await askKeyPath(prompter, existing?.keyPath);
|
|
3326
|
+
passphrase = await askOptionalSecret(prompter, "Key passphrase", existing?.passphrase);
|
|
3327
|
+
}
|
|
3328
|
+
if (auth === "password") {
|
|
3329
|
+
password = await askOptionalSecret(prompter, "Password", existing?.password);
|
|
3330
|
+
if (password == null) {
|
|
3331
|
+
prompter.write(" No password given \u2014 falling back to the ssh agent.\n");
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
let currentRoute = "direct";
|
|
3335
|
+
if (existing?.jumpHost != null && existing.proxyCommand != null) {
|
|
3336
|
+
currentRoute = "jump-proxy";
|
|
3337
|
+
} else if (existing?.jumpHost != null) {
|
|
3338
|
+
currentRoute = "jump";
|
|
3339
|
+
} else if (existing?.proxyCommand != null) {
|
|
3340
|
+
currentRoute = "proxy";
|
|
3341
|
+
}
|
|
3342
|
+
const route = await prompter.select(
|
|
3343
|
+
"How is it reached?",
|
|
3344
|
+
[
|
|
3345
|
+
{ value: "direct", label: "Directly" },
|
|
3346
|
+
{ value: "jump", label: "Through a jump host", hint: "ssh -J" },
|
|
3347
|
+
{ value: "proxy", label: "Through a proxy command", hint: "e.g. cloudflared access ssh" },
|
|
3348
|
+
{ value: "jump-proxy", label: "Proxy command, then a jump host", hint: "cloudflared to the bastion, then on to the host" }
|
|
3349
|
+
],
|
|
3350
|
+
{ defaultValue: currentRoute }
|
|
3351
|
+
);
|
|
3352
|
+
let jumpHost;
|
|
3353
|
+
let jumpPort;
|
|
3354
|
+
let jumpUser;
|
|
3355
|
+
let proxyCommand;
|
|
3356
|
+
if (route === "jump" || route === "jump-proxy") {
|
|
3357
|
+
jumpHost = await prompter.askRequired("Jump host", existing?.jumpHost);
|
|
3358
|
+
jumpPort = await askPort(prompter, "Jump host port", existing?.jumpPort ?? DEFAULT_SSH_PORT);
|
|
3359
|
+
jumpUser = await prompter.askRequired("Jump host user", existing?.jumpUser ?? username);
|
|
3360
|
+
}
|
|
3361
|
+
if (route === "proxy" || route === "jump-proxy") {
|
|
3362
|
+
prompter.write(prompter.paint.dim(" %h / %p / %r expand to the first hop's host, port and user.\n"));
|
|
3363
|
+
proxyCommand = await prompter.askRequired("Proxy command", existing?.proxyCommand ?? "cloudflared access ssh --hostname %h");
|
|
3364
|
+
}
|
|
3365
|
+
const description = await askOptional(prompter, "Description shown to the agent", existing?.description);
|
|
3366
|
+
const readonly = await prompter.askYesNo("Read-only? (rejects writes, redirections, uploads)", existing?.readonly ?? false);
|
|
3367
|
+
let currentAllowedPaths;
|
|
3368
|
+
if (existing != null && existing.allowedPaths.length > 0) {
|
|
3369
|
+
currentAllowedPaths = existing.allowedPaths.join(",");
|
|
3370
|
+
}
|
|
3371
|
+
let allowedPaths = [];
|
|
3372
|
+
const allowedPathsAnswer = await askOptional(
|
|
3373
|
+
prompter,
|
|
3374
|
+
"Limit file tools to path prefixes (comma-separated, empty = anywhere)",
|
|
3375
|
+
currentAllowedPaths
|
|
3376
|
+
);
|
|
3377
|
+
if (allowedPathsAnswer != null) {
|
|
3378
|
+
allowedPaths = allowedPathsAnswer.split(",").map((item) => item.trim()).filter((item) => item !== "");
|
|
3379
|
+
}
|
|
3380
|
+
let resolvedAuth = auth;
|
|
3381
|
+
if (auth === "password" && password == null) {
|
|
3382
|
+
resolvedAuth = "agent";
|
|
3383
|
+
}
|
|
3384
|
+
const answers = {
|
|
3385
|
+
name,
|
|
3386
|
+
host,
|
|
3387
|
+
port,
|
|
3388
|
+
username,
|
|
3389
|
+
auth: resolvedAuth,
|
|
3390
|
+
keyPath,
|
|
3391
|
+
passphrase,
|
|
3392
|
+
password,
|
|
3393
|
+
description,
|
|
3394
|
+
jumpHost,
|
|
3395
|
+
jumpPort,
|
|
3396
|
+
jumpUser,
|
|
3397
|
+
proxyCommand,
|
|
3398
|
+
readonly,
|
|
3399
|
+
allowedPaths,
|
|
3400
|
+
extraEnv: existing?.extraEnv ?? {}
|
|
3401
|
+
};
|
|
3402
|
+
if (existing != null && existing.host === host && existing.port === port) {
|
|
3403
|
+
answers.hostKeyFingerprint = existing.hostKeyFingerprint;
|
|
3404
|
+
}
|
|
3405
|
+
if (existing != null && existing.jumpHost === jumpHost && existing.jumpPort === jumpPort) {
|
|
3406
|
+
answers.jumpHostKeyFingerprint = existing.jumpHostKeyFingerprint;
|
|
3407
|
+
}
|
|
3408
|
+
return answers;
|
|
3409
|
+
}
|
|
3410
|
+
function toMcpServerEntry(value) {
|
|
3411
|
+
if (typeof value !== "object" || value == null) {
|
|
3412
|
+
return null;
|
|
3413
|
+
}
|
|
3414
|
+
const record = value;
|
|
3415
|
+
if (typeof record.command !== "string") {
|
|
3416
|
+
return null;
|
|
3417
|
+
}
|
|
3418
|
+
let args = [];
|
|
3419
|
+
if (Array.isArray(record.args) == true) {
|
|
3420
|
+
args = record.args.filter((item) => typeof item === "string");
|
|
3421
|
+
}
|
|
3422
|
+
const env = {};
|
|
3423
|
+
if (typeof record.env === "object" && record.env != null) {
|
|
3424
|
+
for (const [key, item] of Object.entries(record.env)) {
|
|
3425
|
+
if (typeof item === "string") {
|
|
3426
|
+
env[key] = item;
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
return { command: record.command, args, env };
|
|
3431
|
+
}
|
|
3432
|
+
function readMcpServersJson(document) {
|
|
3433
|
+
if (typeof document !== "object" || document == null || Array.isArray(document) == true) {
|
|
3434
|
+
throw new Error("the config file is not a JSON object");
|
|
3435
|
+
}
|
|
3436
|
+
const entries = {};
|
|
3437
|
+
const servers = document.mcpServers;
|
|
3438
|
+
if (typeof servers !== "object" || servers == null) {
|
|
3439
|
+
return entries;
|
|
3440
|
+
}
|
|
3441
|
+
for (const [name, value] of Object.entries(servers)) {
|
|
3442
|
+
const entry = toMcpServerEntry(value);
|
|
3443
|
+
if (entry != null) {
|
|
3444
|
+
entries[name] = entry;
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
return entries;
|
|
3448
|
+
}
|
|
3449
|
+
function readDocument(filePath) {
|
|
3450
|
+
if (fs6__default.default.existsSync(filePath) == false) {
|
|
3451
|
+
return {};
|
|
3452
|
+
}
|
|
3453
|
+
const text = fs6__default.default.readFileSync(filePath, "utf8");
|
|
3454
|
+
if (text.trim() === "") {
|
|
3455
|
+
return {};
|
|
3456
|
+
}
|
|
3457
|
+
let document;
|
|
3458
|
+
try {
|
|
3459
|
+
document = JSON.parse(text);
|
|
3460
|
+
} catch (ex) {
|
|
3461
|
+
let detail = String(ex);
|
|
3462
|
+
if (ex instanceof Error) {
|
|
3463
|
+
detail = ex.message;
|
|
3464
|
+
}
|
|
3465
|
+
throw new Error(`${filePath} is not valid JSON: ${detail}`);
|
|
3466
|
+
}
|
|
3467
|
+
if (typeof document !== "object" || document == null || Array.isArray(document) == true) {
|
|
3468
|
+
throw new Error(`${filePath} is not a JSON object`);
|
|
3469
|
+
}
|
|
3470
|
+
return document;
|
|
3471
|
+
}
|
|
3472
|
+
function createJsonFileTarget(options) {
|
|
3473
|
+
const { id, label, filePath } = options;
|
|
3474
|
+
function writeDocument(document) {
|
|
3475
|
+
fs6__default.default.mkdirSync(path__default.default.dirname(filePath), { recursive: true });
|
|
3476
|
+
fs6__default.default.writeFileSync(filePath, `${JSON.stringify(document, null, 4)}
|
|
3477
|
+
`, "utf8");
|
|
3478
|
+
}
|
|
3479
|
+
return {
|
|
3480
|
+
id,
|
|
3481
|
+
label,
|
|
3482
|
+
location: filePath,
|
|
3483
|
+
detected: fs6__default.default.existsSync(filePath),
|
|
3484
|
+
readEntries() {
|
|
3485
|
+
const document = readDocument(filePath);
|
|
3486
|
+
return readMcpServersJson(document);
|
|
3487
|
+
},
|
|
3488
|
+
writeEntry(name, entry) {
|
|
3489
|
+
const document = readDocument(filePath);
|
|
3490
|
+
let servers = document.mcpServers;
|
|
3491
|
+
if (typeof servers !== "object" || servers == null || Array.isArray(servers) == true) {
|
|
3492
|
+
servers = {};
|
|
3493
|
+
}
|
|
3494
|
+
servers[name] = entry;
|
|
3495
|
+
document.mcpServers = servers;
|
|
3496
|
+
writeDocument(document);
|
|
3497
|
+
return { status: "written", detail: filePath };
|
|
3498
|
+
},
|
|
3499
|
+
removeEntry(name) {
|
|
3500
|
+
const document = readDocument(filePath);
|
|
3501
|
+
const servers = document.mcpServers;
|
|
3502
|
+
if (typeof servers !== "object" || servers == null || Array.isArray(servers) == true) {
|
|
3503
|
+
return { status: "written", detail: filePath };
|
|
3504
|
+
}
|
|
3505
|
+
delete servers[name];
|
|
3506
|
+
writeDocument(document);
|
|
3507
|
+
return { status: "written", detail: filePath };
|
|
3508
|
+
}
|
|
3509
|
+
};
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
// src/modules/manage/targets/claude-code.ts
|
|
3513
|
+
var CLAUDE_CODE_CONFIG_PATH = path__default.default.join(os5__default.default.homedir(), ".claude.json");
|
|
3514
|
+
var CMD_META_CHARACTER_PATTERN = /([()\][%!^"`<>&|;, *?])/g;
|
|
3515
|
+
function toClaudeAddJsonPayload(entry) {
|
|
3516
|
+
return JSON.stringify({ type: "stdio", command: entry.command, args: entry.args, env: entry.env });
|
|
3517
|
+
}
|
|
3518
|
+
function formatClaudeAddCommand(name, entry) {
|
|
3519
|
+
const payload = toClaudeAddJsonPayload(entry);
|
|
3520
|
+
return `claude mcp add-json ${name} '${payload}' -s user`;
|
|
3521
|
+
}
|
|
3522
|
+
function escapeCmdArgument(argument) {
|
|
3523
|
+
let escaped = argument.replace(/(\\*)"/g, '$1$1\\"');
|
|
3524
|
+
escaped = escaped.replace(/(\\*)$/, "$1$1");
|
|
3525
|
+
escaped = `"${escaped}"`;
|
|
3526
|
+
escaped = escaped.replace(CMD_META_CHARACTER_PATTERN, "^$1");
|
|
3527
|
+
escaped = escaped.replace(CMD_META_CHARACTER_PATTERN, "^$1");
|
|
3528
|
+
return escaped;
|
|
3529
|
+
}
|
|
3530
|
+
function runCliCommand(executable, args) {
|
|
3531
|
+
const isBatchShim = /\.(cmd|bat)$/i.test(executable) == true;
|
|
3532
|
+
if (process.platform !== "win32" || isBatchShim == false) {
|
|
3533
|
+
const result2 = child_process.spawnSync(executable, args, { encoding: "utf8", windowsHide: true });
|
|
3534
|
+
return result2;
|
|
3535
|
+
}
|
|
3536
|
+
const commandLine = [executable.replace(CMD_META_CHARACTER_PATTERN, "^$1"), ...args.map(escapeCmdArgument)].join(" ");
|
|
3537
|
+
const result = child_process.spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `"${commandLine}"`], {
|
|
3538
|
+
encoding: "utf8",
|
|
3539
|
+
windowsHide: true,
|
|
3540
|
+
windowsVerbatimArguments: true
|
|
3541
|
+
});
|
|
3542
|
+
return result;
|
|
3543
|
+
}
|
|
3544
|
+
function runClaude(executable, args) {
|
|
3545
|
+
const result = runCliCommand(executable, args);
|
|
3546
|
+
if (result.error != null) {
|
|
3547
|
+
return { status: "unavailable", detail: `could not run claude: ${result.error.message}` };
|
|
3548
|
+
}
|
|
3549
|
+
if (result.status !== 0) {
|
|
3550
|
+
let output = (result.stderr ?? "").trim();
|
|
3551
|
+
if (output === "") {
|
|
3552
|
+
output = (result.stdout ?? "").trim();
|
|
3553
|
+
}
|
|
3554
|
+
return { status: "unavailable", detail: `claude exited with ${result.status}: ${output}` };
|
|
3555
|
+
}
|
|
3556
|
+
return { status: "written", detail: CLAUDE_CODE_CONFIG_PATH };
|
|
3557
|
+
}
|
|
3558
|
+
function readClaudeEntries() {
|
|
3559
|
+
if (fs6__default.default.existsSync(CLAUDE_CODE_CONFIG_PATH) == false) {
|
|
3560
|
+
return {};
|
|
3561
|
+
}
|
|
3562
|
+
const text = fs6__default.default.readFileSync(CLAUDE_CODE_CONFIG_PATH, "utf8");
|
|
3563
|
+
let document;
|
|
3564
|
+
try {
|
|
3565
|
+
document = JSON.parse(text);
|
|
3566
|
+
} catch (ex) {
|
|
3567
|
+
let detail = String(ex);
|
|
3568
|
+
if (ex instanceof Error) {
|
|
3569
|
+
detail = ex.message;
|
|
3570
|
+
}
|
|
3571
|
+
throw new Error(`${CLAUDE_CODE_CONFIG_PATH} is not valid JSON: ${detail}`);
|
|
3572
|
+
}
|
|
3573
|
+
return readMcpServersJson(document);
|
|
3574
|
+
}
|
|
3575
|
+
function createClaudeCodeTarget() {
|
|
3576
|
+
const executable = findExecutable("claude");
|
|
3577
|
+
return {
|
|
3578
|
+
id: "claude-code",
|
|
3579
|
+
label: "Claude Code (user scope)",
|
|
3580
|
+
location: CLAUDE_CODE_CONFIG_PATH,
|
|
3581
|
+
detected: executable != null || fs6__default.default.existsSync(CLAUDE_CODE_CONFIG_PATH) == true,
|
|
3582
|
+
readEntries: readClaudeEntries,
|
|
3583
|
+
writeEntry(name, entry) {
|
|
3584
|
+
if (executable == null) {
|
|
3585
|
+
return {
|
|
3586
|
+
status: "unavailable",
|
|
3587
|
+
detail: `the claude CLI is not on PATH \u2014 run this yourself:
|
|
3588
|
+
${formatClaudeAddCommand(name, entry)}`
|
|
3589
|
+
};
|
|
3590
|
+
}
|
|
3591
|
+
const previous = readClaudeEntries()[name];
|
|
3592
|
+
if (previous != null) {
|
|
3593
|
+
const removed = runClaude(executable, ["mcp", "remove", name, "-s", "user"]);
|
|
3594
|
+
if (removed.status === "unavailable") {
|
|
3595
|
+
return removed;
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
3598
|
+
const added = runClaude(executable, ["mcp", "add-json", name, toClaudeAddJsonPayload(entry), "-s", "user"]);
|
|
3599
|
+
if (added.status === "unavailable" && previous != null) {
|
|
3600
|
+
runClaude(executable, ["mcp", "add-json", name, toClaudeAddJsonPayload(previous), "-s", "user"]);
|
|
3601
|
+
return { status: "unavailable", detail: `${added.detail}
|
|
3602
|
+
the previous entry was restored` };
|
|
3603
|
+
}
|
|
3604
|
+
return added;
|
|
3605
|
+
},
|
|
3606
|
+
removeEntry(name) {
|
|
3607
|
+
if (executable == null) {
|
|
3608
|
+
return {
|
|
3609
|
+
status: "unavailable",
|
|
3610
|
+
detail: `the claude CLI is not on PATH \u2014 run this yourself:
|
|
3611
|
+
claude mcp remove ${name} -s user`
|
|
3612
|
+
};
|
|
3613
|
+
}
|
|
3614
|
+
return runClaude(executable, ["mcp", "remove", name, "-s", "user"]);
|
|
3615
|
+
}
|
|
3616
|
+
};
|
|
3617
|
+
}
|
|
3618
|
+
|
|
3619
|
+
// src/modules/manage/targets/toml-subset.ts
|
|
3620
|
+
var BARE_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
3621
|
+
var ANY_HEADER_PATTERN = /^\s*\[/;
|
|
3622
|
+
var SERVER_HEADER_PATTERN = /^\s*\[\s*mcp_servers\s*\.\s*([A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*")\s*((?:\.[^\]]*)?)\]\s*(?:#.*)?$/;
|
|
3623
|
+
var KEY_VALUE_PATTERN = /^\s*([A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*")\s*=\s*(.*)$/;
|
|
3624
|
+
var OWNED_TABLE_KEYS = ["command", "args", "env"];
|
|
3625
|
+
var TomlValueParser = class {
|
|
3626
|
+
constructor(text) {
|
|
3627
|
+
this.text = text;
|
|
3628
|
+
this.position = 0;
|
|
3629
|
+
}
|
|
3630
|
+
parse() {
|
|
3631
|
+
const value = this.parseValue();
|
|
3632
|
+
return value;
|
|
3633
|
+
}
|
|
3634
|
+
skipWhitespace() {
|
|
3635
|
+
while (this.position < this.text.length && /\s/.test(this.text[this.position]) == true) {
|
|
3636
|
+
this.position += 1;
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
parseValue() {
|
|
3640
|
+
this.skipWhitespace();
|
|
3641
|
+
const current = this.text[this.position];
|
|
3642
|
+
if (current === '"') {
|
|
3643
|
+
return this.parseBasicString();
|
|
3644
|
+
}
|
|
3645
|
+
if (current === "'") {
|
|
3646
|
+
return this.parseLiteralString();
|
|
3647
|
+
}
|
|
3648
|
+
if (current === "[") {
|
|
3649
|
+
return this.parseArray();
|
|
3650
|
+
}
|
|
3651
|
+
if (current === "{") {
|
|
3652
|
+
return this.parseInlineTable();
|
|
3653
|
+
}
|
|
3654
|
+
throw new Error(`unsupported toml value at offset ${this.position}`);
|
|
3655
|
+
}
|
|
3656
|
+
parseBasicString() {
|
|
3657
|
+
this.position += 1;
|
|
3658
|
+
let result = "";
|
|
3659
|
+
while (this.position < this.text.length) {
|
|
3660
|
+
const current = this.text[this.position];
|
|
3661
|
+
if (current === '"') {
|
|
3662
|
+
this.position += 1;
|
|
3663
|
+
return result;
|
|
3664
|
+
}
|
|
3665
|
+
if (current !== "\\") {
|
|
3666
|
+
result += current;
|
|
3667
|
+
this.position += 1;
|
|
3668
|
+
continue;
|
|
3669
|
+
}
|
|
3670
|
+
const escaped = this.text[this.position + 1];
|
|
3671
|
+
this.position += 2;
|
|
3672
|
+
if (escaped === "n") {
|
|
3673
|
+
result += "\n";
|
|
3674
|
+
} else if (escaped === "t") {
|
|
3675
|
+
result += " ";
|
|
3676
|
+
} else if (escaped === "r") {
|
|
3677
|
+
result += "\r";
|
|
3678
|
+
} else if (escaped === "u" || escaped === "U") {
|
|
3679
|
+
let length = 4;
|
|
3680
|
+
if (escaped === "U") {
|
|
3681
|
+
length = 8;
|
|
3682
|
+
}
|
|
3683
|
+
const hex = this.text.slice(this.position, this.position + length);
|
|
3684
|
+
result += String.fromCodePoint(Number.parseInt(hex, 16));
|
|
3685
|
+
this.position += length;
|
|
3686
|
+
} else {
|
|
3687
|
+
result += escaped;
|
|
3688
|
+
}
|
|
3689
|
+
}
|
|
3690
|
+
throw new Error("unterminated toml string");
|
|
3691
|
+
}
|
|
3692
|
+
parseLiteralString() {
|
|
3693
|
+
const end = this.text.indexOf("'", this.position + 1);
|
|
3694
|
+
if (end < 0) {
|
|
3695
|
+
throw new Error("unterminated toml literal string");
|
|
3696
|
+
}
|
|
3697
|
+
const result = this.text.slice(this.position + 1, end);
|
|
3698
|
+
this.position = end + 1;
|
|
3699
|
+
return result;
|
|
3700
|
+
}
|
|
3701
|
+
parseArray() {
|
|
3702
|
+
this.position += 1;
|
|
3703
|
+
const items = [];
|
|
3704
|
+
for (; ; ) {
|
|
3705
|
+
this.skipWhitespace();
|
|
3706
|
+
if (this.position >= this.text.length) {
|
|
3707
|
+
throw new Error("unterminated toml array");
|
|
3708
|
+
}
|
|
3709
|
+
if (this.text[this.position] === "]") {
|
|
3710
|
+
this.position += 1;
|
|
3711
|
+
return items;
|
|
3712
|
+
}
|
|
3713
|
+
items.push(this.parseValue());
|
|
3714
|
+
this.skipWhitespace();
|
|
3715
|
+
if (this.text[this.position] === ",") {
|
|
3716
|
+
this.position += 1;
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
parseInlineTable() {
|
|
3721
|
+
this.position += 1;
|
|
3722
|
+
const table = {};
|
|
3723
|
+
for (; ; ) {
|
|
3724
|
+
this.skipWhitespace();
|
|
3725
|
+
if (this.position >= this.text.length) {
|
|
3726
|
+
throw new Error("unterminated toml inline table");
|
|
3727
|
+
}
|
|
3728
|
+
if (this.text[this.position] === "}") {
|
|
3729
|
+
this.position += 1;
|
|
3730
|
+
return table;
|
|
3731
|
+
}
|
|
3732
|
+
let key;
|
|
3733
|
+
if (this.text[this.position] === '"') {
|
|
3734
|
+
key = this.parseBasicString();
|
|
3735
|
+
} else {
|
|
3736
|
+
const match = /^[A-Za-z0-9_-]+/.exec(this.text.slice(this.position));
|
|
3737
|
+
if (match == null) {
|
|
3738
|
+
throw new Error(`invalid inline table key at offset ${this.position}`);
|
|
3739
|
+
}
|
|
3740
|
+
key = match[0];
|
|
3741
|
+
this.position += key.length;
|
|
3742
|
+
}
|
|
3743
|
+
this.skipWhitespace();
|
|
3744
|
+
if (this.text[this.position] !== "=") {
|
|
3745
|
+
throw new Error(`expected '=' at offset ${this.position}`);
|
|
3746
|
+
}
|
|
3747
|
+
this.position += 1;
|
|
3748
|
+
table[key] = this.parseValue();
|
|
3749
|
+
this.skipWhitespace();
|
|
3750
|
+
if (this.text[this.position] === ",") {
|
|
3751
|
+
this.position += 1;
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
}
|
|
3755
|
+
};
|
|
3756
|
+
function unquoteKey(rawKey) {
|
|
3757
|
+
if (rawKey.startsWith('"') == false) {
|
|
3758
|
+
return rawKey;
|
|
3759
|
+
}
|
|
3760
|
+
const parsed = new TomlValueParser(rawKey).parse();
|
|
3761
|
+
return parsed;
|
|
3762
|
+
}
|
|
3763
|
+
function bracketBalance(text) {
|
|
3764
|
+
let balance = 0;
|
|
3765
|
+
let quote = null;
|
|
3766
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
3767
|
+
const current = text[index];
|
|
3768
|
+
if (quote != null) {
|
|
3769
|
+
if (current === "\\" && quote === '"') {
|
|
3770
|
+
index += 1;
|
|
3771
|
+
} else if (current === quote) {
|
|
3772
|
+
quote = null;
|
|
3773
|
+
}
|
|
3774
|
+
continue;
|
|
3775
|
+
}
|
|
3776
|
+
if (current === '"' || current === "'") {
|
|
3777
|
+
quote = current;
|
|
3778
|
+
} else if (current === "#") {
|
|
3779
|
+
break;
|
|
3780
|
+
} else if (current === "[" || current === "{") {
|
|
3781
|
+
balance += 1;
|
|
3782
|
+
} else if (current === "]" || current === "}") {
|
|
3783
|
+
balance -= 1;
|
|
3784
|
+
}
|
|
3785
|
+
}
|
|
3786
|
+
return balance;
|
|
3787
|
+
}
|
|
3788
|
+
function stripComment(text) {
|
|
3789
|
+
let quote = null;
|
|
3790
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
3791
|
+
const current = text[index];
|
|
3792
|
+
if (quote != null) {
|
|
3793
|
+
if (current === "\\" && quote === '"') {
|
|
3794
|
+
index += 1;
|
|
3795
|
+
} else if (current === quote) {
|
|
3796
|
+
quote = null;
|
|
3797
|
+
}
|
|
3798
|
+
continue;
|
|
3799
|
+
}
|
|
3800
|
+
if (current === '"' || current === "'") {
|
|
3801
|
+
quote = current;
|
|
3802
|
+
} else if (current === "#") {
|
|
3803
|
+
return text.slice(0, index);
|
|
3804
|
+
}
|
|
3805
|
+
}
|
|
3806
|
+
return text;
|
|
3807
|
+
}
|
|
3808
|
+
function readStatements(body) {
|
|
3809
|
+
const statements = [];
|
|
3810
|
+
let index = 0;
|
|
3811
|
+
while (index < body.length) {
|
|
3812
|
+
const match = KEY_VALUE_PATTERN.exec(body[index]);
|
|
3813
|
+
if (match == null) {
|
|
3814
|
+
index += 1;
|
|
3815
|
+
continue;
|
|
3816
|
+
}
|
|
3817
|
+
const start = index;
|
|
3818
|
+
let rawValue = stripComment(match[2]);
|
|
3819
|
+
let balance = bracketBalance(rawValue);
|
|
3820
|
+
index += 1;
|
|
3821
|
+
while (balance > 0 && index < body.length) {
|
|
3822
|
+
const continuation = stripComment(body[index]);
|
|
3823
|
+
rawValue += `
|
|
3824
|
+
${continuation}`;
|
|
3825
|
+
balance += bracketBalance(continuation);
|
|
3826
|
+
index += 1;
|
|
3827
|
+
}
|
|
3828
|
+
statements.push({ key: unquoteKey(match[1]), rawValue, start, end: index });
|
|
3829
|
+
}
|
|
3830
|
+
return statements;
|
|
3831
|
+
}
|
|
3832
|
+
function splitSections(lines) {
|
|
3833
|
+
const sections = [{ header: null, serverName: null, subPath: "", body: [] }];
|
|
3834
|
+
let balance = 0;
|
|
3835
|
+
for (const line of lines) {
|
|
3836
|
+
const current = sections[sections.length - 1];
|
|
3837
|
+
if (balance > 0) {
|
|
3838
|
+
current.body.push(line);
|
|
3839
|
+
balance += bracketBalance(stripComment(line));
|
|
3840
|
+
continue;
|
|
3841
|
+
}
|
|
3842
|
+
if (ANY_HEADER_PATTERN.test(line) == true) {
|
|
3843
|
+
const serverMatch = SERVER_HEADER_PATTERN.exec(line);
|
|
3844
|
+
let serverName = null;
|
|
3845
|
+
let subPath = "";
|
|
3846
|
+
if (serverMatch != null) {
|
|
3847
|
+
serverName = unquoteKey(serverMatch[1]);
|
|
3848
|
+
subPath = serverMatch[2].replace(/^\./, "").replace(/\s+/g, "");
|
|
3849
|
+
}
|
|
3850
|
+
sections.push({ header: line, serverName, subPath, body: [] });
|
|
3851
|
+
continue;
|
|
3852
|
+
}
|
|
3853
|
+
current.body.push(line);
|
|
3854
|
+
const keyValueMatch = KEY_VALUE_PATTERN.exec(line);
|
|
3855
|
+
if (keyValueMatch != null) {
|
|
3856
|
+
balance = bracketBalance(stripComment(keyValueMatch[2]));
|
|
3857
|
+
}
|
|
3858
|
+
}
|
|
3859
|
+
return sections;
|
|
3860
|
+
}
|
|
3861
|
+
function parseStatementValue(statement) {
|
|
3862
|
+
try {
|
|
3863
|
+
const value = new TomlValueParser(statement.rawValue).parse();
|
|
3864
|
+
return value;
|
|
3865
|
+
} catch {
|
|
3866
|
+
return null;
|
|
3867
|
+
}
|
|
3868
|
+
}
|
|
3869
|
+
function parseMcpServersToml(text) {
|
|
3870
|
+
const sections = splitSections(text.split(/\r?\n/));
|
|
3871
|
+
const entries = {};
|
|
3872
|
+
const envTables = {};
|
|
3873
|
+
for (const section of sections) {
|
|
3874
|
+
if (section.serverName == null) {
|
|
3875
|
+
continue;
|
|
3876
|
+
}
|
|
3877
|
+
if (section.subPath === "env") {
|
|
3878
|
+
const envTable = {};
|
|
3879
|
+
for (const statement of readStatements(section.body)) {
|
|
3880
|
+
const value = parseStatementValue(statement);
|
|
3881
|
+
if (typeof value === "string") {
|
|
3882
|
+
envTable[statement.key] = value;
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
envTables[section.serverName] = envTable;
|
|
3886
|
+
continue;
|
|
3887
|
+
}
|
|
3888
|
+
if (section.subPath !== "") {
|
|
3889
|
+
continue;
|
|
3890
|
+
}
|
|
3891
|
+
let command = null;
|
|
3892
|
+
let args = [];
|
|
3893
|
+
const env = {};
|
|
3894
|
+
for (const statement of readStatements(section.body)) {
|
|
3895
|
+
const value = parseStatementValue(statement);
|
|
3896
|
+
if (statement.key === "command" && typeof value === "string") {
|
|
3897
|
+
command = value;
|
|
3898
|
+
} else if (statement.key === "args" && Array.isArray(value) == true) {
|
|
3899
|
+
args = value.filter((item) => typeof item === "string");
|
|
3900
|
+
} else if (statement.key === "env" && value != null && typeof value === "object" && Array.isArray(value) == false) {
|
|
3901
|
+
for (const [key, item] of Object.entries(value)) {
|
|
3902
|
+
if (typeof item === "string") {
|
|
3903
|
+
env[key] = item;
|
|
3904
|
+
}
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
if (command != null) {
|
|
3909
|
+
entries[section.serverName] = { command, args, env };
|
|
3910
|
+
}
|
|
3911
|
+
}
|
|
3912
|
+
for (const [name, envTable] of Object.entries(envTables)) {
|
|
3913
|
+
if (entries[name] != null) {
|
|
3914
|
+
Object.assign(entries[name].env, envTable);
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
return entries;
|
|
3918
|
+
}
|
|
3919
|
+
function quoteTomlString(value) {
|
|
3920
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
3921
|
+
return `"${escaped}"`;
|
|
3922
|
+
}
|
|
3923
|
+
function formatTomlKey(key) {
|
|
3924
|
+
if (BARE_KEY_PATTERN.test(key) == true) {
|
|
3925
|
+
return key;
|
|
3926
|
+
}
|
|
3927
|
+
return quoteTomlString(key);
|
|
3928
|
+
}
|
|
3929
|
+
function renderOwnedLines(entry) {
|
|
3930
|
+
return [
|
|
3931
|
+
`command = ${quoteTomlString(entry.command)}`,
|
|
3932
|
+
`args = [${entry.args.map((argument) => quoteTomlString(argument)).join(", ")}]`
|
|
3933
|
+
];
|
|
3934
|
+
}
|
|
3935
|
+
function renderEnvLines(entry) {
|
|
3936
|
+
return Object.entries(entry.env).map(([key, value]) => `${formatTomlKey(key)} = ${quoteTomlString(value)}`);
|
|
3937
|
+
}
|
|
3938
|
+
function renderMcpServerTable(name, entry) {
|
|
3939
|
+
const tableKey = formatTomlKey(name);
|
|
3940
|
+
const lines = [`[mcp_servers.${tableKey}]`, ...renderOwnedLines(entry)];
|
|
3941
|
+
const envLines = renderEnvLines(entry);
|
|
3942
|
+
if (envLines.length > 0) {
|
|
3943
|
+
lines.push("");
|
|
3944
|
+
lines.push(`[mcp_servers.${tableKey}.env]`);
|
|
3945
|
+
lines.push(...envLines);
|
|
3946
|
+
}
|
|
3947
|
+
return `${lines.join("\n")}
|
|
3948
|
+
`;
|
|
3949
|
+
}
|
|
3950
|
+
function isBlank(line) {
|
|
3951
|
+
return line.trim() === "";
|
|
3952
|
+
}
|
|
3953
|
+
function joinSections(sections, eol) {
|
|
3954
|
+
const lines = [];
|
|
3955
|
+
for (const section of sections) {
|
|
3956
|
+
if (section.header != null) {
|
|
3957
|
+
lines.push(section.header);
|
|
3958
|
+
}
|
|
3959
|
+
lines.push(...section.body);
|
|
3960
|
+
}
|
|
3961
|
+
return lines.join(eol);
|
|
3962
|
+
}
|
|
3963
|
+
function writeMcpServerToml(text, name, entry) {
|
|
3964
|
+
let eol = "\n";
|
|
3965
|
+
if (text.includes("\r\n") == true) {
|
|
3966
|
+
eol = "\r\n";
|
|
3967
|
+
}
|
|
3968
|
+
const sections = splitSections(text.split(/\r?\n/));
|
|
3969
|
+
const isOwn = (section) => section.serverName === name;
|
|
3970
|
+
if (entry == null) {
|
|
3971
|
+
const kept = sections.filter((section) => isOwn(section) == false);
|
|
3972
|
+
const removedText = joinSections(kept, eol);
|
|
3973
|
+
return removedText;
|
|
3974
|
+
}
|
|
3975
|
+
const tableKey = formatTomlKey(name);
|
|
3976
|
+
const envLines = renderEnvLines(entry);
|
|
3977
|
+
const tableIndex = sections.findIndex((section) => isOwn(section) == true && section.subPath === "");
|
|
3978
|
+
if (tableIndex < 0) {
|
|
3979
|
+
const withoutStrayEnv = sections.filter((section) => (isOwn(section) == true && section.subPath === "env") == false);
|
|
3980
|
+
let prefix = joinSections(withoutStrayEnv, eol);
|
|
3981
|
+
if (prefix.trim() === "") {
|
|
3982
|
+
prefix = "";
|
|
3983
|
+
} else {
|
|
3984
|
+
prefix = prefix.replace(/(\r?\n)*$/, "");
|
|
3985
|
+
prefix = `${prefix}${eol}${eol}`;
|
|
3986
|
+
}
|
|
3987
|
+
const block = renderMcpServerTable(name, entry).replace(/\n/g, eol);
|
|
3988
|
+
return `${prefix}${block}`;
|
|
3989
|
+
}
|
|
3990
|
+
const table = sections[tableIndex];
|
|
3991
|
+
const ownedLineIndexes = /* @__PURE__ */ new Set();
|
|
3992
|
+
for (const statement of readStatements(table.body)) {
|
|
3993
|
+
if (OWNED_TABLE_KEYS.includes(statement.key) == true) {
|
|
3994
|
+
for (let index = statement.start; index < statement.end; index += 1) {
|
|
3995
|
+
ownedLineIndexes.add(index);
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
const userLines = table.body.filter((_line, index) => ownedLineIndexes.has(index) == false);
|
|
4000
|
+
let trailingBlankCount = 0;
|
|
4001
|
+
while (trailingBlankCount < userLines.length && isBlank(userLines[userLines.length - 1 - trailingBlankCount]) == true) {
|
|
4002
|
+
trailingBlankCount += 1;
|
|
4003
|
+
}
|
|
4004
|
+
const trailingBlanks = userLines.slice(userLines.length - trailingBlankCount);
|
|
4005
|
+
const userContent = userLines.slice(0, userLines.length - trailingBlankCount);
|
|
4006
|
+
const rebuilt = [];
|
|
4007
|
+
for (let index = 0; index < sections.length; index += 1) {
|
|
4008
|
+
const section = sections[index];
|
|
4009
|
+
if (isOwn(section) == true && section.subPath === "env") {
|
|
4010
|
+
continue;
|
|
4011
|
+
}
|
|
4012
|
+
if (index !== tableIndex) {
|
|
4013
|
+
rebuilt.push(section);
|
|
4014
|
+
continue;
|
|
4015
|
+
}
|
|
4016
|
+
if (envLines.length === 0) {
|
|
4017
|
+
rebuilt.push({ ...section, body: [...renderOwnedLines(entry), ...userContent, ...trailingBlanks] });
|
|
4018
|
+
continue;
|
|
4019
|
+
}
|
|
4020
|
+
rebuilt.push({ ...section, body: [...renderOwnedLines(entry), ...userContent, ""] });
|
|
4021
|
+
rebuilt.push({
|
|
4022
|
+
header: `[mcp_servers.${tableKey}.env]`,
|
|
4023
|
+
serverName: name,
|
|
4024
|
+
subPath: "env",
|
|
4025
|
+
body: [...envLines, ...trailingBlanks]
|
|
4026
|
+
});
|
|
4027
|
+
}
|
|
4028
|
+
const rebuiltText = joinSections(rebuilt, eol);
|
|
4029
|
+
return rebuiltText;
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
// src/modules/manage/targets/codex.ts
|
|
4033
|
+
var CODEX_CONFIG_PATH = path__default.default.join(os5__default.default.homedir(), ".codex", "config.toml");
|
|
4034
|
+
function createCodexTarget() {
|
|
4035
|
+
function readText() {
|
|
4036
|
+
if (fs6__default.default.existsSync(CODEX_CONFIG_PATH) == false) {
|
|
4037
|
+
return "";
|
|
4038
|
+
}
|
|
4039
|
+
return fs6__default.default.readFileSync(CODEX_CONFIG_PATH, "utf8");
|
|
4040
|
+
}
|
|
4041
|
+
function writeText(text) {
|
|
4042
|
+
fs6__default.default.mkdirSync(path__default.default.dirname(CODEX_CONFIG_PATH), { recursive: true });
|
|
4043
|
+
fs6__default.default.writeFileSync(CODEX_CONFIG_PATH, text, "utf8");
|
|
4044
|
+
return { status: "written", detail: CODEX_CONFIG_PATH };
|
|
4045
|
+
}
|
|
4046
|
+
return {
|
|
4047
|
+
id: "codex",
|
|
4048
|
+
label: "Codex CLI",
|
|
4049
|
+
location: CODEX_CONFIG_PATH,
|
|
4050
|
+
detected: findExecutable("codex") != null || fs6__default.default.existsSync(CODEX_CONFIG_PATH) == true,
|
|
4051
|
+
readEntries() {
|
|
4052
|
+
const text = readText();
|
|
4053
|
+
return parseMcpServersToml(text);
|
|
4054
|
+
},
|
|
4055
|
+
writeEntry(name, entry) {
|
|
4056
|
+
const text = readText();
|
|
4057
|
+
const updated = writeMcpServerToml(text, name, entry);
|
|
4058
|
+
return writeText(updated);
|
|
4059
|
+
},
|
|
4060
|
+
removeEntry(name) {
|
|
4061
|
+
const text = readText();
|
|
4062
|
+
const updated = writeMcpServerToml(text, name, null);
|
|
4063
|
+
return writeText(updated);
|
|
4064
|
+
}
|
|
4065
|
+
};
|
|
1991
4066
|
}
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
4067
|
+
|
|
4068
|
+
// src/modules/manage/targets/index.ts
|
|
4069
|
+
function claudeDesktopConfigPath() {
|
|
4070
|
+
if (process.platform === "win32") {
|
|
4071
|
+
const appData = process.env.APPDATA ?? path__default.default.join(os5__default.default.homedir(), "AppData", "Roaming");
|
|
4072
|
+
return path__default.default.join(appData, "Claude", "claude_desktop_config.json");
|
|
1996
4073
|
}
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
throw new Error(`${name} must be a positive integer (got "${value}")`);
|
|
4074
|
+
if (process.platform === "darwin") {
|
|
4075
|
+
return path__default.default.join(os5__default.default.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
2000
4076
|
}
|
|
2001
|
-
return
|
|
4077
|
+
return path__default.default.join(os5__default.default.homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
2002
4078
|
}
|
|
2003
|
-
function
|
|
2004
|
-
const
|
|
2005
|
-
|
|
2006
|
-
|
|
4079
|
+
function listConfigTargets() {
|
|
4080
|
+
const targets = [
|
|
4081
|
+
createClaudeCodeTarget(),
|
|
4082
|
+
createCodexTarget(),
|
|
4083
|
+
createJsonFileTarget({ id: "claude-desktop", label: "Claude Desktop", filePath: claudeDesktopConfigPath() }),
|
|
4084
|
+
createJsonFileTarget({ id: "cursor", label: "Cursor", filePath: path__default.default.join(os5__default.default.homedir(), ".cursor", "mcp.json") })
|
|
4085
|
+
];
|
|
4086
|
+
const detected = targets.filter((target) => target.detected == true);
|
|
4087
|
+
const others = targets.filter((target) => target.detected == false);
|
|
4088
|
+
return [...detected, ...others];
|
|
4089
|
+
}
|
|
4090
|
+
|
|
4091
|
+
// src/modules/manage/index.ts
|
|
4092
|
+
var SECRET_ENV_NAMES = [
|
|
4093
|
+
SINGLE_HOST_ENV.PASSWORD,
|
|
4094
|
+
SINGLE_HOST_ENV.PASSPHRASE,
|
|
4095
|
+
SINGLE_HOST_ENV.JUMP_PASSWORD,
|
|
4096
|
+
SINGLE_HOST_ENV.JUMP_PASSPHRASE
|
|
4097
|
+
];
|
|
4098
|
+
var HOST_VALUE_PREFIX = "host:";
|
|
4099
|
+
function errorMessage(error) {
|
|
4100
|
+
if (error instanceof Error) {
|
|
4101
|
+
return error.message;
|
|
2007
4102
|
}
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
4103
|
+
return String(error);
|
|
4104
|
+
}
|
|
4105
|
+
function maskSecrets(entry) {
|
|
4106
|
+
const env = {};
|
|
4107
|
+
for (const [name, value] of Object.entries(entry.env)) {
|
|
4108
|
+
if (SECRET_ENV_NAMES.includes(name) == true) {
|
|
4109
|
+
env[name] = "********";
|
|
4110
|
+
continue;
|
|
4111
|
+
}
|
|
4112
|
+
env[name] = value;
|
|
2011
4113
|
}
|
|
2012
|
-
return
|
|
4114
|
+
return { ...entry, env };
|
|
2013
4115
|
}
|
|
2014
|
-
function
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
4116
|
+
function formatForTarget(target, name, entry) {
|
|
4117
|
+
if (target.id === "codex") {
|
|
4118
|
+
return renderMcpServerTable(name, entry);
|
|
4119
|
+
}
|
|
4120
|
+
return `"${name}": ${JSON.stringify(entry, null, 4)}
|
|
4121
|
+
`;
|
|
4122
|
+
}
|
|
4123
|
+
function indent(text) {
|
|
4124
|
+
return text.replace(/\n$/, "").split("\n").map((line) => ` ${line}`).join("\n");
|
|
4125
|
+
}
|
|
4126
|
+
function reportWrite(prompter, result, action) {
|
|
4127
|
+
if (result.status === "written") {
|
|
4128
|
+
prompter.write(` ${prompter.paint.success(action)} ${prompter.paint.dim(result.detail)}
|
|
4129
|
+
`);
|
|
4130
|
+
prompter.write(prompter.paint.dim(" Restart the client (or reconnect it from its MCP menu) to pick up the change.\n\n"));
|
|
4131
|
+
return;
|
|
4132
|
+
}
|
|
4133
|
+
prompter.write(` Not ${action.toLowerCase()}: ${result.detail}
|
|
4134
|
+
|
|
4135
|
+
`);
|
|
4136
|
+
}
|
|
4137
|
+
function profileFromEntry(output, entry) {
|
|
4138
|
+
try {
|
|
4139
|
+
const profile = loadHostProfile(entry.env);
|
|
4140
|
+
if (profile == null) {
|
|
4141
|
+
output.write(" The entry has no host.\n");
|
|
4142
|
+
}
|
|
4143
|
+
return profile;
|
|
4144
|
+
} catch (ex) {
|
|
4145
|
+
output.write(` The server would refuse to start with this entry: ${errorMessage(ex)}
|
|
4146
|
+
`);
|
|
4147
|
+
return null;
|
|
4148
|
+
}
|
|
4149
|
+
}
|
|
4150
|
+
async function verifyAnswers(prompter, output, initial) {
|
|
4151
|
+
let answers = initial;
|
|
4152
|
+
for (; ; ) {
|
|
4153
|
+
const testNow = await prompter.askYesNo("Test the connection now?", true);
|
|
4154
|
+
if (testNow == false) {
|
|
4155
|
+
return answers;
|
|
4156
|
+
}
|
|
4157
|
+
output.write("\n");
|
|
4158
|
+
const profile = profileFromEntry(output, answersToEntry(answers));
|
|
4159
|
+
if (profile != null) {
|
|
4160
|
+
const result = await runConnectionCheck(profile, output);
|
|
4161
|
+
output.write("\n");
|
|
4162
|
+
if (result.connected == true) {
|
|
4163
|
+
if (result.hostKeyFingerprint != null && answers.hostKeyFingerprint == null) {
|
|
4164
|
+
const pin = await prompter.askYesNo("Pin this host key? (a changed key then aborts the connection)", true);
|
|
4165
|
+
if (pin == true) {
|
|
4166
|
+
answers = { ...answers, hostKeyFingerprint: result.hostKeyFingerprint };
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
4169
|
+
if (result.jumpHostKeyFingerprint != null && answers.jumpHost != null && answers.jumpHostKeyFingerprint == null) {
|
|
4170
|
+
const pin = await prompter.askYesNo("Pin the jump host key too?", true);
|
|
4171
|
+
if (pin == true) {
|
|
4172
|
+
answers = { ...answers, jumpHostKeyFingerprint: result.jumpHostKeyFingerprint };
|
|
4173
|
+
}
|
|
4174
|
+
}
|
|
4175
|
+
return answers;
|
|
4176
|
+
}
|
|
4177
|
+
const hostKeyChanged = result.hostKeyFingerprint != null && answers.hostKeyFingerprint != null && result.hostKeyFingerprint !== answers.hostKeyFingerprint;
|
|
4178
|
+
if (hostKeyChanged == true) {
|
|
4179
|
+
output.write(" The host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
|
|
4180
|
+
const repin = await prompter.askYesNo(`Pin ${result.hostKeyFingerprint} instead?`, false);
|
|
4181
|
+
if (repin == true) {
|
|
4182
|
+
answers = { ...answers, hostKeyFingerprint: result.hostKeyFingerprint ?? void 0 };
|
|
4183
|
+
continue;
|
|
4184
|
+
}
|
|
4185
|
+
}
|
|
4186
|
+
const jumpKeyChanged = result.jumpHostKeyFingerprint != null && answers.jumpHostKeyFingerprint != null && result.jumpHostKeyFingerprint !== answers.jumpHostKeyFingerprint;
|
|
4187
|
+
if (jumpKeyChanged == true) {
|
|
4188
|
+
output.write(" The jump host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
|
|
4189
|
+
const repin = await prompter.askYesNo(`Pin ${result.jumpHostKeyFingerprint} instead?`, false);
|
|
4190
|
+
if (repin == true) {
|
|
4191
|
+
answers = { ...answers, jumpHostKeyFingerprint: result.jumpHostKeyFingerprint ?? void 0 };
|
|
4192
|
+
continue;
|
|
4193
|
+
}
|
|
2024
4194
|
}
|
|
2025
|
-
throw new Error(`${SINGLE_HOST_ENV.DENY_PATTERNS} has an invalid entry /${source}/: ${detail}`);
|
|
2026
4195
|
}
|
|
2027
|
-
|
|
4196
|
+
const next = await prompter.select(
|
|
4197
|
+
"The check failed. What now?",
|
|
4198
|
+
[
|
|
4199
|
+
{ value: "edit", key: "e", label: "Change the answers" },
|
|
4200
|
+
{ value: "retry", key: "r", label: "Try again", hint: "e.g. after starting a tunnel" },
|
|
4201
|
+
{ value: "write", key: "w", label: "Write it anyway", hint: "the host may be unreachable from here" },
|
|
4202
|
+
{ value: "cancel", key: "c", label: "Cancel", hint: "nothing is written" }
|
|
4203
|
+
],
|
|
4204
|
+
{ defaultValue: "edit", cancelValue: "cancel" }
|
|
4205
|
+
);
|
|
4206
|
+
if (next === "cancel") {
|
|
4207
|
+
return null;
|
|
4208
|
+
}
|
|
4209
|
+
if (next === "write") {
|
|
4210
|
+
return answers;
|
|
4211
|
+
}
|
|
4212
|
+
if (next === "edit") {
|
|
4213
|
+
output.write("\n");
|
|
4214
|
+
answers = await askHostQuestions(prompter, answers);
|
|
4215
|
+
}
|
|
4216
|
+
}
|
|
2028
4217
|
}
|
|
2029
|
-
function
|
|
2030
|
-
|
|
2031
|
-
if (
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
4218
|
+
async function addOrEditHost(prompter, output, target, entries, existingName) {
|
|
4219
|
+
let existing;
|
|
4220
|
+
if (existingName != null) {
|
|
4221
|
+
existing = entryToAnswers(existingName, entries[existingName]);
|
|
4222
|
+
output.write(`
|
|
4223
|
+
${prompter.paint.bold(`Editing ${existingName}`)}
|
|
4224
|
+
`);
|
|
4225
|
+
} else {
|
|
4226
|
+
output.write(`
|
|
4227
|
+
${prompter.paint.bold("Adding a host")}
|
|
4228
|
+
`);
|
|
2037
4229
|
}
|
|
2038
|
-
const
|
|
2039
|
-
|
|
2040
|
-
|
|
4230
|
+
const asked = await askHostQuestions(prompter, existing);
|
|
4231
|
+
output.write("\n");
|
|
4232
|
+
const answers = await verifyAnswers(prompter, output, asked);
|
|
4233
|
+
if (answers == null) {
|
|
4234
|
+
output.write(" Cancelled \u2014 nothing was written.\n\n");
|
|
4235
|
+
return;
|
|
2041
4236
|
}
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
4237
|
+
if (answers.name !== existingName && entries[answers.name] != null) {
|
|
4238
|
+
const replace = await prompter.askYesNo(`"${answers.name}" already exists in this file. Replace it?`, false);
|
|
4239
|
+
if (replace == false) {
|
|
4240
|
+
output.write(" Cancelled \u2014 nothing was written.\n\n");
|
|
4241
|
+
return;
|
|
4242
|
+
}
|
|
2047
4243
|
}
|
|
2048
|
-
const
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
policy
|
|
2070
|
-
};
|
|
2071
|
-
const keyPath = readEnv(SINGLE_HOST_ENV.KEY);
|
|
2072
|
-
if (keyPath != null) {
|
|
2073
|
-
const expandedKeyPath = expandHomePath(keyPath);
|
|
2074
|
-
profile.privateKeyPath = expandedKeyPath;
|
|
2075
|
-
if (fs__default.default.existsSync(expandedKeyPath) == false) {
|
|
2076
|
-
_logger.warn(`${SINGLE_HOST_ENV.KEY} does not exist; connections will fail`, {
|
|
2077
|
-
private_key_path: expandedKeyPath
|
|
2078
|
-
});
|
|
4244
|
+
const entry = answersToEntry(answers);
|
|
4245
|
+
output.write(`
|
|
4246
|
+
${prompter.paint.dim(`This goes into ${target.location}:`)}
|
|
4247
|
+
|
|
4248
|
+
`);
|
|
4249
|
+
output.write(`${indent(formatForTarget(target, answers.name, maskSecrets(entry)))}
|
|
4250
|
+
|
|
4251
|
+
`);
|
|
4252
|
+
const confirmed = await prompter.askYesNo("Write it?", true);
|
|
4253
|
+
if (confirmed == false) {
|
|
4254
|
+
output.write(" Not written. Pick the host and choose Print to get the entry ready to paste.\n\n");
|
|
4255
|
+
return;
|
|
4256
|
+
}
|
|
4257
|
+
const written = target.writeEntry(answers.name, entry);
|
|
4258
|
+
reportWrite(prompter, written, "Written");
|
|
4259
|
+
if (written.status === "written" && existingName != null && existingName !== answers.name) {
|
|
4260
|
+
const removed = target.removeEntry(existingName);
|
|
4261
|
+
if (removed.status === "unavailable") {
|
|
4262
|
+
output.write(` The old name ${existingName} is still there: ${removed.detail}
|
|
4263
|
+
|
|
4264
|
+
`);
|
|
2079
4265
|
}
|
|
2080
4266
|
}
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
4267
|
+
}
|
|
4268
|
+
async function checkHost(prompter, output, target, name, entry) {
|
|
4269
|
+
output.write("\n");
|
|
4270
|
+
const profile = profileFromEntry(output, entry);
|
|
4271
|
+
if (profile == null) {
|
|
4272
|
+
output.write("\n");
|
|
4273
|
+
return;
|
|
4274
|
+
}
|
|
4275
|
+
const result = await runConnectionCheck(profile, output);
|
|
4276
|
+
output.write("\n");
|
|
4277
|
+
if (result.connected == false) {
|
|
4278
|
+
return;
|
|
4279
|
+
}
|
|
4280
|
+
const env = { ...entry.env };
|
|
4281
|
+
let changed = false;
|
|
4282
|
+
if (result.hostKeyFingerprint != null && env[SINGLE_HOST_ENV.HOST_KEY] == null) {
|
|
4283
|
+
const pin = await prompter.askYesNo("Pin this host key?", true);
|
|
4284
|
+
if (pin == true) {
|
|
4285
|
+
env[SINGLE_HOST_ENV.HOST_KEY] = result.hostKeyFingerprint;
|
|
4286
|
+
changed = true;
|
|
4287
|
+
}
|
|
4288
|
+
}
|
|
4289
|
+
if (result.jumpHostKeyFingerprint != null && env[SINGLE_HOST_ENV.JUMP_HOST_KEY] == null) {
|
|
4290
|
+
const pin = await prompter.askYesNo("Pin the jump host key too?", true);
|
|
4291
|
+
if (pin == true) {
|
|
4292
|
+
env[SINGLE_HOST_ENV.JUMP_HOST_KEY] = result.jumpHostKeyFingerprint;
|
|
4293
|
+
changed = true;
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
if (changed == true) {
|
|
4297
|
+
const written = target.writeEntry(name, { ...entry, env });
|
|
4298
|
+
reportWrite(prompter, written, "Pinned");
|
|
4299
|
+
}
|
|
4300
|
+
}
|
|
4301
|
+
function printHost(prompter, name, entry) {
|
|
4302
|
+
prompter.write(`
|
|
4303
|
+
${prompter.paint.dim('JSON \u2014 Claude Desktop, Cursor, .mcp.json (inside "mcpServers"):')}
|
|
4304
|
+
|
|
4305
|
+
`);
|
|
4306
|
+
prompter.write(`${indent(`"${name}": ${JSON.stringify(entry, null, 4)}`)}
|
|
4307
|
+
|
|
4308
|
+
`);
|
|
4309
|
+
prompter.write(` ${prompter.paint.dim("TOML \u2014 Codex (~/.codex/config.toml):")}
|
|
4310
|
+
|
|
4311
|
+
`);
|
|
4312
|
+
prompter.write(`${indent(renderMcpServerTable(name, entry))}
|
|
4313
|
+
|
|
4314
|
+
`);
|
|
4315
|
+
prompter.write(` ${prompter.paint.dim("Claude Code CLI (bash / zsh / Git Bash):")}
|
|
4316
|
+
|
|
4317
|
+
`);
|
|
4318
|
+
prompter.write(`${indent(formatClaudeAddCommand(name, entry))}
|
|
4319
|
+
|
|
4320
|
+
`);
|
|
4321
|
+
}
|
|
4322
|
+
function buildHostItems(names, entries) {
|
|
4323
|
+
const nameWidth = Math.max(...names.map((name) => displayWidth(name)));
|
|
4324
|
+
const summaries = names.map((name) => summarizeEntry(entries[name]));
|
|
4325
|
+
const endpointWidth = Math.max(...summaries.map((summary) => displayWidth(summary.endpoint)));
|
|
4326
|
+
return names.map((name, index) => {
|
|
4327
|
+
const summary = summaries[index];
|
|
4328
|
+
const hintParts = [summary.auth];
|
|
4329
|
+
if (summary.notes !== "") {
|
|
4330
|
+
hintParts.push(summary.notes);
|
|
4331
|
+
}
|
|
4332
|
+
return {
|
|
4333
|
+
value: `${HOST_VALUE_PREFIX}${name}`,
|
|
4334
|
+
key: name,
|
|
4335
|
+
label: `${name.padEnd(nameWidth)} ${summary.endpoint.padEnd(endpointWidth)}`,
|
|
4336
|
+
hint: hintParts.join(" \xB7 ")
|
|
4337
|
+
};
|
|
2086
4338
|
});
|
|
2087
|
-
return profile;
|
|
2088
4339
|
}
|
|
2089
|
-
function
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
4340
|
+
async function manageHost(prompter, output, target, entries, name) {
|
|
4341
|
+
const action = await prompter.select(
|
|
4342
|
+
name,
|
|
4343
|
+
[
|
|
4344
|
+
{ value: "edit", key: "e", label: "Edit", hint: "change the answers, re-check, re-write" },
|
|
4345
|
+
{ value: "check", key: "c", label: "Check the connection", hint: "connect now and offer to pin the host key" },
|
|
4346
|
+
{ value: "print", key: "p", label: "Print the entry", hint: "JSON, Codex TOML and a claude command, ready to paste" },
|
|
4347
|
+
{ value: "remove", key: "r", label: "Remove", hint: `from ${target.label}` },
|
|
4348
|
+
{ separator: true },
|
|
4349
|
+
{ value: "back", key: "b", label: "Back" }
|
|
4350
|
+
],
|
|
4351
|
+
{ defaultValue: "edit", cancelValue: "back" }
|
|
4352
|
+
);
|
|
4353
|
+
if (action === "edit") {
|
|
4354
|
+
await addOrEditHost(prompter, output, target, entries, name);
|
|
4355
|
+
return;
|
|
4356
|
+
}
|
|
4357
|
+
if (action === "check") {
|
|
4358
|
+
await checkHost(prompter, output, target, name, entries[name]);
|
|
4359
|
+
return;
|
|
4360
|
+
}
|
|
4361
|
+
if (action === "print") {
|
|
4362
|
+
printHost(prompter, name, entries[name]);
|
|
4363
|
+
return;
|
|
4364
|
+
}
|
|
4365
|
+
if (action === "remove") {
|
|
4366
|
+
const remove = await prompter.askYesNo(`Remove ${name} from ${target.location}?`, false);
|
|
4367
|
+
if (remove == true) {
|
|
4368
|
+
reportWrite(prompter, target.removeEntry(name), "Removed");
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
4371
|
+
}
|
|
4372
|
+
async function runConfigCommand(options) {
|
|
4373
|
+
const { input, output } = options;
|
|
4374
|
+
const targets = options.targets ?? listConfigTargets();
|
|
4375
|
+
let target = targets[0];
|
|
4376
|
+
if (options.targetId != null) {
|
|
4377
|
+
const requested = targets.find((candidate) => candidate.id === options.targetId);
|
|
4378
|
+
if (requested == null) {
|
|
4379
|
+
output.write(`Unknown client "${options.targetId}". One of: ${targets.map((candidate) => candidate.id).join(", ")}
|
|
4380
|
+
`);
|
|
4381
|
+
return 1;
|
|
4382
|
+
}
|
|
4383
|
+
target = requested;
|
|
4384
|
+
}
|
|
4385
|
+
const prompter = new Prompter(input, output);
|
|
4386
|
+
const paint = prompter.paint;
|
|
4387
|
+
try {
|
|
4388
|
+
for (; ; ) {
|
|
4389
|
+
let entries = {};
|
|
4390
|
+
let names = [];
|
|
4391
|
+
let readError = null;
|
|
4392
|
+
try {
|
|
4393
|
+
entries = target.readEntries();
|
|
4394
|
+
names = Object.keys(entries).filter((name) => isOurEntry(entries[name]) == true);
|
|
4395
|
+
} catch (ex) {
|
|
4396
|
+
readError = errorMessage(ex);
|
|
4397
|
+
}
|
|
4398
|
+
output.write(`
|
|
4399
|
+
${paint.bold(target.label)} ${paint.dim(target.location)}
|
|
4400
|
+
`);
|
|
4401
|
+
if (readError != null) {
|
|
4402
|
+
output.write(` Cannot read it: ${readError}
|
|
4403
|
+
Fix the file by hand, or switch to another client.
|
|
4404
|
+
`);
|
|
4405
|
+
} else {
|
|
4406
|
+
const otherCount = Object.keys(entries).length - names.length;
|
|
4407
|
+
if (otherCount > 0) {
|
|
4408
|
+
output.write(paint.dim(` ${otherCount} other MCP server(s) in this file are not managed here.
|
|
4409
|
+
`));
|
|
4410
|
+
}
|
|
4411
|
+
}
|
|
4412
|
+
output.write("\n");
|
|
4413
|
+
const items = [];
|
|
4414
|
+
if (names.length > 0) {
|
|
4415
|
+
items.push(...buildHostItems(names, entries));
|
|
4416
|
+
items.push({ separator: true });
|
|
4417
|
+
}
|
|
4418
|
+
if (readError == null) {
|
|
4419
|
+
items.push({ value: "add", key: "a", label: "Add a host" });
|
|
4420
|
+
}
|
|
4421
|
+
const otherTargets = targets.filter((candidate) => candidate.id !== target.id);
|
|
4422
|
+
if (otherTargets.length > 0) {
|
|
4423
|
+
const detectedOthers = otherTargets.filter((candidate) => candidate.detected == true).map((candidate) => candidate.label);
|
|
4424
|
+
let switchHint = "";
|
|
4425
|
+
if (detectedOthers.length > 0) {
|
|
4426
|
+
switchHint = `also found: ${detectedOthers.join(", ")}`;
|
|
4427
|
+
}
|
|
4428
|
+
items.push({ value: "switch", key: "t", label: "Switch client", hint: switchHint });
|
|
4429
|
+
}
|
|
4430
|
+
items.push({ value: "quit", key: "q", label: "Quit" });
|
|
4431
|
+
let question = "No SSH hosts here yet";
|
|
4432
|
+
if (names.length === 1) {
|
|
4433
|
+
question = "1 SSH host \u2014 pick it to manage it";
|
|
4434
|
+
} else if (names.length > 1) {
|
|
4435
|
+
question = `${names.length} SSH hosts \u2014 pick one to manage it`;
|
|
4436
|
+
}
|
|
4437
|
+
let defaultValue = "add";
|
|
4438
|
+
if (names.length > 0) {
|
|
4439
|
+
defaultValue = `${HOST_VALUE_PREFIX}${names[0]}`;
|
|
4440
|
+
}
|
|
4441
|
+
const picked = await prompter.select(question, items, { defaultValue, cancelValue: "quit" });
|
|
4442
|
+
if (picked === "quit") {
|
|
4443
|
+
return 0;
|
|
4444
|
+
}
|
|
4445
|
+
if (picked === "switch") {
|
|
4446
|
+
const switched = await prompter.select(
|
|
4447
|
+
"Which client?",
|
|
4448
|
+
targets.map((candidate) => {
|
|
4449
|
+
let hint = candidate.location;
|
|
4450
|
+
if (candidate.detected == false) {
|
|
4451
|
+
hint += " \xB7 not found on this machine";
|
|
4452
|
+
}
|
|
4453
|
+
return { value: candidate.id, label: candidate.label, hint };
|
|
4454
|
+
}),
|
|
4455
|
+
{ defaultValue: target.id, cancelValue: target.id }
|
|
4456
|
+
);
|
|
4457
|
+
target = targets.find((candidate) => candidate.id === switched) ?? target;
|
|
4458
|
+
continue;
|
|
4459
|
+
}
|
|
4460
|
+
try {
|
|
4461
|
+
if (picked === "add") {
|
|
4462
|
+
await addOrEditHost(prompter, output, target, entries);
|
|
4463
|
+
continue;
|
|
4464
|
+
}
|
|
4465
|
+
if (picked.startsWith(HOST_VALUE_PREFIX) == true) {
|
|
4466
|
+
await manageHost(prompter, output, target, entries, picked.slice(HOST_VALUE_PREFIX.length));
|
|
4467
|
+
}
|
|
4468
|
+
} catch (ex) {
|
|
4469
|
+
if (ex instanceof PromptClosedError) {
|
|
4470
|
+
throw ex;
|
|
4471
|
+
}
|
|
4472
|
+
output.write(` Failed: ${errorMessage(ex)}
|
|
4473
|
+
|
|
4474
|
+
`);
|
|
4475
|
+
}
|
|
4476
|
+
}
|
|
4477
|
+
} catch (ex) {
|
|
4478
|
+
if (ex instanceof PromptClosedError) {
|
|
4479
|
+
output.write("\n");
|
|
4480
|
+
return 0;
|
|
4481
|
+
}
|
|
4482
|
+
throw ex;
|
|
4483
|
+
} finally {
|
|
4484
|
+
prompter.close();
|
|
2095
4485
|
}
|
|
2096
|
-
return {
|
|
2097
|
-
name: profile.name,
|
|
2098
|
-
host: profile.host,
|
|
2099
|
-
port: profile.port,
|
|
2100
|
-
username: profile.username,
|
|
2101
|
-
description: profile.description,
|
|
2102
|
-
authMethod,
|
|
2103
|
-
readonly: profile.policy.readonly,
|
|
2104
|
-
allowSudo: profile.policy.allowSudo,
|
|
2105
|
-
allowedPaths: profile.policy.allowedPaths,
|
|
2106
|
-
allowCommands: profile.policy.allowCommands
|
|
2107
|
-
};
|
|
2108
4486
|
}
|
|
2109
4487
|
|
|
2110
4488
|
// src/modules/tools/format.ts
|
|
@@ -2145,6 +4523,16 @@ function formatHostProfile(profile) {
|
|
|
2145
4523
|
if (summary.description != null) {
|
|
2146
4524
|
lines.push(` ${summary.description}`);
|
|
2147
4525
|
}
|
|
4526
|
+
const routeParts = [];
|
|
4527
|
+
if (summary.viaProxyCommand == true) {
|
|
4528
|
+
routeParts.push("proxy command");
|
|
4529
|
+
}
|
|
4530
|
+
if (summary.jump != null) {
|
|
4531
|
+
routeParts.push(`jump host ${summary.jump.username}@${summary.jump.host}:${summary.jump.port}`);
|
|
4532
|
+
}
|
|
4533
|
+
if (routeParts.length > 0) {
|
|
4534
|
+
lines.push(` via ${routeParts.join(" \u2192 ")}`);
|
|
4535
|
+
}
|
|
2148
4536
|
if (summary.allowedPaths.length > 0) {
|
|
2149
4537
|
lines.push(` file tools limited to: ${summary.allowedPaths.join(", ")} (does not restrict ssh_exec)`);
|
|
2150
4538
|
}
|
|
@@ -2525,7 +4913,7 @@ ${file.content}`);
|
|
|
2525
4913
|
const { profile, session: openSession } = resolveProfile(context, { session });
|
|
2526
4914
|
const resolvedLocalPath = expandHomePath(localPath);
|
|
2527
4915
|
requirePermittedLocalPath(resolvedLocalPath, "read");
|
|
2528
|
-
if (
|
|
4916
|
+
if (fs6__default.default.existsSync(resolvedLocalPath) == false) {
|
|
2529
4917
|
return textResult(`local file not found: ${resolvedLocalPath}`);
|
|
2530
4918
|
}
|
|
2531
4919
|
const transferredBytes = await withHostConnection(profile, openSession, async (connection) => {
|
|
@@ -2565,7 +4953,7 @@ ${file.content}`);
|
|
|
2565
4953
|
const { profile, session: openSession } = resolveProfile(context, { session });
|
|
2566
4954
|
const resolvedLocalPath = expandHomePath(localPath);
|
|
2567
4955
|
requirePermittedLocalPath(resolvedLocalPath, "write");
|
|
2568
|
-
|
|
4956
|
+
fs6__default.default.mkdirSync(path__default.default.dirname(resolvedLocalPath), { recursive: true });
|
|
2569
4957
|
const transferredBytes = await withHostConnection(profile, openSession, async (connection) => {
|
|
2570
4958
|
return await downloadFile(connection, remotePath, resolvedLocalPath);
|
|
2571
4959
|
});
|
|
@@ -2789,8 +5177,23 @@ function createSshMcpServer() {
|
|
|
2789
5177
|
|
|
2790
5178
|
// src/cli.ts
|
|
2791
5179
|
function parseArguments(argv) {
|
|
2792
|
-
const parsed = { showHelp: false, showVersion: false, checkOnly: false };
|
|
2793
|
-
|
|
5180
|
+
const parsed = { showHelp: false, showVersion: false, checkOnly: false, configMode: false };
|
|
5181
|
+
let rest = argv;
|
|
5182
|
+
if (argv[0] === "config") {
|
|
5183
|
+
parsed.configMode = true;
|
|
5184
|
+
rest = argv.slice(1);
|
|
5185
|
+
}
|
|
5186
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
5187
|
+
const argument = rest[index];
|
|
5188
|
+
if (argument === "--target") {
|
|
5189
|
+
parsed.targetId = rest[index + 1];
|
|
5190
|
+
index += 1;
|
|
5191
|
+
continue;
|
|
5192
|
+
}
|
|
5193
|
+
if (argument.startsWith("--target=") == true) {
|
|
5194
|
+
parsed.targetId = argument.slice("--target=".length);
|
|
5195
|
+
continue;
|
|
5196
|
+
}
|
|
2794
5197
|
if (argument === "--help" || argument === "-h") {
|
|
2795
5198
|
parsed.showHelp = true;
|
|
2796
5199
|
continue;
|
|
@@ -2811,10 +5214,18 @@ var HELP_TEXT = [
|
|
|
2811
5214
|
"",
|
|
2812
5215
|
"Usage:",
|
|
2813
5216
|
" akms-mcp-ssh [--check]",
|
|
5217
|
+
" akms-mcp-ssh config [--target <client>]",
|
|
5218
|
+
"",
|
|
5219
|
+
"Commands:",
|
|
5220
|
+
" config Manage the SSH hosts registered in an MCP client's config \u2014",
|
|
5221
|
+
" list, add, edit, remove, check, print. Asks plain questions,",
|
|
5222
|
+
" so the SSH_* names below never have to be typed.",
|
|
2814
5223
|
"",
|
|
2815
5224
|
"Options:",
|
|
2816
5225
|
" --check Connect to the configured host, report the result, and exit.",
|
|
2817
5226
|
" Prints the host key fingerprint so it can be pinned.",
|
|
5227
|
+
" --target <id> config: claude-code | codex | claude-desktop | cursor",
|
|
5228
|
+
" (default: the first one installed)",
|
|
2818
5229
|
" -v, --version Print the version and exit",
|
|
2819
5230
|
" -h, --help Print this help and exit",
|
|
2820
5231
|
"",
|
|
@@ -2829,6 +5240,11 @@ var HELP_TEXT = [
|
|
|
2829
5240
|
` ${SINGLE_HOST_ENV.NAME} Alias shown to the model; defaults to the hostname`,
|
|
2830
5241
|
` ${SINGLE_HOST_ENV.DESCRIPTION} Shown to the model \u2014 say what the host is for`,
|
|
2831
5242
|
` ${SINGLE_HOST_ENV.CWD} Directory new sessions start in`,
|
|
5243
|
+
` ${SINGLE_HOST_ENV.PROXY_COMMAND} Command carrying the SSH stream on its stdin/stdout (OpenSSH ProxyCommand);`,
|
|
5244
|
+
" %h / %p / %r expand to the first hop's host, port and user",
|
|
5245
|
+
` ${SINGLE_HOST_ENV.JUMP_HOST} Jump host the target is reached through (OpenSSH ProxyJump / -J)`,
|
|
5246
|
+
` ${SINGLE_HOST_ENV.JUMP_PORT} / ${SINGLE_HOST_ENV.JUMP_USER} / ${SINGLE_HOST_ENV.JUMP_KEY} / ${SINGLE_HOST_ENV.JUMP_PASSPHRASE} / ${SINGLE_HOST_ENV.JUMP_PASSWORD} / ${SINGLE_HOST_ENV.JUMP_HOST_KEY}`,
|
|
5247
|
+
` Jump host connection; user and key default to ${SINGLE_HOST_ENV.USER} / ${SINGLE_HOST_ENV.KEY}`,
|
|
2832
5248
|
` ${SINGLE_HOST_ENV.READONLY} Reject write commands, redirections, uploads (default false)`,
|
|
2833
5249
|
` ${SINGLE_HOST_ENV.ALLOW_SUDO} Allow sudo / su / doas (default true)`,
|
|
2834
5250
|
` ${SINGLE_HOST_ENV.ALLOW_COMMANDS} Comma-separated allow-list of binaries`,
|
|
@@ -2840,60 +5256,14 @@ var HELP_TEXT = [
|
|
|
2840
5256
|
"",
|
|
2841
5257
|
"The server speaks MCP over stdio; run it from an MCP client, not interactively."
|
|
2842
5258
|
].join("\n");
|
|
2843
|
-
function
|
|
2844
|
-
if (profile.privateKeyPath != null) {
|
|
2845
|
-
return `key ${profile.privateKeyPath}`;
|
|
2846
|
-
}
|
|
2847
|
-
if (profile.password != null) {
|
|
2848
|
-
return "password";
|
|
2849
|
-
}
|
|
2850
|
-
return "ssh agent";
|
|
2851
|
-
}
|
|
2852
|
-
async function runConnectionCheck(profile) {
|
|
5259
|
+
async function runCheckCommand(profile) {
|
|
2853
5260
|
if (profile == null) {
|
|
2854
|
-
process.stdout.write(`No SSH host is registered \u2014 set ${SINGLE_HOST_ENV.HOST} and ${SINGLE_HOST_ENV.USER}
|
|
5261
|
+
process.stdout.write(`No SSH host is registered \u2014 set ${SINGLE_HOST_ENV.HOST} and ${SINGLE_HOST_ENV.USER}, or run: akms-mcp-ssh config
|
|
2855
5262
|
`);
|
|
2856
5263
|
return 1;
|
|
2857
5264
|
}
|
|
2858
|
-
const
|
|
2859
|
-
|
|
2860
|
-
process.stdout.write(`Checking ${profile.name} \u2192 ${endpoint}
|
|
2861
|
-
|
|
2862
|
-
`);
|
|
2863
|
-
const connection = new SshConnection(profile);
|
|
2864
|
-
const startedAt = Date.now();
|
|
2865
|
-
let connected = false;
|
|
2866
|
-
let detail = "";
|
|
2867
|
-
try {
|
|
2868
|
-
await connection.connect();
|
|
2869
|
-
connected = true;
|
|
2870
|
-
} catch (ex) {
|
|
2871
|
-
detail = String(ex);
|
|
2872
|
-
if (ex instanceof Error) {
|
|
2873
|
-
detail = ex.message;
|
|
2874
|
-
}
|
|
2875
|
-
} finally {
|
|
2876
|
-
connection.disconnect();
|
|
2877
|
-
}
|
|
2878
|
-
const durationMs = Date.now() - startedAt;
|
|
2879
|
-
if (connected == true) {
|
|
2880
|
-
process.stdout.write(` OK ${endpoint} (${auth}, ${durationMs}ms)
|
|
2881
|
-
`);
|
|
2882
|
-
} else {
|
|
2883
|
-
process.stdout.write(` FAILED ${endpoint} (${auth})
|
|
2884
|
-
`);
|
|
2885
|
-
process.stdout.write(` ${detail}
|
|
2886
|
-
`);
|
|
2887
|
-
}
|
|
2888
|
-
if (connection.hostKeyFingerprint != null) {
|
|
2889
|
-
let pinNote = `not pinned \u2014 set ${SINGLE_HOST_ENV.HOST_KEY} to it to detect a changed key`;
|
|
2890
|
-
if (profile.hostKeyFingerprint != null) {
|
|
2891
|
-
pinNote = "pinned";
|
|
2892
|
-
}
|
|
2893
|
-
process.stdout.write(` host key ${connection.hostKeyFingerprint} (${pinNote})
|
|
2894
|
-
`);
|
|
2895
|
-
}
|
|
2896
|
-
if (connected == true) {
|
|
5265
|
+
const result = await runConnectionCheck(profile, process.stdout);
|
|
5266
|
+
if (result.connected == true) {
|
|
2897
5267
|
return 0;
|
|
2898
5268
|
}
|
|
2899
5269
|
return 1;
|
|
@@ -2911,12 +5281,23 @@ async function main() {
|
|
|
2911
5281
|
`);
|
|
2912
5282
|
return;
|
|
2913
5283
|
}
|
|
5284
|
+
if (cliArguments.configMode == true) {
|
|
5285
|
+
if (process.env[LOG_LEVEL_ENV] == null) {
|
|
5286
|
+
process.env[LOG_LEVEL_ENV] = "warn";
|
|
5287
|
+
}
|
|
5288
|
+
const exitCode = await runConfigCommand({
|
|
5289
|
+
input: process.stdin,
|
|
5290
|
+
output: process.stdout,
|
|
5291
|
+
targetId: cliArguments.targetId
|
|
5292
|
+
});
|
|
5293
|
+
process.exit(exitCode);
|
|
5294
|
+
}
|
|
2914
5295
|
if (cliArguments.checkOnly == true && process.env[LOG_LEVEL_ENV] == null) {
|
|
2915
|
-
process.env[LOG_LEVEL_ENV] = "
|
|
5296
|
+
process.env[LOG_LEVEL_ENV] = "warn";
|
|
2916
5297
|
}
|
|
2917
5298
|
const instance = createSshMcpServer();
|
|
2918
5299
|
if (cliArguments.checkOnly == true) {
|
|
2919
|
-
const exitCode = await
|
|
5300
|
+
const exitCode = await runCheckCommand(instance.profile);
|
|
2920
5301
|
instance.shutdown();
|
|
2921
5302
|
process.exit(exitCode);
|
|
2922
5303
|
}
|