@akms/mcp-ssh 0.1.0 → 0.1.2
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 +12 -7
- package/lib/cli.cjs +1268 -467
- package/lib/cli.cjs.map +1 -1
- package/lib/cli.js +1266 -465
- package/lib/cli.js.map +1 -1
- package/lib/index.cjs +1 -1
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.cts +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
package/lib/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
-
import
|
|
4
|
-
import
|
|
3
|
+
import fs6 from 'fs';
|
|
4
|
+
import os5 from 'os';
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { spawnSync, spawn } from 'child_process';
|
|
7
7
|
import { createHash } from 'crypto';
|
|
@@ -26,7 +26,7 @@ var CWD_MARKER = "__AKMS_SSH_CWD__";
|
|
|
26
26
|
var MAX_DIRECTORY_ENTRIES = 1e3;
|
|
27
27
|
var SERVER_NAME = "akms-mcp-ssh";
|
|
28
28
|
var PACKAGE_NAME = "@akms/mcp-ssh";
|
|
29
|
-
var SERVER_VERSION = "0.1.
|
|
29
|
+
var SERVER_VERSION = "0.1.2";
|
|
30
30
|
|
|
31
31
|
// src/_defs/command-rules.ts
|
|
32
32
|
var CRITICAL_PATH_PATTERN = new RegExp(
|
|
@@ -601,7 +601,7 @@ function findExecutable(name) {
|
|
|
601
601
|
for (const directory of directories) {
|
|
602
602
|
for (const suffix of suffixes) {
|
|
603
603
|
const candidate = path.join(directory, `${name}${suffix}`);
|
|
604
|
-
if (
|
|
604
|
+
if (fs6.existsSync(candidate) == true && fs6.statSync(candidate).isFile() == true) {
|
|
605
605
|
return candidate;
|
|
606
606
|
}
|
|
607
607
|
}
|
|
@@ -611,9 +611,9 @@ function findExecutable(name) {
|
|
|
611
611
|
function expandHomePath(inputPath) {
|
|
612
612
|
let expanded = inputPath;
|
|
613
613
|
if (inputPath === "~") {
|
|
614
|
-
expanded =
|
|
614
|
+
expanded = os5.homedir();
|
|
615
615
|
} else if (inputPath.startsWith("~/") == true || inputPath.startsWith("~\\") == true) {
|
|
616
|
-
expanded = path.join(
|
|
616
|
+
expanded = path.join(os5.homedir(), inputPath.slice(2));
|
|
617
617
|
}
|
|
618
618
|
return path.resolve(expanded);
|
|
619
619
|
}
|
|
@@ -742,7 +742,7 @@ function loadHostProfile(env = process.env) {
|
|
|
742
742
|
if (keyPath != null) {
|
|
743
743
|
const expandedKeyPath = expandHomePath(keyPath);
|
|
744
744
|
profile.privateKeyPath = expandedKeyPath;
|
|
745
|
-
if (
|
|
745
|
+
if (fs6.existsSync(expandedKeyPath) == false) {
|
|
746
746
|
_logger.warn(`${SINGLE_HOST_ENV.KEY} does not exist; connections will fail`, {
|
|
747
747
|
private_key_path: expandedKeyPath
|
|
748
748
|
});
|
|
@@ -774,7 +774,7 @@ function loadHostProfile(env = process.env) {
|
|
|
774
774
|
if (jumpKeyPath != null) {
|
|
775
775
|
profile.jump.privateKeyPath = expandHomePath(jumpKeyPath);
|
|
776
776
|
profile.jump.passphrase = readEnv(env, SINGLE_HOST_ENV.JUMP_PASSPHRASE);
|
|
777
|
-
if (
|
|
777
|
+
if (fs6.existsSync(profile.jump.privateKeyPath) == false) {
|
|
778
778
|
_logger.warn(`${SINGLE_HOST_ENV.JUMP_KEY} does not exist; connections will fail`, {
|
|
779
779
|
private_key_path: profile.jump.privateKeyPath
|
|
780
780
|
});
|
|
@@ -1260,14 +1260,14 @@ function inspectRemotePath(remotePath, policy, access) {
|
|
|
1260
1260
|
function toRealPath(inputPath) {
|
|
1261
1261
|
const _logger = $logger.child({ context: "toRealPath" });
|
|
1262
1262
|
try {
|
|
1263
|
-
return
|
|
1263
|
+
return fs6.realpathSync.native(inputPath);
|
|
1264
1264
|
} catch (ex) {
|
|
1265
1265
|
_logger.debug("path does not resolve, falling back to its parent directory", {
|
|
1266
1266
|
reason: ex instanceof Error ? ex.name : "unknown"
|
|
1267
1267
|
});
|
|
1268
1268
|
}
|
|
1269
1269
|
try {
|
|
1270
|
-
const parentRealPath =
|
|
1270
|
+
const parentRealPath = fs6.realpathSync.native(path.dirname(inputPath));
|
|
1271
1271
|
return path.join(parentRealPath, path.basename(inputPath));
|
|
1272
1272
|
} catch (ex) {
|
|
1273
1273
|
_logger.debug("parent directory does not resolve either, comparing lexically", {
|
|
@@ -1359,7 +1359,7 @@ function readPrivateKey(label, keyPath) {
|
|
|
1359
1359
|
return cached;
|
|
1360
1360
|
}
|
|
1361
1361
|
try {
|
|
1362
|
-
const contents =
|
|
1362
|
+
const contents = fs6.readFileSync(keyPath);
|
|
1363
1363
|
PRIVATE_KEY_CACHE.set(keyPath, contents);
|
|
1364
1364
|
return contents;
|
|
1365
1365
|
} catch (ex) {
|
|
@@ -2310,7 +2310,7 @@ async function writeRemoteFile(connection, args) {
|
|
|
2310
2310
|
}
|
|
2311
2311
|
async function uploadFile(connection, localPath, remotePath) {
|
|
2312
2312
|
requirePermittedPath(connection, remotePath, "write");
|
|
2313
|
-
const localStats =
|
|
2313
|
+
const localStats = fs6.statSync(localPath);
|
|
2314
2314
|
if (localStats.isFile() == false) {
|
|
2315
2315
|
throw new Error(`local path is not a file: ${localPath}`);
|
|
2316
2316
|
}
|
|
@@ -2347,7 +2347,7 @@ async function downloadFile(connection, remotePath, localPath) {
|
|
|
2347
2347
|
});
|
|
2348
2348
|
});
|
|
2349
2349
|
});
|
|
2350
|
-
const localStats =
|
|
2350
|
+
const localStats = fs6.statSync(localPath);
|
|
2351
2351
|
const _logger = $logger.child({
|
|
2352
2352
|
context: "downloadFile",
|
|
2353
2353
|
profile: connection.profile.name,
|
|
@@ -2359,6 +2359,23 @@ async function downloadFile(connection, remotePath, localPath) {
|
|
|
2359
2359
|
}
|
|
2360
2360
|
|
|
2361
2361
|
// src/modules/manage/connection-check.ts
|
|
2362
|
+
function profileFromEnv(output, env) {
|
|
2363
|
+
try {
|
|
2364
|
+
const profile = loadHostProfile(env);
|
|
2365
|
+
if (profile == null) {
|
|
2366
|
+
output.write(" The entry has no host.\n");
|
|
2367
|
+
}
|
|
2368
|
+
return profile;
|
|
2369
|
+
} catch (ex) {
|
|
2370
|
+
let detail = String(ex);
|
|
2371
|
+
if (ex instanceof Error) {
|
|
2372
|
+
detail = ex.message;
|
|
2373
|
+
}
|
|
2374
|
+
output.write(` The server would refuse to start with this entry: ${detail}
|
|
2375
|
+
`);
|
|
2376
|
+
return null;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2362
2379
|
function describeAuth(profile) {
|
|
2363
2380
|
if (profile.privateKeyPath != null) {
|
|
2364
2381
|
return `key ${profile.privateKeyPath}`;
|
|
@@ -2617,6 +2634,654 @@ function summarizeEntry(entry) {
|
|
|
2617
2634
|
notes: notes.join(", ")
|
|
2618
2635
|
};
|
|
2619
2636
|
}
|
|
2637
|
+
var PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
|
2638
|
+
var NON_KEY_FILE_NAMES = ["config", "known_hosts", "known_hosts.old", "authorized_keys", "environment"];
|
|
2639
|
+
var MAX_KEY_FILE_BYTES = 64 * 1024;
|
|
2640
|
+
var CLEAR_ANSWER = "-";
|
|
2641
|
+
var OTHER_KEY_PATH = "\0other";
|
|
2642
|
+
var FIELD_NAME_WIDTH = 17;
|
|
2643
|
+
var SUB_FIELDS = ["key", "passphrase", "password", "jump-host", "jump-port", "jump-user", "proxy"];
|
|
2644
|
+
var FIELD_NAMES = {
|
|
2645
|
+
"host": "Host",
|
|
2646
|
+
"port": "Port",
|
|
2647
|
+
"user": "User",
|
|
2648
|
+
"name": "Server name",
|
|
2649
|
+
"auth": "Authentication",
|
|
2650
|
+
"key": "Private key",
|
|
2651
|
+
"passphrase": "Passphrase",
|
|
2652
|
+
"password": "Password",
|
|
2653
|
+
"route": "Route",
|
|
2654
|
+
"jump-host": "Jump host",
|
|
2655
|
+
"jump-port": "Jump port",
|
|
2656
|
+
"jump-user": "Jump user",
|
|
2657
|
+
"proxy": "Proxy command",
|
|
2658
|
+
"description": "Description",
|
|
2659
|
+
"readonly": "Read-only",
|
|
2660
|
+
"paths": "File paths",
|
|
2661
|
+
"host-key": "Host key",
|
|
2662
|
+
"jump-host-key": "Jump host key"
|
|
2663
|
+
};
|
|
2664
|
+
var AUTH_LABELS = {
|
|
2665
|
+
key: "Private key file",
|
|
2666
|
+
password: "Password",
|
|
2667
|
+
agent: "SSH agent"
|
|
2668
|
+
};
|
|
2669
|
+
var ROUTE_LABELS = {
|
|
2670
|
+
"direct": "Directly",
|
|
2671
|
+
"jump": "Through a jump host",
|
|
2672
|
+
"proxy": "Through a proxy command",
|
|
2673
|
+
"jump-proxy": "Proxy command, then a jump host"
|
|
2674
|
+
};
|
|
2675
|
+
var REQUIRED_MARK = "(required)";
|
|
2676
|
+
function findPrivateKeys() {
|
|
2677
|
+
const sshDirectory = path.join(os5.homedir(), ".ssh");
|
|
2678
|
+
let names = [];
|
|
2679
|
+
try {
|
|
2680
|
+
names = fs6.readdirSync(sshDirectory);
|
|
2681
|
+
} catch {
|
|
2682
|
+
return [];
|
|
2683
|
+
}
|
|
2684
|
+
const keys = [];
|
|
2685
|
+
for (const name of names) {
|
|
2686
|
+
if (name.endsWith(".pub") == true || NON_KEY_FILE_NAMES.includes(name) == true) {
|
|
2687
|
+
continue;
|
|
2688
|
+
}
|
|
2689
|
+
const fullPath = path.join(sshDirectory, name);
|
|
2690
|
+
let head = "";
|
|
2691
|
+
try {
|
|
2692
|
+
const stat = fs6.statSync(fullPath);
|
|
2693
|
+
if (stat.isFile() == false || stat.size > MAX_KEY_FILE_BYTES) {
|
|
2694
|
+
continue;
|
|
2695
|
+
}
|
|
2696
|
+
const descriptor = fs6.openSync(fullPath, "r");
|
|
2697
|
+
const buffer = Buffer.alloc(64);
|
|
2698
|
+
const bytesRead = fs6.readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
2699
|
+
fs6.closeSync(descriptor);
|
|
2700
|
+
head = buffer.subarray(0, bytesRead).toString("utf8");
|
|
2701
|
+
} catch {
|
|
2702
|
+
continue;
|
|
2703
|
+
}
|
|
2704
|
+
const isPemKey = head.startsWith("-----BEGIN") == true && head.includes("PRIVATE KEY") == true;
|
|
2705
|
+
const isPuttyKey = head.startsWith("PuTTY-User-Key-File") == true;
|
|
2706
|
+
if (isPemKey == false && isPuttyKey == false) {
|
|
2707
|
+
continue;
|
|
2708
|
+
}
|
|
2709
|
+
let hint = "";
|
|
2710
|
+
try {
|
|
2711
|
+
const publicKey = fs6.readFileSync(`${fullPath}.pub`, "utf8").trim().split(/\s+/);
|
|
2712
|
+
hint = publicKey[0].replace(/^ssh-/, "");
|
|
2713
|
+
if (publicKey.length > 2) {
|
|
2714
|
+
hint += ` \xB7 ${publicKey.slice(2).join(" ")}`;
|
|
2715
|
+
}
|
|
2716
|
+
} catch {
|
|
2717
|
+
}
|
|
2718
|
+
keys.push({ path: `~/.ssh/${name}`, hint });
|
|
2719
|
+
}
|
|
2720
|
+
const rank = (key) => {
|
|
2721
|
+
const index = PREFERRED_KEY_NAMES.indexOf(path.basename(key.path));
|
|
2722
|
+
if (index < 0) {
|
|
2723
|
+
return PREFERRED_KEY_NAMES.length;
|
|
2724
|
+
}
|
|
2725
|
+
return index;
|
|
2726
|
+
};
|
|
2727
|
+
return keys.sort((left, right) => rank(left) - rank(right) || left.path.localeCompare(right.path));
|
|
2728
|
+
}
|
|
2729
|
+
async function askPort(prompter, question, current) {
|
|
2730
|
+
for (; ; ) {
|
|
2731
|
+
const answer = await prompter.ask(question, String(current));
|
|
2732
|
+
const parsed = Number(answer);
|
|
2733
|
+
if (Number.isInteger(parsed) == true && parsed > 0 && parsed <= 65535) {
|
|
2734
|
+
return parsed;
|
|
2735
|
+
}
|
|
2736
|
+
prompter.write(" Enter a port number between 1 and 65535.\n");
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
async function askOptional(prompter, question, current) {
|
|
2740
|
+
const answer = await prompter.ask(question, current);
|
|
2741
|
+
if (answer === "" || answer === CLEAR_ANSWER) {
|
|
2742
|
+
return void 0;
|
|
2743
|
+
}
|
|
2744
|
+
return answer;
|
|
2745
|
+
}
|
|
2746
|
+
async function askOptionalSecret(prompter, question, current) {
|
|
2747
|
+
let suffix = "(empty for none)";
|
|
2748
|
+
if (current != null) {
|
|
2749
|
+
suffix = `(enter keeps current, ${CLEAR_ANSWER} clears)`;
|
|
2750
|
+
}
|
|
2751
|
+
const answer = await prompter.askSecret(`${question} ${prompter.paint.dim(suffix)}`);
|
|
2752
|
+
if (answer === CLEAR_ANSWER) {
|
|
2753
|
+
return void 0;
|
|
2754
|
+
}
|
|
2755
|
+
if (answer === "") {
|
|
2756
|
+
return current;
|
|
2757
|
+
}
|
|
2758
|
+
return answer;
|
|
2759
|
+
}
|
|
2760
|
+
async function askKeyPath(prompter, current) {
|
|
2761
|
+
const found = findPrivateKeys();
|
|
2762
|
+
let picked = OTHER_KEY_PATH;
|
|
2763
|
+
if (found.length > 0 || current != null) {
|
|
2764
|
+
const items = [];
|
|
2765
|
+
if (current != null && found.some((key) => key.path === current) == false) {
|
|
2766
|
+
items.push({ value: current, label: current, hint: "current" });
|
|
2767
|
+
}
|
|
2768
|
+
for (const key of found) {
|
|
2769
|
+
items.push({ value: key.path, label: key.path, hint: key.hint });
|
|
2770
|
+
}
|
|
2771
|
+
items.push({ separator: true });
|
|
2772
|
+
items.push({ value: OTHER_KEY_PATH, key: "other", label: "Another path\u2026" });
|
|
2773
|
+
picked = await prompter.select("Private key", items, { defaultValue: current ?? found[0]?.path });
|
|
2774
|
+
}
|
|
2775
|
+
if (picked !== OTHER_KEY_PATH) {
|
|
2776
|
+
return picked;
|
|
2777
|
+
}
|
|
2778
|
+
for (; ; ) {
|
|
2779
|
+
const typed = await prompter.askRequired("Private key path");
|
|
2780
|
+
if (fs6.existsSync(expandHomePath(typed)) == true) {
|
|
2781
|
+
return typed;
|
|
2782
|
+
}
|
|
2783
|
+
prompter.write(` ${expandHomePath(typed)} does not exist.
|
|
2784
|
+
`);
|
|
2785
|
+
const useAnyway = await prompter.askYesNo("Use this path anyway?", false);
|
|
2786
|
+
if (useAnyway == true) {
|
|
2787
|
+
return typed;
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
function routeUsesJump(route) {
|
|
2792
|
+
return route === "jump" || route === "jump-proxy";
|
|
2793
|
+
}
|
|
2794
|
+
function routeUsesProxy(route) {
|
|
2795
|
+
return route === "proxy" || route === "jump-proxy";
|
|
2796
|
+
}
|
|
2797
|
+
function deriveServerName(host) {
|
|
2798
|
+
return `ssh-${host}`.replace(/[^A-Za-z0-9_-]+/g, "-").slice(0, 48);
|
|
2799
|
+
}
|
|
2800
|
+
function finalizeAnswers(state) {
|
|
2801
|
+
const answers = { ...state.answers };
|
|
2802
|
+
if (routeUsesJump(state.route) == false) {
|
|
2803
|
+
answers.jumpHost = void 0;
|
|
2804
|
+
answers.jumpPort = void 0;
|
|
2805
|
+
answers.jumpUser = void 0;
|
|
2806
|
+
answers.jumpHostKeyFingerprint = void 0;
|
|
2807
|
+
}
|
|
2808
|
+
if (routeUsesProxy(state.route) == false) {
|
|
2809
|
+
answers.proxyCommand = void 0;
|
|
2810
|
+
}
|
|
2811
|
+
if (answers.auth !== "key") {
|
|
2812
|
+
answers.keyPath = void 0;
|
|
2813
|
+
answers.passphrase = void 0;
|
|
2814
|
+
}
|
|
2815
|
+
if (answers.auth !== "password") {
|
|
2816
|
+
answers.password = void 0;
|
|
2817
|
+
}
|
|
2818
|
+
return answers;
|
|
2819
|
+
}
|
|
2820
|
+
function snapshotOf(state) {
|
|
2821
|
+
return JSON.stringify(finalizeAnswers(state));
|
|
2822
|
+
}
|
|
2823
|
+
function missingFields(state) {
|
|
2824
|
+
const { answers } = state;
|
|
2825
|
+
const missing = [];
|
|
2826
|
+
if (answers.host === "") {
|
|
2827
|
+
missing.push("host");
|
|
2828
|
+
}
|
|
2829
|
+
if (answers.username === "") {
|
|
2830
|
+
missing.push("user");
|
|
2831
|
+
}
|
|
2832
|
+
if (answers.auth === "key" && answers.keyPath == null) {
|
|
2833
|
+
missing.push("key");
|
|
2834
|
+
}
|
|
2835
|
+
if (answers.auth === "password" && answers.password == null) {
|
|
2836
|
+
missing.push("password");
|
|
2837
|
+
}
|
|
2838
|
+
if (routeUsesJump(state.route) == true && answers.jumpHost == null) {
|
|
2839
|
+
missing.push("jump-host");
|
|
2840
|
+
}
|
|
2841
|
+
if (routeUsesProxy(state.route) == true && answers.proxyCommand == null) {
|
|
2842
|
+
missing.push("proxy");
|
|
2843
|
+
}
|
|
2844
|
+
return missing;
|
|
2845
|
+
}
|
|
2846
|
+
function describePin(fingerprint) {
|
|
2847
|
+
if (fingerprint == null) {
|
|
2848
|
+
return "not pinned";
|
|
2849
|
+
}
|
|
2850
|
+
return `pinned \xB7 ${fingerprint.slice(0, 19)}\u2026`;
|
|
2851
|
+
}
|
|
2852
|
+
function valueOrRequired(value) {
|
|
2853
|
+
if (value == null || value === "") {
|
|
2854
|
+
return REQUIRED_MARK;
|
|
2855
|
+
}
|
|
2856
|
+
return value;
|
|
2857
|
+
}
|
|
2858
|
+
function fieldRow(id, value, hint) {
|
|
2859
|
+
let name = FIELD_NAMES[id];
|
|
2860
|
+
if (SUB_FIELDS.includes(id) == true) {
|
|
2861
|
+
name = ` ${name}`;
|
|
2862
|
+
}
|
|
2863
|
+
return {
|
|
2864
|
+
value: id,
|
|
2865
|
+
key: id,
|
|
2866
|
+
label: `${name.padEnd(FIELD_NAME_WIDTH)}${value}`,
|
|
2867
|
+
hint
|
|
2868
|
+
};
|
|
2869
|
+
}
|
|
2870
|
+
function buildRows(state, destination) {
|
|
2871
|
+
const { answers } = state;
|
|
2872
|
+
const rows = [];
|
|
2873
|
+
rows.push(fieldRow("host", valueOrRequired(answers.host)));
|
|
2874
|
+
rows.push(fieldRow("port", String(answers.port)));
|
|
2875
|
+
rows.push(fieldRow("user", valueOrRequired(answers.username)));
|
|
2876
|
+
let nameValue = answers.name;
|
|
2877
|
+
let nameHint = "what the agent sees";
|
|
2878
|
+
if (state.nameTouched == false) {
|
|
2879
|
+
nameHint = "follows the host until you change it";
|
|
2880
|
+
}
|
|
2881
|
+
if (nameValue === "") {
|
|
2882
|
+
nameValue = "ssh-<host>";
|
|
2883
|
+
}
|
|
2884
|
+
rows.push(fieldRow("name", nameValue, nameHint));
|
|
2885
|
+
rows.push(fieldRow("auth", AUTH_LABELS[answers.auth]));
|
|
2886
|
+
if (answers.auth === "key") {
|
|
2887
|
+
rows.push(fieldRow("key", valueOrRequired(answers.keyPath)));
|
|
2888
|
+
let passphrase = "none";
|
|
2889
|
+
if (answers.passphrase != null) {
|
|
2890
|
+
passphrase = "set";
|
|
2891
|
+
}
|
|
2892
|
+
rows.push(fieldRow("passphrase", passphrase));
|
|
2893
|
+
}
|
|
2894
|
+
if (answers.auth === "password") {
|
|
2895
|
+
let password = REQUIRED_MARK;
|
|
2896
|
+
if (answers.password != null) {
|
|
2897
|
+
password = "set";
|
|
2898
|
+
}
|
|
2899
|
+
rows.push(fieldRow("password", password, "stored in the config file as plain text"));
|
|
2900
|
+
}
|
|
2901
|
+
rows.push(fieldRow("route", ROUTE_LABELS[state.route]));
|
|
2902
|
+
if (routeUsesJump(state.route) == true) {
|
|
2903
|
+
rows.push(fieldRow("jump-host", valueOrRequired(answers.jumpHost)));
|
|
2904
|
+
rows.push(fieldRow("jump-port", String(answers.jumpPort ?? DEFAULT_SSH_PORT)));
|
|
2905
|
+
let jumpUser = answers.jumpUser;
|
|
2906
|
+
if (jumpUser == null || jumpUser === answers.username) {
|
|
2907
|
+
let loginUser = answers.username;
|
|
2908
|
+
if (loginUser === "") {
|
|
2909
|
+
loginUser = "\u2026";
|
|
2910
|
+
}
|
|
2911
|
+
jumpUser = `same as login (${loginUser})`;
|
|
2912
|
+
}
|
|
2913
|
+
rows.push(fieldRow("jump-user", jumpUser));
|
|
2914
|
+
}
|
|
2915
|
+
if (routeUsesProxy(state.route) == true) {
|
|
2916
|
+
rows.push(fieldRow("proxy", valueOrRequired(answers.proxyCommand), "%h %p %r \u2192 first hop"));
|
|
2917
|
+
}
|
|
2918
|
+
rows.push(fieldRow("description", answers.description ?? "none", "shown to the agent"));
|
|
2919
|
+
let readonly = "no";
|
|
2920
|
+
if (answers.readonly == true) {
|
|
2921
|
+
readonly = "yes";
|
|
2922
|
+
}
|
|
2923
|
+
rows.push(fieldRow("readonly", readonly, "enter toggles"));
|
|
2924
|
+
let paths = "anywhere";
|
|
2925
|
+
if (answers.allowedPaths.length > 0) {
|
|
2926
|
+
paths = answers.allowedPaths.join(", ");
|
|
2927
|
+
}
|
|
2928
|
+
rows.push(fieldRow("paths", paths, "where the file tools may read and write"));
|
|
2929
|
+
rows.push(fieldRow("host-key", describePin(answers.hostKeyFingerprint)));
|
|
2930
|
+
if (routeUsesJump(state.route) == true) {
|
|
2931
|
+
rows.push(fieldRow("jump-host-key", describePin(answers.jumpHostKeyFingerprint)));
|
|
2932
|
+
}
|
|
2933
|
+
rows.push({ separator: true });
|
|
2934
|
+
let testHint = "not run yet";
|
|
2935
|
+
if (state.lastTest === "ok") {
|
|
2936
|
+
testHint = "last run: OK";
|
|
2937
|
+
} else if (state.lastTest === "failed") {
|
|
2938
|
+
testHint = "last run: failed";
|
|
2939
|
+
}
|
|
2940
|
+
rows.push({ value: "test", key: "test", label: "Test the connection", hint: testHint });
|
|
2941
|
+
rows.push({ value: "save", key: "save", label: "Save", hint: `to ${destination}` });
|
|
2942
|
+
rows.push({ value: "cancel", key: "cancel", label: "Cancel" });
|
|
2943
|
+
return rows;
|
|
2944
|
+
}
|
|
2945
|
+
function nextCursor(state, destination, edited) {
|
|
2946
|
+
if (edited === "readonly") {
|
|
2947
|
+
return edited;
|
|
2948
|
+
}
|
|
2949
|
+
const values = buildRows(state, destination).flatMap((row) => {
|
|
2950
|
+
if ("value" in row) {
|
|
2951
|
+
return [row.value];
|
|
2952
|
+
}
|
|
2953
|
+
return [];
|
|
2954
|
+
});
|
|
2955
|
+
let index = values.indexOf(edited) + 1;
|
|
2956
|
+
if (SUB_FIELDS.includes(edited) == false) {
|
|
2957
|
+
while (index < values.length && SUB_FIELDS.includes(values[index]) == true) {
|
|
2958
|
+
index += 1;
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
const next = values[index];
|
|
2962
|
+
if (next == null || next === "test") {
|
|
2963
|
+
return "save";
|
|
2964
|
+
}
|
|
2965
|
+
return next;
|
|
2966
|
+
}
|
|
2967
|
+
async function testConnection(prompter, output, state) {
|
|
2968
|
+
const answers = finalizeAnswers(state);
|
|
2969
|
+
output.write("\n");
|
|
2970
|
+
const profile = profileFromEnv(output, answersToEntry(answers).env);
|
|
2971
|
+
if (profile == null) {
|
|
2972
|
+
output.write("\n");
|
|
2973
|
+
state.lastTest = "failed";
|
|
2974
|
+
return false;
|
|
2975
|
+
}
|
|
2976
|
+
const result = await runConnectionCheck(profile, output);
|
|
2977
|
+
output.write("\n");
|
|
2978
|
+
if (result.connected == true) {
|
|
2979
|
+
if (result.hostKeyFingerprint != null && state.answers.hostKeyFingerprint == null) {
|
|
2980
|
+
const pin = await prompter.askYesNo("Pin this host key? (a changed key then aborts the connection)", true);
|
|
2981
|
+
if (pin == true) {
|
|
2982
|
+
state.answers.hostKeyFingerprint = result.hostKeyFingerprint;
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
if (result.jumpHostKeyFingerprint != null && routeUsesJump(state.route) == true && state.answers.jumpHostKeyFingerprint == null) {
|
|
2986
|
+
const pin = await prompter.askYesNo("Pin the jump host key too?", true);
|
|
2987
|
+
if (pin == true) {
|
|
2988
|
+
state.answers.jumpHostKeyFingerprint = result.jumpHostKeyFingerprint;
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
state.lastTest = "ok";
|
|
2992
|
+
state.testedSnapshot = snapshotOf(state);
|
|
2993
|
+
return true;
|
|
2994
|
+
}
|
|
2995
|
+
const hostKeyChanged = result.hostKeyFingerprint != null && state.answers.hostKeyFingerprint != null && result.hostKeyFingerprint !== state.answers.hostKeyFingerprint;
|
|
2996
|
+
if (hostKeyChanged == true) {
|
|
2997
|
+
output.write(" The host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
|
|
2998
|
+
const repin = await prompter.askYesNo(`Pin ${result.hostKeyFingerprint} instead?`, false);
|
|
2999
|
+
if (repin == true) {
|
|
3000
|
+
state.answers.hostKeyFingerprint = result.hostKeyFingerprint ?? void 0;
|
|
3001
|
+
output.write(" Pinned the new key \u2014 test again to confirm.\n");
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
const jumpKeyChanged = result.jumpHostKeyFingerprint != null && state.answers.jumpHostKeyFingerprint != null && result.jumpHostKeyFingerprint !== state.answers.jumpHostKeyFingerprint;
|
|
3005
|
+
if (jumpKeyChanged == true) {
|
|
3006
|
+
output.write(" The jump host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
|
|
3007
|
+
const repin = await prompter.askYesNo(`Pin ${result.jumpHostKeyFingerprint} instead?`, false);
|
|
3008
|
+
if (repin == true) {
|
|
3009
|
+
state.answers.jumpHostKeyFingerprint = result.jumpHostKeyFingerprint ?? void 0;
|
|
3010
|
+
output.write(" Pinned the new key \u2014 test again to confirm.\n");
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
state.lastTest = "failed";
|
|
3014
|
+
return false;
|
|
3015
|
+
}
|
|
3016
|
+
async function editField(prompter, output, state, id) {
|
|
3017
|
+
const { answers } = state;
|
|
3018
|
+
if (id === "host") {
|
|
3019
|
+
let current;
|
|
3020
|
+
if (answers.host !== "") {
|
|
3021
|
+
current = answers.host;
|
|
3022
|
+
}
|
|
3023
|
+
answers.host = await prompter.askRequired("Host (hostname or IP)", current);
|
|
3024
|
+
if (state.nameTouched == false) {
|
|
3025
|
+
answers.name = deriveServerName(answers.host);
|
|
3026
|
+
}
|
|
3027
|
+
return;
|
|
3028
|
+
}
|
|
3029
|
+
if (id === "port") {
|
|
3030
|
+
answers.port = await askPort(prompter, "Port", answers.port);
|
|
3031
|
+
return;
|
|
3032
|
+
}
|
|
3033
|
+
if (id === "user") {
|
|
3034
|
+
let current;
|
|
3035
|
+
if (answers.username !== "") {
|
|
3036
|
+
current = answers.username;
|
|
3037
|
+
}
|
|
3038
|
+
answers.username = await prompter.askRequired("Login user", current);
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
if (id === "name") {
|
|
3042
|
+
let current;
|
|
3043
|
+
if (answers.name !== "") {
|
|
3044
|
+
current = answers.name;
|
|
3045
|
+
}
|
|
3046
|
+
for (; ; ) {
|
|
3047
|
+
const name = await prompter.askRequired("Server name the agent sees", current);
|
|
3048
|
+
if (SERVER_NAME_PATTERN.test(name) == true) {
|
|
3049
|
+
answers.name = name;
|
|
3050
|
+
state.nameTouched = true;
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
prompter.write(" Use letters, digits, '-' and '_' only (max 48) \u2014 the name becomes part of every tool name.\n");
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
if (id === "auth") {
|
|
3057
|
+
answers.auth = await prompter.select(
|
|
3058
|
+
"Authentication",
|
|
3059
|
+
[
|
|
3060
|
+
{ value: "key", label: AUTH_LABELS.key, hint: "only the path is stored \u2014 recommended" },
|
|
3061
|
+
{ value: "password", label: AUTH_LABELS.password, hint: "stored in the config file as plain text" },
|
|
3062
|
+
{ value: "agent", label: AUTH_LABELS.agent, hint: "keys already loaded in ssh-agent" }
|
|
3063
|
+
],
|
|
3064
|
+
{ defaultValue: answers.auth }
|
|
3065
|
+
);
|
|
3066
|
+
if (answers.auth === "key" && answers.keyPath == null) {
|
|
3067
|
+
answers.keyPath = await askKeyPath(prompter, void 0);
|
|
3068
|
+
}
|
|
3069
|
+
if (answers.auth === "password" && answers.password == null) {
|
|
3070
|
+
answers.password = await askOptionalSecret(prompter, "Password", void 0);
|
|
3071
|
+
}
|
|
3072
|
+
return;
|
|
3073
|
+
}
|
|
3074
|
+
if (id === "key") {
|
|
3075
|
+
answers.keyPath = await askKeyPath(prompter, answers.keyPath);
|
|
3076
|
+
return;
|
|
3077
|
+
}
|
|
3078
|
+
if (id === "passphrase") {
|
|
3079
|
+
answers.passphrase = await askOptionalSecret(prompter, "Key passphrase", answers.passphrase);
|
|
3080
|
+
return;
|
|
3081
|
+
}
|
|
3082
|
+
if (id === "password") {
|
|
3083
|
+
answers.password = await askOptionalSecret(prompter, "Password", answers.password);
|
|
3084
|
+
return;
|
|
3085
|
+
}
|
|
3086
|
+
if (id === "route") {
|
|
3087
|
+
state.route = await prompter.select(
|
|
3088
|
+
"Route",
|
|
3089
|
+
[
|
|
3090
|
+
{ value: "direct", label: ROUTE_LABELS.direct },
|
|
3091
|
+
{ value: "jump", label: ROUTE_LABELS.jump, hint: "ssh -J" },
|
|
3092
|
+
{ value: "proxy", label: ROUTE_LABELS.proxy, hint: "e.g. cloudflared access ssh" },
|
|
3093
|
+
{ value: "jump-proxy", label: ROUTE_LABELS["jump-proxy"], hint: "cloudflared to the bastion, then on to the host" }
|
|
3094
|
+
],
|
|
3095
|
+
{ defaultValue: state.route }
|
|
3096
|
+
);
|
|
3097
|
+
if (routeUsesJump(state.route) == true && answers.jumpHost == null) {
|
|
3098
|
+
answers.jumpHost = await prompter.askRequired("Jump host");
|
|
3099
|
+
}
|
|
3100
|
+
if (routeUsesProxy(state.route) == true && answers.proxyCommand == null) {
|
|
3101
|
+
answers.proxyCommand = await prompter.askRequired("Proxy command", "cloudflared access ssh --hostname %h");
|
|
3102
|
+
}
|
|
3103
|
+
return;
|
|
3104
|
+
}
|
|
3105
|
+
if (id === "jump-host") {
|
|
3106
|
+
answers.jumpHost = await prompter.askRequired("Jump host", answers.jumpHost);
|
|
3107
|
+
return;
|
|
3108
|
+
}
|
|
3109
|
+
if (id === "jump-port") {
|
|
3110
|
+
answers.jumpPort = await askPort(prompter, "Jump host port", answers.jumpPort ?? DEFAULT_SSH_PORT);
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
if (id === "jump-user") {
|
|
3114
|
+
answers.jumpUser = await askOptional(prompter, "Jump host user (empty = same as the login user)", answers.jumpUser);
|
|
3115
|
+
return;
|
|
3116
|
+
}
|
|
3117
|
+
if (id === "proxy") {
|
|
3118
|
+
output.write(prompter.paint.dim(" %h / %p / %r expand to the first hop's host, port and user.\n"));
|
|
3119
|
+
answers.proxyCommand = await prompter.askRequired("Proxy command", answers.proxyCommand ?? "cloudflared access ssh --hostname %h");
|
|
3120
|
+
return;
|
|
3121
|
+
}
|
|
3122
|
+
if (id === "description") {
|
|
3123
|
+
answers.description = await askOptional(prompter, "Description shown to the agent", answers.description);
|
|
3124
|
+
return;
|
|
3125
|
+
}
|
|
3126
|
+
if (id === "readonly") {
|
|
3127
|
+
answers.readonly = answers.readonly == false;
|
|
3128
|
+
return;
|
|
3129
|
+
}
|
|
3130
|
+
if (id === "paths") {
|
|
3131
|
+
let current;
|
|
3132
|
+
if (answers.allowedPaths.length > 0) {
|
|
3133
|
+
current = answers.allowedPaths.join(",");
|
|
3134
|
+
}
|
|
3135
|
+
const typed = await askOptional(prompter, "Limit file tools to path prefixes (comma-separated, empty = anywhere)", current);
|
|
3136
|
+
answers.allowedPaths = [];
|
|
3137
|
+
if (typed != null) {
|
|
3138
|
+
answers.allowedPaths = typed.split(",").map((item) => item.trim()).filter((item) => item !== "");
|
|
3139
|
+
}
|
|
3140
|
+
return;
|
|
3141
|
+
}
|
|
3142
|
+
const isJump = id === "jump-host-key";
|
|
3143
|
+
let pinned = answers.hostKeyFingerprint;
|
|
3144
|
+
if (isJump == true) {
|
|
3145
|
+
pinned = answers.jumpHostKeyFingerprint;
|
|
3146
|
+
}
|
|
3147
|
+
if (pinned != null) {
|
|
3148
|
+
const unpin = await prompter.askYesNo(`Unpin ${pinned}?`, false);
|
|
3149
|
+
if (unpin == true && isJump == true) {
|
|
3150
|
+
answers.jumpHostKeyFingerprint = void 0;
|
|
3151
|
+
} else if (unpin == true) {
|
|
3152
|
+
answers.hostKeyFingerprint = void 0;
|
|
3153
|
+
}
|
|
3154
|
+
return;
|
|
3155
|
+
}
|
|
3156
|
+
const missing = missingFields(state);
|
|
3157
|
+
if (missing.length > 0) {
|
|
3158
|
+
output.write(` Fill in ${missing.map((field) => FIELD_NAMES[field]).join(", ")} first \u2014 the key is pinned by testing the connection.
|
|
3159
|
+
`);
|
|
3160
|
+
return;
|
|
3161
|
+
}
|
|
3162
|
+
await testConnection(prompter, output, state);
|
|
3163
|
+
}
|
|
3164
|
+
async function runHostForm(prompter, output, options) {
|
|
3165
|
+
const { existing, takenNames, destination } = options;
|
|
3166
|
+
let state;
|
|
3167
|
+
if (existing != null) {
|
|
3168
|
+
let route = "direct";
|
|
3169
|
+
if (existing.jumpHost != null && existing.proxyCommand != null) {
|
|
3170
|
+
route = "jump-proxy";
|
|
3171
|
+
} else if (existing.jumpHost != null) {
|
|
3172
|
+
route = "jump";
|
|
3173
|
+
} else if (existing.proxyCommand != null) {
|
|
3174
|
+
route = "proxy";
|
|
3175
|
+
}
|
|
3176
|
+
state = { answers: { ...existing }, route, nameTouched: true, lastTest: "never", testedSnapshot: null };
|
|
3177
|
+
} else {
|
|
3178
|
+
const foundKeys = findPrivateKeys();
|
|
3179
|
+
state = {
|
|
3180
|
+
answers: {
|
|
3181
|
+
name: "",
|
|
3182
|
+
host: "",
|
|
3183
|
+
port: DEFAULT_SSH_PORT,
|
|
3184
|
+
username: "",
|
|
3185
|
+
auth: "key",
|
|
3186
|
+
keyPath: foundKeys[0]?.path,
|
|
3187
|
+
readonly: false,
|
|
3188
|
+
allowedPaths: [],
|
|
3189
|
+
extraEnv: {}
|
|
3190
|
+
},
|
|
3191
|
+
route: "direct",
|
|
3192
|
+
nameTouched: false,
|
|
3193
|
+
lastTest: "never",
|
|
3194
|
+
testedSnapshot: null
|
|
3195
|
+
};
|
|
3196
|
+
}
|
|
3197
|
+
const initialSnapshot = snapshotOf(state);
|
|
3198
|
+
const originalName = existing?.name;
|
|
3199
|
+
let title = "New host";
|
|
3200
|
+
if (existing != null) {
|
|
3201
|
+
title = `Editing ${existing.name}`;
|
|
3202
|
+
}
|
|
3203
|
+
output.write(prompter.paint.dim(`
|
|
3204
|
+
Pick a setting to change it. In a text field, Enter keeps the value shown and ${CLEAR_ANSWER} clears it.
|
|
3205
|
+
|
|
3206
|
+
`));
|
|
3207
|
+
const previousTransient = prompter.transient;
|
|
3208
|
+
prompter.transient = true;
|
|
3209
|
+
try {
|
|
3210
|
+
let cursor = "host";
|
|
3211
|
+
for (; ; ) {
|
|
3212
|
+
const picked = await prompter.select(title, buildRows(state, destination), { defaultValue: cursor, cancelValue: "cancel" });
|
|
3213
|
+
if (picked === "cancel") {
|
|
3214
|
+
if (snapshotOf(state) !== initialSnapshot) {
|
|
3215
|
+
const discard = await prompter.askYesNo("Discard the changes?", false);
|
|
3216
|
+
if (discard == false) {
|
|
3217
|
+
continue;
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
return null;
|
|
3221
|
+
}
|
|
3222
|
+
const missing = missingFields(state);
|
|
3223
|
+
if (picked === "test") {
|
|
3224
|
+
if (missing.length > 0) {
|
|
3225
|
+
output.write(` Fill in ${missing.map((field) => FIELD_NAMES[field]).join(", ")} first.
|
|
3226
|
+
`);
|
|
3227
|
+
cursor = missing[0];
|
|
3228
|
+
continue;
|
|
3229
|
+
}
|
|
3230
|
+
const connected = await testConnection(prompter, output, state);
|
|
3231
|
+
cursor = "test";
|
|
3232
|
+
if (connected == true) {
|
|
3233
|
+
cursor = "save";
|
|
3234
|
+
}
|
|
3235
|
+
continue;
|
|
3236
|
+
}
|
|
3237
|
+
if (picked === "save") {
|
|
3238
|
+
if (missing.length > 0) {
|
|
3239
|
+
output.write(` Fill in ${missing.map((field) => FIELD_NAMES[field]).join(", ")} first.
|
|
3240
|
+
`);
|
|
3241
|
+
cursor = missing[0];
|
|
3242
|
+
continue;
|
|
3243
|
+
}
|
|
3244
|
+
const name = state.answers.name;
|
|
3245
|
+
if (name !== originalName && takenNames.includes(name) == true) {
|
|
3246
|
+
const replace = await prompter.askYesNo(`"${name}" already exists in this file. Replace it?`, false);
|
|
3247
|
+
if (replace == false) {
|
|
3248
|
+
cursor = "name";
|
|
3249
|
+
continue;
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
if (snapshotOf(state) !== state.testedSnapshot) {
|
|
3253
|
+
const testFirst = await prompter.askYesNo("Test the connection before saving?", true);
|
|
3254
|
+
if (testFirst == true) {
|
|
3255
|
+
const connected = await testConnection(prompter, output, state);
|
|
3256
|
+
if (connected == false) {
|
|
3257
|
+
const next = await prompter.select(
|
|
3258
|
+
"The check failed. What now?",
|
|
3259
|
+
[
|
|
3260
|
+
{ value: "back", key: "back", label: "Back to the settings" },
|
|
3261
|
+
{ value: "save", key: "save", label: "Save anyway", hint: "the host may be unreachable from here" },
|
|
3262
|
+
{ value: "discard", key: "discard", label: "Discard", hint: "nothing is written" }
|
|
3263
|
+
],
|
|
3264
|
+
{ defaultValue: "back", cancelValue: "back" }
|
|
3265
|
+
);
|
|
3266
|
+
if (next === "discard") {
|
|
3267
|
+
return null;
|
|
3268
|
+
}
|
|
3269
|
+
if (next === "back") {
|
|
3270
|
+
cursor = "test";
|
|
3271
|
+
continue;
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
return finalizeAnswers(state);
|
|
3277
|
+
}
|
|
3278
|
+
await editField(prompter, output, state, picked);
|
|
3279
|
+
cursor = nextCursor(state, destination, picked);
|
|
3280
|
+
}
|
|
3281
|
+
} finally {
|
|
3282
|
+
prompter.transient = previousTransient;
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
2620
3285
|
|
|
2621
3286
|
// src/modules/manage/prompter.ts
|
|
2622
3287
|
var PromptClosedError = class extends Error {
|
|
@@ -2625,36 +3290,158 @@ var PromptClosedError = class extends Error {
|
|
|
2625
3290
|
this.name = "PromptClosedError";
|
|
2626
3291
|
}
|
|
2627
3292
|
};
|
|
3293
|
+
var ESC = "\x1B";
|
|
3294
|
+
var KEY_UP = [`${ESC}[A`, `${ESC}OA`, "k"];
|
|
3295
|
+
var KEY_DOWN = [`${ESC}[B`, `${ESC}OB`, "j"];
|
|
3296
|
+
var KEY_HOME = [`${ESC}[H`, `${ESC}OH`, `${ESC}[1~`];
|
|
3297
|
+
var KEY_END = [`${ESC}[F`, `${ESC}OF`, `${ESC}[4~`];
|
|
3298
|
+
var KEY_ENTER = ["\r", "\n"];
|
|
3299
|
+
var KEY_BACKSPACE = ["\x7F", "\b"];
|
|
3300
|
+
var KEY_CTRL_C = "";
|
|
3301
|
+
var KEY_CTRL_D = "";
|
|
3302
|
+
function splitKeys(chunk) {
|
|
3303
|
+
const keys = [];
|
|
3304
|
+
let index = 0;
|
|
3305
|
+
while (index < chunk.length) {
|
|
3306
|
+
const isSequence = chunk[index] === ESC && (chunk[index + 1] === "[" || chunk[index + 1] === "O");
|
|
3307
|
+
if (isSequence == true) {
|
|
3308
|
+
let end = index + 2;
|
|
3309
|
+
while (end < chunk.length && /[0-9;]/.test(chunk[end]) == true) {
|
|
3310
|
+
end += 1;
|
|
3311
|
+
}
|
|
3312
|
+
keys.push(chunk.slice(index, end + 1));
|
|
3313
|
+
index = end + 1;
|
|
3314
|
+
continue;
|
|
3315
|
+
}
|
|
3316
|
+
const codePoint = chunk.codePointAt(index) ?? 0;
|
|
3317
|
+
const character = String.fromCodePoint(codePoint);
|
|
3318
|
+
keys.push(character);
|
|
3319
|
+
index += character.length;
|
|
3320
|
+
}
|
|
3321
|
+
return keys;
|
|
3322
|
+
}
|
|
3323
|
+
function charWidth(codePoint) {
|
|
3324
|
+
if (codePoint < 4352) {
|
|
3325
|
+
return 1;
|
|
3326
|
+
}
|
|
3327
|
+
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;
|
|
3328
|
+
if (isWide == true) {
|
|
3329
|
+
return 2;
|
|
3330
|
+
}
|
|
3331
|
+
return 1;
|
|
3332
|
+
}
|
|
3333
|
+
function displayWidth(text) {
|
|
3334
|
+
let width = 0;
|
|
3335
|
+
for (const character of text) {
|
|
3336
|
+
width += charWidth(character.codePointAt(0) ?? 0);
|
|
3337
|
+
}
|
|
3338
|
+
return width;
|
|
3339
|
+
}
|
|
3340
|
+
function padToWidth(text, width) {
|
|
3341
|
+
const missing = width - displayWidth(text);
|
|
3342
|
+
if (missing <= 0) {
|
|
3343
|
+
return text;
|
|
3344
|
+
}
|
|
3345
|
+
return `${text}${" ".repeat(missing)}`;
|
|
3346
|
+
}
|
|
3347
|
+
function groupLabelWidths(items) {
|
|
3348
|
+
const widths = items.map(() => 0);
|
|
3349
|
+
let groupStart = 0;
|
|
3350
|
+
for (let index = 0; index <= items.length; index += 1) {
|
|
3351
|
+
const atBoundary = index === items.length || "value" in items[index] == false;
|
|
3352
|
+
if (atBoundary == false) {
|
|
3353
|
+
continue;
|
|
3354
|
+
}
|
|
3355
|
+
let groupWidth = 0;
|
|
3356
|
+
for (let member = groupStart; member < index; member += 1) {
|
|
3357
|
+
const item = items[member];
|
|
3358
|
+
groupWidth = Math.max(groupWidth, displayWidth(item.label));
|
|
3359
|
+
}
|
|
3360
|
+
for (let member = groupStart; member < index; member += 1) {
|
|
3361
|
+
widths[member] = groupWidth;
|
|
3362
|
+
}
|
|
3363
|
+
groupStart = index + 1;
|
|
3364
|
+
}
|
|
3365
|
+
return widths;
|
|
3366
|
+
}
|
|
3367
|
+
function supportsUnicode() {
|
|
3368
|
+
if (process.platform !== "win32") {
|
|
3369
|
+
return true;
|
|
3370
|
+
}
|
|
3371
|
+
return process.env.WT_SESSION != null || process.env.TERM_PROGRAM === "vscode" || process.env.TERMINAL_EMULATOR === "JetBrains-JediTerm" || process.env.ConEmuTask != null;
|
|
3372
|
+
}
|
|
3373
|
+
function createStyle(enabled) {
|
|
3374
|
+
const paint = (open, close) => {
|
|
3375
|
+
return (text) => {
|
|
3376
|
+
if (enabled == false || text === "") {
|
|
3377
|
+
return text;
|
|
3378
|
+
}
|
|
3379
|
+
return `${ESC}[${open}m${text}${ESC}[${close}m`;
|
|
3380
|
+
};
|
|
3381
|
+
};
|
|
3382
|
+
return {
|
|
3383
|
+
accent: paint(36, 39),
|
|
3384
|
+
success: paint(32, 39),
|
|
3385
|
+
dim: paint(2, 22),
|
|
3386
|
+
bold: paint(1, 22)
|
|
3387
|
+
};
|
|
3388
|
+
}
|
|
2628
3389
|
var Prompter = class {
|
|
2629
3390
|
constructor(input, output) {
|
|
2630
3391
|
this.input = input;
|
|
2631
3392
|
this.output = output;
|
|
2632
3393
|
this.lineBuffer = "";
|
|
2633
3394
|
this.pendingLines = [];
|
|
2634
|
-
this.
|
|
3395
|
+
this.lineWaiter = null;
|
|
2635
3396
|
this.inputEnded = false;
|
|
2636
|
-
|
|
2637
|
-
this.
|
|
3397
|
+
/** Set while a key-driven prompt (select, secret) owns the input. */
|
|
3398
|
+
this.keyHandler = null;
|
|
3399
|
+
/**
|
|
3400
|
+
* When true, an answered prompt leaves nothing behind on a TTY instead of its
|
|
3401
|
+
* `✔ question · answer` line. The settings form sets it: the form itself shows every
|
|
3402
|
+
* value, so it can redraw in place rather than scroll a trail of edits.
|
|
3403
|
+
*/
|
|
3404
|
+
this.transient = false;
|
|
3405
|
+
const ttyInput = input;
|
|
3406
|
+
const ttyOutput = output;
|
|
3407
|
+
this.interactive = ttyInput.isTTY == true && ttyInput.setRawMode != null && ttyOutput.isTTY == true;
|
|
3408
|
+
this.style = createStyle(this.interactive == true && process.env.NO_COLOR == null);
|
|
3409
|
+
if (supportsUnicode() == true) {
|
|
3410
|
+
this.symbols = { question: "?", pointer: "\u276F", tick: "\u2714", rule: "\u2500" };
|
|
3411
|
+
} else {
|
|
3412
|
+
this.symbols = { question: "?", pointer: ">", tick: "\u221A", rule: "-" };
|
|
3413
|
+
}
|
|
2638
3414
|
input.setEncoding("utf8");
|
|
2639
3415
|
input.on("data", (chunk) => {
|
|
2640
3416
|
this.onData(chunk);
|
|
2641
3417
|
});
|
|
2642
3418
|
input.on("end", () => {
|
|
2643
3419
|
this.inputEnded = true;
|
|
2644
|
-
if (this.
|
|
2645
|
-
const waiter = this.
|
|
2646
|
-
this.
|
|
3420
|
+
if (this.lineWaiter != null) {
|
|
3421
|
+
const waiter = this.lineWaiter;
|
|
3422
|
+
this.lineWaiter = null;
|
|
2647
3423
|
waiter(null);
|
|
2648
3424
|
}
|
|
3425
|
+
if (this.keyHandler != null) {
|
|
3426
|
+
this.keyHandler(KEY_CTRL_D);
|
|
3427
|
+
}
|
|
2649
3428
|
});
|
|
2650
3429
|
}
|
|
2651
3430
|
/** Stops reading so the process can exit once the command is done. */
|
|
2652
3431
|
close() {
|
|
3432
|
+
if (this.interactive == true) {
|
|
3433
|
+
this.input.setRawMode?.(false);
|
|
3434
|
+
this.output.write(`${ESC}[?25h`);
|
|
3435
|
+
}
|
|
2653
3436
|
this.input.pause();
|
|
2654
3437
|
}
|
|
2655
3438
|
write(text) {
|
|
2656
3439
|
this.output.write(text);
|
|
2657
3440
|
}
|
|
3441
|
+
/** Styling for callers that print around the prompts; plain text off a TTY. */
|
|
3442
|
+
get paint() {
|
|
3443
|
+
return this.style;
|
|
3444
|
+
}
|
|
2658
3445
|
/**
|
|
2659
3446
|
* Free-text prompt. Enter accepts `defaultValue` when one is shown.
|
|
2660
3447
|
*
|
|
@@ -2663,18 +3450,20 @@ var Prompter = class {
|
|
|
2663
3450
|
async ask(question, defaultValue) {
|
|
2664
3451
|
let suffix = "";
|
|
2665
3452
|
if (defaultValue != null && defaultValue !== "") {
|
|
2666
|
-
suffix = `
|
|
3453
|
+
suffix = ` ${this.style.dim(`(${defaultValue})`)}`;
|
|
2667
3454
|
}
|
|
2668
|
-
this.
|
|
3455
|
+
const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)}${suffix} `;
|
|
3456
|
+
this.output.write(prompt);
|
|
2669
3457
|
const line = await this.readLine();
|
|
2670
3458
|
if (line == null) {
|
|
2671
3459
|
throw new PromptClosedError();
|
|
2672
3460
|
}
|
|
2673
|
-
|
|
2674
|
-
if (
|
|
2675
|
-
|
|
3461
|
+
let answer = line.trim();
|
|
3462
|
+
if (answer === "" && defaultValue != null) {
|
|
3463
|
+
answer = defaultValue;
|
|
2676
3464
|
}
|
|
2677
|
-
|
|
3465
|
+
this.collapseAnswered(prompt, line, question, answer);
|
|
3466
|
+
return answer;
|
|
2678
3467
|
}
|
|
2679
3468
|
/** Like `ask`, re-asking until something non-empty is entered. */
|
|
2680
3469
|
async askRequired(question, defaultValue) {
|
|
@@ -2683,77 +3472,115 @@ var Prompter = class {
|
|
|
2683
3472
|
if (answer !== "") {
|
|
2684
3473
|
return answer;
|
|
2685
3474
|
}
|
|
2686
|
-
this.output.write("
|
|
3475
|
+
this.output.write(` ${this.style.dim("A value is required.")}
|
|
3476
|
+
`);
|
|
2687
3477
|
}
|
|
2688
3478
|
}
|
|
2689
3479
|
async askYesNo(question, defaultValue) {
|
|
2690
|
-
let hint = "
|
|
3480
|
+
let hint = "y/N";
|
|
2691
3481
|
if (defaultValue == true) {
|
|
2692
|
-
hint = "
|
|
3482
|
+
hint = "Y/n";
|
|
2693
3483
|
}
|
|
2694
3484
|
for (; ; ) {
|
|
2695
|
-
this.
|
|
3485
|
+
const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)} ${this.style.dim(`(${hint})`)} `;
|
|
3486
|
+
this.output.write(prompt);
|
|
2696
3487
|
const line = await this.readLine();
|
|
2697
3488
|
if (line == null) {
|
|
2698
3489
|
throw new PromptClosedError();
|
|
2699
3490
|
}
|
|
2700
3491
|
const normalized = line.trim().toLowerCase();
|
|
3492
|
+
let answer = null;
|
|
2701
3493
|
if (normalized === "") {
|
|
2702
|
-
|
|
3494
|
+
answer = defaultValue;
|
|
3495
|
+
} else if (normalized === "y" || normalized === "yes") {
|
|
3496
|
+
answer = true;
|
|
3497
|
+
} else if (normalized === "n" || normalized === "no") {
|
|
3498
|
+
answer = false;
|
|
2703
3499
|
}
|
|
2704
|
-
if (
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
3500
|
+
if (answer != null) {
|
|
3501
|
+
let shown = "no";
|
|
3502
|
+
if (answer == true) {
|
|
3503
|
+
shown = "yes";
|
|
3504
|
+
}
|
|
3505
|
+
this.collapseAnswered(prompt, line, question, shown);
|
|
3506
|
+
return answer;
|
|
2709
3507
|
}
|
|
2710
|
-
this.output.write("
|
|
3508
|
+
this.output.write(` ${this.style.dim("Answer y or n.")}
|
|
3509
|
+
`);
|
|
2711
3510
|
}
|
|
2712
3511
|
}
|
|
2713
|
-
/**
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
3512
|
+
/**
|
|
3513
|
+
* Picks one of `items`. On a TTY: ↑/↓ (or k/j) move, 1–9 jump, Enter picks, Esc returns
|
|
3514
|
+
* `cancelValue`. Off a TTY: a numbered list, answered by number, key or label.
|
|
3515
|
+
*
|
|
3516
|
+
* @throws {PromptClosedError} On Ctrl+C or when the input closes.
|
|
3517
|
+
*/
|
|
3518
|
+
async select(question, items, options) {
|
|
3519
|
+
const choices = items.filter((item) => "value" in item);
|
|
3520
|
+
if (choices.length === 0) {
|
|
3521
|
+
throw new Error("select needs at least one choice");
|
|
2720
3522
|
}
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
`);
|
|
3523
|
+
let startIndex = items.findIndex((item) => "value" in item && item.value === options?.defaultValue);
|
|
3524
|
+
if (startIndex < 0) {
|
|
3525
|
+
startIndex = items.findIndex((item) => "value" in item);
|
|
3526
|
+
}
|
|
3527
|
+
if (this.interactive == true) {
|
|
3528
|
+
const picked2 = await this.selectWithKeys(question, items, startIndex, options?.cancelValue);
|
|
3529
|
+
return picked2;
|
|
2729
3530
|
}
|
|
3531
|
+
const picked = await this.selectWithLines(question, items, startIndex);
|
|
3532
|
+
return picked;
|
|
2730
3533
|
}
|
|
2731
3534
|
/**
|
|
2732
3535
|
* Reads a value without echoing it on a TTY. Empty means "none", or "keep" when the
|
|
2733
3536
|
* caller says a value already exists.
|
|
3537
|
+
*
|
|
3538
|
+
* @throws {PromptClosedError} On Ctrl+C or when the input closes.
|
|
2734
3539
|
*/
|
|
2735
3540
|
async askSecret(question) {
|
|
2736
|
-
|
|
2737
|
-
if (rawModeStream.isTTY != true || rawModeStream.setRawMode == null) {
|
|
3541
|
+
if (this.interactive == false) {
|
|
2738
3542
|
const answer = await this.ask(question);
|
|
2739
3543
|
return answer;
|
|
2740
3544
|
}
|
|
2741
|
-
this.
|
|
2742
|
-
this.
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
const
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
3545
|
+
const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)} `;
|
|
3546
|
+
this.output.write(prompt);
|
|
3547
|
+
const ttyInput = this.input;
|
|
3548
|
+
return new Promise((resolve, reject) => {
|
|
3549
|
+
let buffer = "";
|
|
3550
|
+
const finish = () => {
|
|
3551
|
+
this.keyHandler = null;
|
|
3552
|
+
ttyInput.setRawMode?.(false);
|
|
3553
|
+
this.output.write(`\r${ESC}[2K`);
|
|
3554
|
+
};
|
|
3555
|
+
this.keyHandler = (key) => {
|
|
3556
|
+
if (key === KEY_CTRL_C || key === KEY_CTRL_D) {
|
|
3557
|
+
finish();
|
|
3558
|
+
reject(new PromptClosedError());
|
|
3559
|
+
return;
|
|
3560
|
+
}
|
|
3561
|
+
if (KEY_ENTER.includes(key) == true) {
|
|
3562
|
+
finish();
|
|
3563
|
+
let shown = this.style.dim("(empty)");
|
|
3564
|
+
if (buffer !== "") {
|
|
3565
|
+
shown = "\u2022".repeat(Math.min(buffer.length, 8));
|
|
3566
|
+
}
|
|
3567
|
+
this.writeAnswered(question, shown);
|
|
3568
|
+
resolve(buffer);
|
|
3569
|
+
return;
|
|
3570
|
+
}
|
|
3571
|
+
if (KEY_BACKSPACE.includes(key) == true) {
|
|
3572
|
+
buffer = Array.from(buffer).slice(0, -1).join("");
|
|
3573
|
+
return;
|
|
3574
|
+
}
|
|
3575
|
+
if (key.startsWith(ESC) == true) {
|
|
3576
|
+
return;
|
|
3577
|
+
}
|
|
3578
|
+
buffer += key;
|
|
3579
|
+
};
|
|
3580
|
+
ttyInput.setRawMode?.(true);
|
|
3581
|
+
});
|
|
2756
3582
|
}
|
|
3583
|
+
// ── line mode ──────────────────────────────────────────────────────────
|
|
2757
3584
|
readLine() {
|
|
2758
3585
|
const queued = this.pendingLines.shift();
|
|
2759
3586
|
if (queued != null) {
|
|
@@ -2763,36 +3590,16 @@ var Prompter = class {
|
|
|
2763
3590
|
return Promise.resolve(null);
|
|
2764
3591
|
}
|
|
2765
3592
|
return new Promise((resolve) => {
|
|
2766
|
-
this.
|
|
3593
|
+
this.lineWaiter = resolve;
|
|
2767
3594
|
});
|
|
2768
3595
|
}
|
|
2769
|
-
deliver(line) {
|
|
2770
|
-
if (this.waiter != null) {
|
|
2771
|
-
const waiter = this.waiter;
|
|
2772
|
-
this.waiter = null;
|
|
2773
|
-
waiter(line);
|
|
2774
|
-
return;
|
|
2775
|
-
}
|
|
2776
|
-
this.pendingLines.push(line);
|
|
2777
|
-
}
|
|
2778
3596
|
onData(chunk) {
|
|
2779
|
-
if (this.
|
|
2780
|
-
for (const
|
|
2781
|
-
if (
|
|
2782
|
-
|
|
2783
|
-
this.secretBuffer = "";
|
|
2784
|
-
this.deliver(secret);
|
|
2785
|
-
continue;
|
|
2786
|
-
}
|
|
2787
|
-
if (character === "") {
|
|
2788
|
-
this.output.write("\n");
|
|
2789
|
-
process.exit(130);
|
|
2790
|
-
}
|
|
2791
|
-
if (character === "\x7F" || character === "\b") {
|
|
2792
|
-
this.secretBuffer = this.secretBuffer.slice(0, -1);
|
|
2793
|
-
continue;
|
|
3597
|
+
if (this.keyHandler != null) {
|
|
3598
|
+
for (const key of splitKeys(chunk)) {
|
|
3599
|
+
if (this.keyHandler == null) {
|
|
3600
|
+
break;
|
|
2794
3601
|
}
|
|
2795
|
-
this.
|
|
3602
|
+
this.keyHandler(key);
|
|
2796
3603
|
}
|
|
2797
3604
|
return;
|
|
2798
3605
|
}
|
|
@@ -2801,160 +3608,234 @@ var Prompter = class {
|
|
|
2801
3608
|
while (newlineIndex >= 0) {
|
|
2802
3609
|
const line = this.lineBuffer.slice(0, newlineIndex).replace(/\r$/, "");
|
|
2803
3610
|
this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1);
|
|
2804
|
-
this.
|
|
3611
|
+
if (this.lineWaiter != null) {
|
|
3612
|
+
const waiter = this.lineWaiter;
|
|
3613
|
+
this.lineWaiter = null;
|
|
3614
|
+
waiter(line);
|
|
3615
|
+
} else {
|
|
3616
|
+
this.pendingLines.push(line);
|
|
3617
|
+
}
|
|
2805
3618
|
newlineIndex = this.lineBuffer.indexOf("\n");
|
|
2806
3619
|
}
|
|
2807
3620
|
}
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
return answer;
|
|
2827
|
-
}
|
|
2828
|
-
async function askOptionalSecret(prompter, question, current) {
|
|
2829
|
-
let suffix = " (empty for none)";
|
|
2830
|
-
if (current != null) {
|
|
2831
|
-
suffix = ` [keep current, ${CLEAR_ANSWER} to clear]`;
|
|
2832
|
-
}
|
|
2833
|
-
const answer = await prompter.askSecret(`${question}${suffix}`);
|
|
2834
|
-
if (answer === CLEAR_ANSWER) {
|
|
2835
|
-
return void 0;
|
|
2836
|
-
}
|
|
2837
|
-
if (answer === "") {
|
|
2838
|
-
return current;
|
|
2839
|
-
}
|
|
2840
|
-
return answer;
|
|
2841
|
-
}
|
|
2842
|
-
async function askHostQuestions(prompter, existing) {
|
|
2843
|
-
if (existing != null) {
|
|
2844
|
-
prompter.write(` Enter keeps the value in brackets; ${CLEAR_ANSWER} clears an optional one.
|
|
2845
|
-
|
|
3621
|
+
async selectWithLines(question, items, startIndex) {
|
|
3622
|
+
const choices = items.filter((item) => "value" in item);
|
|
3623
|
+
const labelWidths = groupLabelWidths(items);
|
|
3624
|
+
this.output.write(`${this.symbols.question} ${question}
|
|
3625
|
+
`);
|
|
3626
|
+
let number = 0;
|
|
3627
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
3628
|
+
const item = items[index];
|
|
3629
|
+
if ("value" in item == false) {
|
|
3630
|
+
this.output.write("\n");
|
|
3631
|
+
continue;
|
|
3632
|
+
}
|
|
3633
|
+
number += 1;
|
|
3634
|
+
let line = ` ${String(number).padStart(2)}) ${padToWidth(item.label, labelWidths[index])}`;
|
|
3635
|
+
if (item.hint != null && item.hint !== "") {
|
|
3636
|
+
line += ` ${item.hint}`;
|
|
3637
|
+
}
|
|
3638
|
+
this.output.write(`${line.trimEnd()}
|
|
2846
3639
|
`);
|
|
2847
|
-
}
|
|
2848
|
-
const host = await prompter.askRequired("Host (hostname or IP)", existing?.host);
|
|
2849
|
-
const port = await askPort(prompter, "Port", existing?.port ?? DEFAULT_SSH_PORT);
|
|
2850
|
-
const username = await prompter.askRequired("Login user", existing?.username);
|
|
2851
|
-
let name = "";
|
|
2852
|
-
const defaultName = existing?.name ?? `ssh-${host}`.replace(/[^A-Za-z0-9_-]+/g, "-").slice(0, 48);
|
|
2853
|
-
for (; ; ) {
|
|
2854
|
-
name = await prompter.askRequired("Server name the agent sees", defaultName);
|
|
2855
|
-
if (SERVER_NAME_PATTERN.test(name) == true) {
|
|
2856
|
-
break;
|
|
2857
|
-
}
|
|
2858
|
-
prompter.write(" Use letters, digits, '-' and '_' only (max 48) \u2014 the name becomes part of every tool name.\n");
|
|
2859
|
-
}
|
|
2860
|
-
const auth = await prompter.askChoice(
|
|
2861
|
-
"Authentication",
|
|
2862
|
-
[
|
|
2863
|
-
{ key: "key", label: "private key file (recommended \u2014 only the path is stored)" },
|
|
2864
|
-
{ key: "password", label: "password (stored in the config file in plain text)" },
|
|
2865
|
-
{ key: "agent", label: "ssh agent (SSH_AUTH_SOCK / OpenSSH agent pipe)" }
|
|
2866
|
-
],
|
|
2867
|
-
existing?.auth ?? "key"
|
|
2868
|
-
);
|
|
2869
|
-
let keyPath;
|
|
2870
|
-
let passphrase;
|
|
2871
|
-
let password;
|
|
2872
|
-
if (auth === "key") {
|
|
2873
|
-
let defaultKeyPath = existing?.keyPath;
|
|
2874
|
-
if (defaultKeyPath == null) {
|
|
2875
|
-
defaultKeyPath = DEFAULT_KEY_CANDIDATES.find((candidate) => fs7.existsSync(expandHomePath(candidate)) == true);
|
|
2876
3640
|
}
|
|
3641
|
+
const defaultChoice = items[startIndex];
|
|
3642
|
+
const defaultNumber = choices.indexOf(defaultChoice) + 1;
|
|
2877
3643
|
for (; ; ) {
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
3644
|
+
this.output.write(`Choice (${defaultNumber}): `);
|
|
3645
|
+
const line = await this.readLine();
|
|
3646
|
+
if (line == null) {
|
|
3647
|
+
throw new PromptClosedError();
|
|
3648
|
+
}
|
|
3649
|
+
const answer = line.trim();
|
|
3650
|
+
if (answer === "") {
|
|
3651
|
+
return defaultChoice.value;
|
|
3652
|
+
}
|
|
3653
|
+
const index = Number(answer);
|
|
3654
|
+
if (Number.isInteger(index) == true && index >= 1 && index <= choices.length) {
|
|
3655
|
+
return choices[index - 1].value;
|
|
3656
|
+
}
|
|
3657
|
+
const lowered = answer.toLowerCase();
|
|
3658
|
+
const matched = choices.find((choice) => {
|
|
3659
|
+
const key = choice.key ?? choice.value;
|
|
3660
|
+
return key.toLowerCase() === lowered || choice.label.toLowerCase() === lowered;
|
|
3661
|
+
});
|
|
3662
|
+
if (matched != null) {
|
|
3663
|
+
return matched.value;
|
|
2881
3664
|
}
|
|
2882
|
-
|
|
3665
|
+
this.output.write(` Pick a number from 1 to ${choices.length}.
|
|
2883
3666
|
`);
|
|
2884
|
-
|
|
2885
|
-
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3669
|
+
// ── TTY rendering ──────────────────────────────────────────────────────
|
|
3670
|
+
columns() {
|
|
3671
|
+
const columns = this.output.columns;
|
|
3672
|
+
if (columns == null || columns < 20) {
|
|
3673
|
+
return 80;
|
|
3674
|
+
}
|
|
3675
|
+
return columns;
|
|
3676
|
+
}
|
|
3677
|
+
/** Joins styled parts, cutting the text (not the styles) once `width` columns are used. */
|
|
3678
|
+
fitLine(parts, width) {
|
|
3679
|
+
let used = 0;
|
|
3680
|
+
let line = "";
|
|
3681
|
+
for (const part of parts) {
|
|
3682
|
+
let text = "";
|
|
3683
|
+
for (const character of part.text) {
|
|
3684
|
+
const characterWidth = charWidth(character.codePointAt(0) ?? 0);
|
|
3685
|
+
if (used + characterWidth > width) {
|
|
3686
|
+
break;
|
|
3687
|
+
}
|
|
3688
|
+
used += characterWidth;
|
|
3689
|
+
text += character;
|
|
3690
|
+
}
|
|
3691
|
+
if (part.paint != null) {
|
|
3692
|
+
line += part.paint(text);
|
|
3693
|
+
} else {
|
|
3694
|
+
line += text;
|
|
3695
|
+
}
|
|
3696
|
+
if (used >= width) {
|
|
2886
3697
|
break;
|
|
2887
3698
|
}
|
|
2888
3699
|
}
|
|
2889
|
-
|
|
3700
|
+
return line;
|
|
2890
3701
|
}
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
prompter.write(" No password given \u2014 falling back to the ssh agent.\n");
|
|
3702
|
+
/** The one-line record an answered prompt leaves behind on a TTY. */
|
|
3703
|
+
writeAnswered(question, answer) {
|
|
3704
|
+
if (this.transient == true) {
|
|
3705
|
+
return;
|
|
2896
3706
|
}
|
|
3707
|
+
this.output.write(`${this.style.success(this.symbols.tick)} ${question} ${this.style.dim("\xB7")} ${this.style.accent(answer)}
|
|
3708
|
+
`);
|
|
2897
3709
|
}
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
};
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
3710
|
+
/**
|
|
3711
|
+
* Replaces a cooked-mode prompt and its echoed answer with the one-line record. The
|
|
3712
|
+
* rows are worked out from the widths, since a long answer wraps.
|
|
3713
|
+
*/
|
|
3714
|
+
collapseAnswered(prompt, typed, question, answer) {
|
|
3715
|
+
if (this.interactive == false) {
|
|
3716
|
+
return;
|
|
3717
|
+
}
|
|
3718
|
+
const plainPrompt = prompt.replace(/\u001b\[[0-9;]*m/g, "");
|
|
3719
|
+
const used = displayWidth(plainPrompt) + displayWidth(typed);
|
|
3720
|
+
const rows = Math.max(1, Math.ceil(used / this.columns()));
|
|
3721
|
+
this.output.write(`${ESC}[${rows}A\r${ESC}[J`);
|
|
3722
|
+
let shown = answer;
|
|
3723
|
+
if (shown === "") {
|
|
3724
|
+
shown = this.style.dim("(empty)");
|
|
3725
|
+
}
|
|
3726
|
+
this.writeAnswered(question, shown);
|
|
3727
|
+
}
|
|
3728
|
+
selectWithKeys(question, items, startIndex, cancelValue) {
|
|
3729
|
+
const ttyInput = this.input;
|
|
3730
|
+
const selectableIndexes = items.flatMap((item, index) => {
|
|
3731
|
+
if ("value" in item) {
|
|
3732
|
+
return [index];
|
|
3733
|
+
}
|
|
3734
|
+
return [];
|
|
3735
|
+
});
|
|
3736
|
+
const labelWidths = groupLabelWidths(items);
|
|
3737
|
+
const ruleWidth = Math.min(Math.max(...labelWidths), 24);
|
|
3738
|
+
let active = startIndex;
|
|
3739
|
+
let renderedLines = 0;
|
|
3740
|
+
const draw = (lines) => {
|
|
3741
|
+
let text = "";
|
|
3742
|
+
if (renderedLines > 1) {
|
|
3743
|
+
text += `${ESC}[${renderedLines - 1}A`;
|
|
3744
|
+
}
|
|
3745
|
+
text += `\r${ESC}[J${lines.join("\n")}`;
|
|
3746
|
+
this.output.write(text);
|
|
3747
|
+
renderedLines = lines.length;
|
|
3748
|
+
};
|
|
3749
|
+
const buildLines = () => {
|
|
3750
|
+
const width = this.columns() - 1;
|
|
3751
|
+
const lines = [
|
|
3752
|
+
this.fitLine([
|
|
3753
|
+
{ text: this.symbols.question, paint: this.style.accent },
|
|
3754
|
+
{ text: " " },
|
|
3755
|
+
{ text: question, paint: this.style.bold }
|
|
3756
|
+
], width)
|
|
3757
|
+
];
|
|
3758
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
3759
|
+
const item = items[index];
|
|
3760
|
+
if ("value" in item == false) {
|
|
3761
|
+
lines.push(this.fitLine([{ text: ` ${this.symbols.rule.repeat(ruleWidth)}`, paint: this.style.dim }], width));
|
|
3762
|
+
continue;
|
|
3763
|
+
}
|
|
3764
|
+
const isActive = index === active;
|
|
3765
|
+
const parts = [];
|
|
3766
|
+
if (isActive == true) {
|
|
3767
|
+
parts.push({ text: `${this.symbols.pointer} `, paint: this.style.accent });
|
|
3768
|
+
parts.push({ text: padToWidth(item.label, labelWidths[index]), paint: this.style.accent });
|
|
3769
|
+
} else {
|
|
3770
|
+
parts.push({ text: " " });
|
|
3771
|
+
parts.push({ text: padToWidth(item.label, labelWidths[index]) });
|
|
3772
|
+
}
|
|
3773
|
+
if (item.hint != null && item.hint !== "") {
|
|
3774
|
+
parts.push({ text: ` ${item.hint}`, paint: this.style.dim });
|
|
3775
|
+
}
|
|
3776
|
+
lines.push(this.fitLine(parts, width));
|
|
3777
|
+
}
|
|
3778
|
+
let footer = "\u2191\u2193 move \xB7 enter select";
|
|
3779
|
+
if (cancelValue != null) {
|
|
3780
|
+
footer += " \xB7 esc back";
|
|
3781
|
+
}
|
|
3782
|
+
lines.push(this.fitLine([{ text: footer, paint: this.style.dim }], width));
|
|
3783
|
+
return lines;
|
|
3784
|
+
};
|
|
3785
|
+
const move = (step) => {
|
|
3786
|
+
const position = selectableIndexes.indexOf(active);
|
|
3787
|
+
const next = (position + step + selectableIndexes.length) % selectableIndexes.length;
|
|
3788
|
+
active = selectableIndexes[next];
|
|
3789
|
+
};
|
|
3790
|
+
return new Promise((resolve, reject) => {
|
|
3791
|
+
const finish = () => {
|
|
3792
|
+
this.keyHandler = null;
|
|
3793
|
+
ttyInput.setRawMode?.(false);
|
|
3794
|
+
draw([]);
|
|
3795
|
+
this.output.write(`${ESC}[?25h`);
|
|
3796
|
+
};
|
|
3797
|
+
this.keyHandler = (key) => {
|
|
3798
|
+
if (key === KEY_CTRL_C || key === KEY_CTRL_D) {
|
|
3799
|
+
finish();
|
|
3800
|
+
reject(new PromptClosedError());
|
|
3801
|
+
return;
|
|
3802
|
+
}
|
|
3803
|
+
if (KEY_ENTER.includes(key) == true) {
|
|
3804
|
+
const picked = items[active];
|
|
3805
|
+
finish();
|
|
3806
|
+
this.writeAnswered(question, picked.label);
|
|
3807
|
+
resolve(picked.value);
|
|
3808
|
+
return;
|
|
3809
|
+
}
|
|
3810
|
+
if (key === ESC) {
|
|
3811
|
+
if (cancelValue == null) {
|
|
3812
|
+
return;
|
|
3813
|
+
}
|
|
3814
|
+
finish();
|
|
3815
|
+
resolve(cancelValue);
|
|
3816
|
+
return;
|
|
3817
|
+
}
|
|
3818
|
+
if (KEY_UP.includes(key) == true) {
|
|
3819
|
+
move(-1);
|
|
3820
|
+
} else if (KEY_DOWN.includes(key) == true) {
|
|
3821
|
+
move(1);
|
|
3822
|
+
} else if (KEY_HOME.includes(key) == true) {
|
|
3823
|
+
active = selectableIndexes[0];
|
|
3824
|
+
} else if (KEY_END.includes(key) == true) {
|
|
3825
|
+
active = selectableIndexes[selectableIndexes.length - 1];
|
|
3826
|
+
} else if (/^[1-9]$/.test(key) == true && Number(key) <= selectableIndexes.length) {
|
|
3827
|
+
active = selectableIndexes[Number(key) - 1];
|
|
3828
|
+
} else {
|
|
3829
|
+
return;
|
|
3830
|
+
}
|
|
3831
|
+
draw(buildLines());
|
|
3832
|
+
};
|
|
3833
|
+
ttyInput.setRawMode?.(true);
|
|
3834
|
+
this.output.write(`${ESC}[?25l`);
|
|
3835
|
+
draw(buildLines());
|
|
3836
|
+
});
|
|
2955
3837
|
}
|
|
2956
|
-
|
|
2957
|
-
}
|
|
3838
|
+
};
|
|
2958
3839
|
function toMcpServerEntry(value) {
|
|
2959
3840
|
if (typeof value !== "object" || value == null) {
|
|
2960
3841
|
return null;
|
|
@@ -2995,10 +3876,10 @@ function readMcpServersJson(document) {
|
|
|
2995
3876
|
return entries;
|
|
2996
3877
|
}
|
|
2997
3878
|
function readDocument(filePath) {
|
|
2998
|
-
if (
|
|
3879
|
+
if (fs6.existsSync(filePath) == false) {
|
|
2999
3880
|
return {};
|
|
3000
3881
|
}
|
|
3001
|
-
const text =
|
|
3882
|
+
const text = fs6.readFileSync(filePath, "utf8");
|
|
3002
3883
|
if (text.trim() === "") {
|
|
3003
3884
|
return {};
|
|
3004
3885
|
}
|
|
@@ -3020,15 +3901,15 @@ function readDocument(filePath) {
|
|
|
3020
3901
|
function createJsonFileTarget(options) {
|
|
3021
3902
|
const { id, label, filePath } = options;
|
|
3022
3903
|
function writeDocument(document) {
|
|
3023
|
-
|
|
3024
|
-
|
|
3904
|
+
fs6.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
3905
|
+
fs6.writeFileSync(filePath, `${JSON.stringify(document, null, 4)}
|
|
3025
3906
|
`, "utf8");
|
|
3026
3907
|
}
|
|
3027
3908
|
return {
|
|
3028
3909
|
id,
|
|
3029
3910
|
label,
|
|
3030
3911
|
location: filePath,
|
|
3031
|
-
detected:
|
|
3912
|
+
detected: fs6.existsSync(filePath),
|
|
3032
3913
|
readEntries() {
|
|
3033
3914
|
const document = readDocument(filePath);
|
|
3034
3915
|
return readMcpServersJson(document);
|
|
@@ -3058,7 +3939,7 @@ function createJsonFileTarget(options) {
|
|
|
3058
3939
|
}
|
|
3059
3940
|
|
|
3060
3941
|
// src/modules/manage/targets/claude-code.ts
|
|
3061
|
-
var CLAUDE_CODE_CONFIG_PATH = path.join(
|
|
3942
|
+
var CLAUDE_CODE_CONFIG_PATH = path.join(os5.homedir(), ".claude.json");
|
|
3062
3943
|
var CMD_META_CHARACTER_PATTERN = /([()\][%!^"`<>&|;, *?])/g;
|
|
3063
3944
|
function toClaudeAddJsonPayload(entry) {
|
|
3064
3945
|
return JSON.stringify({ type: "stdio", command: entry.command, args: entry.args, env: entry.env });
|
|
@@ -3104,10 +3985,10 @@ function runClaude(executable, args) {
|
|
|
3104
3985
|
return { status: "written", detail: CLAUDE_CODE_CONFIG_PATH };
|
|
3105
3986
|
}
|
|
3106
3987
|
function readClaudeEntries() {
|
|
3107
|
-
if (
|
|
3988
|
+
if (fs6.existsSync(CLAUDE_CODE_CONFIG_PATH) == false) {
|
|
3108
3989
|
return {};
|
|
3109
3990
|
}
|
|
3110
|
-
const text =
|
|
3991
|
+
const text = fs6.readFileSync(CLAUDE_CODE_CONFIG_PATH, "utf8");
|
|
3111
3992
|
let document;
|
|
3112
3993
|
try {
|
|
3113
3994
|
document = JSON.parse(text);
|
|
@@ -3126,7 +4007,7 @@ function createClaudeCodeTarget() {
|
|
|
3126
4007
|
id: "claude-code",
|
|
3127
4008
|
label: "Claude Code (user scope)",
|
|
3128
4009
|
location: CLAUDE_CODE_CONFIG_PATH,
|
|
3129
|
-
detected: executable != null ||
|
|
4010
|
+
detected: executable != null || fs6.existsSync(CLAUDE_CODE_CONFIG_PATH) == true,
|
|
3130
4011
|
readEntries: readClaudeEntries,
|
|
3131
4012
|
writeEntry(name, entry) {
|
|
3132
4013
|
if (executable == null) {
|
|
@@ -3578,24 +4459,24 @@ function writeMcpServerToml(text, name, entry) {
|
|
|
3578
4459
|
}
|
|
3579
4460
|
|
|
3580
4461
|
// src/modules/manage/targets/codex.ts
|
|
3581
|
-
var CODEX_CONFIG_PATH = path.join(
|
|
4462
|
+
var CODEX_CONFIG_PATH = path.join(os5.homedir(), ".codex", "config.toml");
|
|
3582
4463
|
function createCodexTarget() {
|
|
3583
4464
|
function readText() {
|
|
3584
|
-
if (
|
|
4465
|
+
if (fs6.existsSync(CODEX_CONFIG_PATH) == false) {
|
|
3585
4466
|
return "";
|
|
3586
4467
|
}
|
|
3587
|
-
return
|
|
4468
|
+
return fs6.readFileSync(CODEX_CONFIG_PATH, "utf8");
|
|
3588
4469
|
}
|
|
3589
4470
|
function writeText(text) {
|
|
3590
|
-
|
|
3591
|
-
|
|
4471
|
+
fs6.mkdirSync(path.dirname(CODEX_CONFIG_PATH), { recursive: true });
|
|
4472
|
+
fs6.writeFileSync(CODEX_CONFIG_PATH, text, "utf8");
|
|
3592
4473
|
return { status: "written", detail: CODEX_CONFIG_PATH };
|
|
3593
4474
|
}
|
|
3594
4475
|
return {
|
|
3595
4476
|
id: "codex",
|
|
3596
4477
|
label: "Codex CLI",
|
|
3597
4478
|
location: CODEX_CONFIG_PATH,
|
|
3598
|
-
detected: findExecutable("codex") != null ||
|
|
4479
|
+
detected: findExecutable("codex") != null || fs6.existsSync(CODEX_CONFIG_PATH) == true,
|
|
3599
4480
|
readEntries() {
|
|
3600
4481
|
const text = readText();
|
|
3601
4482
|
return parseMcpServersToml(text);
|
|
@@ -3616,20 +4497,20 @@ function createCodexTarget() {
|
|
|
3616
4497
|
// src/modules/manage/targets/index.ts
|
|
3617
4498
|
function claudeDesktopConfigPath() {
|
|
3618
4499
|
if (process.platform === "win32") {
|
|
3619
|
-
const appData = process.env.APPDATA ?? path.join(
|
|
4500
|
+
const appData = process.env.APPDATA ?? path.join(os5.homedir(), "AppData", "Roaming");
|
|
3620
4501
|
return path.join(appData, "Claude", "claude_desktop_config.json");
|
|
3621
4502
|
}
|
|
3622
4503
|
if (process.platform === "darwin") {
|
|
3623
|
-
return path.join(
|
|
4504
|
+
return path.join(os5.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
3624
4505
|
}
|
|
3625
|
-
return path.join(
|
|
4506
|
+
return path.join(os5.homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
3626
4507
|
}
|
|
3627
4508
|
function listConfigTargets() {
|
|
3628
4509
|
const targets = [
|
|
3629
4510
|
createClaudeCodeTarget(),
|
|
3630
4511
|
createCodexTarget(),
|
|
3631
4512
|
createJsonFileTarget({ id: "claude-desktop", label: "Claude Desktop", filePath: claudeDesktopConfigPath() }),
|
|
3632
|
-
createJsonFileTarget({ id: "cursor", label: "Cursor", filePath: path.join(
|
|
4513
|
+
createJsonFileTarget({ id: "cursor", label: "Cursor", filePath: path.join(os5.homedir(), ".cursor", "mcp.json") })
|
|
3633
4514
|
];
|
|
3634
4515
|
const detected = targets.filter((target) => target.detected == true);
|
|
3635
4516
|
const others = targets.filter((target) => target.detected == false);
|
|
@@ -3643,6 +4524,7 @@ var SECRET_ENV_NAMES = [
|
|
|
3643
4524
|
SINGLE_HOST_ENV.JUMP_PASSWORD,
|
|
3644
4525
|
SINGLE_HOST_ENV.JUMP_PASSPHRASE
|
|
3645
4526
|
];
|
|
4527
|
+
var HOST_VALUE_PREFIX = "host:";
|
|
3646
4528
|
function errorMessage(error) {
|
|
3647
4529
|
if (error instanceof Error) {
|
|
3648
4530
|
return error.message;
|
|
@@ -3670,125 +4552,34 @@ function formatForTarget(target, name, entry) {
|
|
|
3670
4552
|
function indent(text) {
|
|
3671
4553
|
return text.replace(/\n$/, "").split("\n").map((line) => ` ${line}`).join("\n");
|
|
3672
4554
|
}
|
|
3673
|
-
function reportWrite(
|
|
4555
|
+
function reportWrite(prompter, result, action) {
|
|
3674
4556
|
if (result.status === "written") {
|
|
3675
|
-
|
|
4557
|
+
prompter.write(` ${prompter.paint.success(action)} ${prompter.paint.dim(result.detail)}
|
|
3676
4558
|
`);
|
|
3677
|
-
|
|
4559
|
+
prompter.write(prompter.paint.dim(" Restart the client (or reconnect it from its MCP menu) to pick up the change.\n\n"));
|
|
3678
4560
|
return;
|
|
3679
4561
|
}
|
|
3680
|
-
|
|
4562
|
+
prompter.write(` Not ${action.toLowerCase()}: ${result.detail}
|
|
3681
4563
|
|
|
3682
4564
|
`);
|
|
3683
4565
|
}
|
|
3684
|
-
function profileFromEntry(output, entry) {
|
|
3685
|
-
try {
|
|
3686
|
-
const profile = loadHostProfile(entry.env);
|
|
3687
|
-
if (profile == null) {
|
|
3688
|
-
output.write(" The entry has no host.\n");
|
|
3689
|
-
}
|
|
3690
|
-
return profile;
|
|
3691
|
-
} catch (ex) {
|
|
3692
|
-
output.write(` The server would refuse to start with this entry: ${errorMessage(ex)}
|
|
3693
|
-
`);
|
|
3694
|
-
return null;
|
|
3695
|
-
}
|
|
3696
|
-
}
|
|
3697
|
-
async function verifyAnswers(prompter, output, initial) {
|
|
3698
|
-
let answers = initial;
|
|
3699
|
-
for (; ; ) {
|
|
3700
|
-
const testNow = await prompter.askYesNo("Test the connection now?", true);
|
|
3701
|
-
if (testNow == false) {
|
|
3702
|
-
return answers;
|
|
3703
|
-
}
|
|
3704
|
-
output.write("\n");
|
|
3705
|
-
const profile = profileFromEntry(output, answersToEntry(answers));
|
|
3706
|
-
if (profile != null) {
|
|
3707
|
-
const result = await runConnectionCheck(profile, output);
|
|
3708
|
-
output.write("\n");
|
|
3709
|
-
if (result.connected == true) {
|
|
3710
|
-
if (result.hostKeyFingerprint != null && answers.hostKeyFingerprint == null) {
|
|
3711
|
-
const pin = await prompter.askYesNo(`Pin the host key ${result.hostKeyFingerprint}? (a changed key then aborts the connection)`, true);
|
|
3712
|
-
if (pin == true) {
|
|
3713
|
-
answers = { ...answers, hostKeyFingerprint: result.hostKeyFingerprint };
|
|
3714
|
-
}
|
|
3715
|
-
}
|
|
3716
|
-
if (result.jumpHostKeyFingerprint != null && answers.jumpHost != null && answers.jumpHostKeyFingerprint == null) {
|
|
3717
|
-
const pin = await prompter.askYesNo(`Pin the jump host key ${result.jumpHostKeyFingerprint}?`, true);
|
|
3718
|
-
if (pin == true) {
|
|
3719
|
-
answers = { ...answers, jumpHostKeyFingerprint: result.jumpHostKeyFingerprint };
|
|
3720
|
-
}
|
|
3721
|
-
}
|
|
3722
|
-
return answers;
|
|
3723
|
-
}
|
|
3724
|
-
const hostKeyChanged = result.hostKeyFingerprint != null && answers.hostKeyFingerprint != null && result.hostKeyFingerprint !== answers.hostKeyFingerprint;
|
|
3725
|
-
if (hostKeyChanged == true) {
|
|
3726
|
-
output.write(" The host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
|
|
3727
|
-
const repin = await prompter.askYesNo(`Pin ${result.hostKeyFingerprint} instead?`, false);
|
|
3728
|
-
if (repin == true) {
|
|
3729
|
-
answers = { ...answers, hostKeyFingerprint: result.hostKeyFingerprint ?? void 0 };
|
|
3730
|
-
continue;
|
|
3731
|
-
}
|
|
3732
|
-
}
|
|
3733
|
-
const jumpKeyChanged = result.jumpHostKeyFingerprint != null && answers.jumpHostKeyFingerprint != null && result.jumpHostKeyFingerprint !== answers.jumpHostKeyFingerprint;
|
|
3734
|
-
if (jumpKeyChanged == true) {
|
|
3735
|
-
output.write(" The jump host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
|
|
3736
|
-
const repin = await prompter.askYesNo(`Pin ${result.jumpHostKeyFingerprint} instead?`, false);
|
|
3737
|
-
if (repin == true) {
|
|
3738
|
-
answers = { ...answers, jumpHostKeyFingerprint: result.jumpHostKeyFingerprint ?? void 0 };
|
|
3739
|
-
continue;
|
|
3740
|
-
}
|
|
3741
|
-
}
|
|
3742
|
-
}
|
|
3743
|
-
const next = await prompter.askChoice(
|
|
3744
|
-
"What now?",
|
|
3745
|
-
[
|
|
3746
|
-
{ key: "r", label: "retry the check" },
|
|
3747
|
-
{ key: "e", label: "edit the answers" },
|
|
3748
|
-
{ key: "w", label: "write it anyway" },
|
|
3749
|
-
{ key: "c", label: "cancel" }
|
|
3750
|
-
],
|
|
3751
|
-
"e"
|
|
3752
|
-
);
|
|
3753
|
-
if (next === "c") {
|
|
3754
|
-
return null;
|
|
3755
|
-
}
|
|
3756
|
-
if (next === "w") {
|
|
3757
|
-
return answers;
|
|
3758
|
-
}
|
|
3759
|
-
if (next === "e") {
|
|
3760
|
-
output.write("\n");
|
|
3761
|
-
answers = await askHostQuestions(prompter, answers);
|
|
3762
|
-
}
|
|
3763
|
-
}
|
|
3764
|
-
}
|
|
3765
4566
|
async function addOrEditHost(prompter, output, target, entries, existingName) {
|
|
3766
4567
|
let existing;
|
|
3767
4568
|
if (existingName != null) {
|
|
3768
4569
|
existing = entryToAnswers(existingName, entries[existingName]);
|
|
3769
|
-
output.write(`
|
|
3770
|
-
Editing ${existingName}
|
|
3771
|
-
`);
|
|
3772
|
-
} else {
|
|
3773
|
-
output.write("\nAdding a host\n");
|
|
3774
4570
|
}
|
|
3775
|
-
const
|
|
3776
|
-
|
|
3777
|
-
|
|
4571
|
+
const answers = await runHostForm(prompter, output, {
|
|
4572
|
+
existing,
|
|
4573
|
+
takenNames: Object.keys(entries),
|
|
4574
|
+
destination: target.label
|
|
4575
|
+
});
|
|
3778
4576
|
if (answers == null) {
|
|
3779
4577
|
output.write(" Cancelled \u2014 nothing was written.\n\n");
|
|
3780
4578
|
return;
|
|
3781
4579
|
}
|
|
3782
|
-
if (answers.name !== existingName && entries[answers.name] != null) {
|
|
3783
|
-
const replace = await prompter.askYesNo(`"${answers.name}" already exists in this file. Replace it?`, false);
|
|
3784
|
-
if (replace == false) {
|
|
3785
|
-
output.write(" Cancelled \u2014 nothing was written.\n\n");
|
|
3786
|
-
return;
|
|
3787
|
-
}
|
|
3788
|
-
}
|
|
3789
4580
|
const entry = answersToEntry(answers);
|
|
3790
4581
|
output.write(`
|
|
3791
|
-
This goes into ${target.location}
|
|
4582
|
+
${prompter.paint.dim(`This goes into ${target.location}:`)}
|
|
3792
4583
|
|
|
3793
4584
|
`);
|
|
3794
4585
|
output.write(`${indent(formatForTarget(target, answers.name, maskSecrets(entry)))}
|
|
@@ -3796,11 +4587,11 @@ Editing ${existingName}
|
|
|
3796
4587
|
`);
|
|
3797
4588
|
const confirmed = await prompter.askYesNo("Write it?", true);
|
|
3798
4589
|
if (confirmed == false) {
|
|
3799
|
-
output.write(" Not written.
|
|
4590
|
+
output.write(" Not written. Pick the host and choose Print to get the entry ready to paste.\n\n");
|
|
3800
4591
|
return;
|
|
3801
4592
|
}
|
|
3802
4593
|
const written = target.writeEntry(answers.name, entry);
|
|
3803
|
-
reportWrite(
|
|
4594
|
+
reportWrite(prompter, written, "Written");
|
|
3804
4595
|
if (written.status === "written" && existingName != null && existingName !== answers.name) {
|
|
3805
4596
|
const removed = target.removeEntry(existingName);
|
|
3806
4597
|
if (removed.status === "unavailable") {
|
|
@@ -3812,7 +4603,7 @@ Editing ${existingName}
|
|
|
3812
4603
|
}
|
|
3813
4604
|
async function checkHost(prompter, output, target, name, entry) {
|
|
3814
4605
|
output.write("\n");
|
|
3815
|
-
const profile =
|
|
4606
|
+
const profile = profileFromEnv(output, entry.env);
|
|
3816
4607
|
if (profile == null) {
|
|
3817
4608
|
output.write("\n");
|
|
3818
4609
|
return;
|
|
@@ -3825,14 +4616,14 @@ async function checkHost(prompter, output, target, name, entry) {
|
|
|
3825
4616
|
const env = { ...entry.env };
|
|
3826
4617
|
let changed = false;
|
|
3827
4618
|
if (result.hostKeyFingerprint != null && env[SINGLE_HOST_ENV.HOST_KEY] == null) {
|
|
3828
|
-
const pin = await prompter.askYesNo(
|
|
4619
|
+
const pin = await prompter.askYesNo("Pin this host key?", true);
|
|
3829
4620
|
if (pin == true) {
|
|
3830
4621
|
env[SINGLE_HOST_ENV.HOST_KEY] = result.hostKeyFingerprint;
|
|
3831
4622
|
changed = true;
|
|
3832
4623
|
}
|
|
3833
4624
|
}
|
|
3834
4625
|
if (result.jumpHostKeyFingerprint != null && env[SINGLE_HOST_ENV.JUMP_HOST_KEY] == null) {
|
|
3835
|
-
const pin = await prompter.askYesNo(
|
|
4626
|
+
const pin = await prompter.askYesNo("Pin the jump host key too?", true);
|
|
3836
4627
|
if (pin == true) {
|
|
3837
4628
|
env[SINGLE_HOST_ENV.JUMP_HOST_KEY] = result.jumpHostKeyFingerprint;
|
|
3838
4629
|
changed = true;
|
|
@@ -3840,76 +4631,79 @@ async function checkHost(prompter, output, target, name, entry) {
|
|
|
3840
4631
|
}
|
|
3841
4632
|
if (changed == true) {
|
|
3842
4633
|
const written = target.writeEntry(name, { ...entry, env });
|
|
3843
|
-
reportWrite(
|
|
4634
|
+
reportWrite(prompter, written, "Pinned");
|
|
3844
4635
|
}
|
|
3845
4636
|
}
|
|
3846
|
-
function printHost(
|
|
3847
|
-
|
|
3848
|
-
|
|
4637
|
+
function printHost(prompter, name, entry) {
|
|
4638
|
+
prompter.write(`
|
|
4639
|
+
${prompter.paint.dim('JSON \u2014 Claude Desktop, Cursor, .mcp.json (inside "mcpServers"):')}
|
|
3849
4640
|
|
|
3850
4641
|
`);
|
|
3851
|
-
|
|
3852
|
-
output.write(`${indent(renderMcpServerTable(name, entry))}
|
|
4642
|
+
prompter.write(`${indent(`"${name}": ${JSON.stringify(entry, null, 4)}`)}
|
|
3853
4643
|
|
|
3854
4644
|
`);
|
|
3855
|
-
|
|
3856
|
-
output.write(`${indent(formatClaudeAddCommand(name, entry))}
|
|
4645
|
+
prompter.write(` ${prompter.paint.dim("TOML \u2014 Codex (~/.codex/config.toml):")}
|
|
3857
4646
|
|
|
3858
4647
|
`);
|
|
3859
|
-
}
|
|
3860
|
-
|
|
3861
|
-
if (names.length === 0) {
|
|
3862
|
-
output.write(" No hosts yet \u2014 add one with a.\n\n");
|
|
3863
|
-
return null;
|
|
3864
|
-
}
|
|
3865
|
-
if (names.length === 1) {
|
|
3866
|
-
return names[0];
|
|
3867
|
-
}
|
|
3868
|
-
for (; ; ) {
|
|
3869
|
-
const answer = await prompter.ask("Which host (number or name, empty to go back)");
|
|
3870
|
-
if (answer === "") {
|
|
3871
|
-
return null;
|
|
3872
|
-
}
|
|
3873
|
-
const index = Number(answer);
|
|
3874
|
-
if (Number.isInteger(index) == true && index >= 1 && index <= names.length) {
|
|
3875
|
-
return names[index - 1];
|
|
3876
|
-
}
|
|
3877
|
-
if (names.includes(answer) == true) {
|
|
3878
|
-
return answer;
|
|
3879
|
-
}
|
|
3880
|
-
output.write(` No host "${answer}".
|
|
4648
|
+
prompter.write(`${indent(renderMcpServerTable(name, entry))}
|
|
4649
|
+
|
|
3881
4650
|
`);
|
|
3882
|
-
}
|
|
3883
|
-
}
|
|
3884
|
-
function renderHostList(output, target, entries) {
|
|
3885
|
-
const names = Object.keys(entries).filter((name) => isOurEntry(entries[name]) == true);
|
|
3886
|
-
const otherCount = Object.keys(entries).length - names.length;
|
|
3887
|
-
output.write(`
|
|
3888
|
-
${target.label} \u2014 ${target.location}
|
|
4651
|
+
prompter.write(` ${prompter.paint.dim("Claude Code CLI (bash / zsh / Git Bash):")}
|
|
3889
4652
|
|
|
3890
4653
|
`);
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
} else {
|
|
3894
|
-
const rows = names.map((name, index) => {
|
|
3895
|
-
const summary = summarizeEntry(entries[name]);
|
|
3896
|
-
return [String(index + 1), name, summary.endpoint, summary.auth, summary.notes];
|
|
3897
|
-
});
|
|
3898
|
-
const header = ["#", "Server", "Endpoint", "Auth", "Notes"];
|
|
3899
|
-
const widths = header.map((title, column) => Math.max(title.length, ...rows.map((row) => row[column].length)));
|
|
3900
|
-
for (const row of [header, ...rows]) {
|
|
3901
|
-
const line = row.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd();
|
|
3902
|
-
output.write(` ${line}
|
|
4654
|
+
prompter.write(`${indent(formatClaudeAddCommand(name, entry))}
|
|
4655
|
+
|
|
3903
4656
|
`);
|
|
4657
|
+
}
|
|
4658
|
+
function buildHostItems(names, entries) {
|
|
4659
|
+
const nameWidth = Math.max(...names.map((name) => displayWidth(name)));
|
|
4660
|
+
const summaries = names.map((name) => summarizeEntry(entries[name]));
|
|
4661
|
+
const endpointWidth = Math.max(...summaries.map((summary) => displayWidth(summary.endpoint)));
|
|
4662
|
+
return names.map((name, index) => {
|
|
4663
|
+
const summary = summaries[index];
|
|
4664
|
+
const hintParts = [summary.auth];
|
|
4665
|
+
if (summary.notes !== "") {
|
|
4666
|
+
hintParts.push(summary.notes);
|
|
3904
4667
|
}
|
|
4668
|
+
return {
|
|
4669
|
+
value: `${HOST_VALUE_PREFIX}${name}`,
|
|
4670
|
+
key: name,
|
|
4671
|
+
label: `${name.padEnd(nameWidth)} ${summary.endpoint.padEnd(endpointWidth)}`,
|
|
4672
|
+
hint: hintParts.join(" \xB7 ")
|
|
4673
|
+
};
|
|
4674
|
+
});
|
|
4675
|
+
}
|
|
4676
|
+
async function manageHost(prompter, output, target, entries, name) {
|
|
4677
|
+
const action = await prompter.select(
|
|
4678
|
+
name,
|
|
4679
|
+
[
|
|
4680
|
+
{ value: "edit", key: "e", label: "Edit", hint: "open its settings" },
|
|
4681
|
+
{ value: "check", key: "c", label: "Check the connection", hint: "connect now and offer to pin the host key" },
|
|
4682
|
+
{ value: "print", key: "p", label: "Print the entry", hint: "JSON, Codex TOML and a claude command, ready to paste" },
|
|
4683
|
+
{ value: "remove", key: "r", label: "Remove", hint: `from ${target.label}` },
|
|
4684
|
+
{ separator: true },
|
|
4685
|
+
{ value: "back", key: "b", label: "Back" }
|
|
4686
|
+
],
|
|
4687
|
+
{ defaultValue: "edit", cancelValue: "back" }
|
|
4688
|
+
);
|
|
4689
|
+
if (action === "edit") {
|
|
4690
|
+
await addOrEditHost(prompter, output, target, entries, name);
|
|
4691
|
+
return;
|
|
3905
4692
|
}
|
|
3906
|
-
if (
|
|
3907
|
-
output
|
|
3908
|
-
|
|
3909
|
-
|
|
4693
|
+
if (action === "check") {
|
|
4694
|
+
await checkHost(prompter, output, target, name, entries[name]);
|
|
4695
|
+
return;
|
|
4696
|
+
}
|
|
4697
|
+
if (action === "print") {
|
|
4698
|
+
printHost(prompter, name, entries[name]);
|
|
4699
|
+
return;
|
|
4700
|
+
}
|
|
4701
|
+
if (action === "remove") {
|
|
4702
|
+
const remove = await prompter.askYesNo(`Remove ${name} from ${target.location}?`, false);
|
|
4703
|
+
if (remove == true) {
|
|
4704
|
+
reportWrite(prompter, target.removeEntry(name), "Removed");
|
|
4705
|
+
}
|
|
3910
4706
|
}
|
|
3911
|
-
output.write("\n");
|
|
3912
|
-
return names;
|
|
3913
4707
|
}
|
|
3914
4708
|
async function runConfigCommand(options) {
|
|
3915
4709
|
const { input, output } = options;
|
|
@@ -3925,81 +4719,88 @@ async function runConfigCommand(options) {
|
|
|
3925
4719
|
target = requested;
|
|
3926
4720
|
}
|
|
3927
4721
|
const prompter = new Prompter(input, output);
|
|
4722
|
+
const paint = prompter.paint;
|
|
3928
4723
|
try {
|
|
3929
4724
|
for (; ; ) {
|
|
3930
4725
|
let entries = {};
|
|
3931
4726
|
let names = [];
|
|
3932
|
-
let
|
|
4727
|
+
let readError = null;
|
|
3933
4728
|
try {
|
|
3934
4729
|
entries = target.readEntries();
|
|
3935
|
-
names =
|
|
4730
|
+
names = Object.keys(entries).filter((name) => isOurEntry(entries[name]) == true);
|
|
3936
4731
|
} catch (ex) {
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
4732
|
+
readError = errorMessage(ex);
|
|
4733
|
+
}
|
|
4734
|
+
output.write(`
|
|
4735
|
+
${paint.bold(target.label)} ${paint.dim(target.location)}
|
|
4736
|
+
`);
|
|
4737
|
+
if (readError != null) {
|
|
4738
|
+
output.write(` Cannot read it: ${readError}
|
|
3942
4739
|
Fix the file by hand, or switch to another client.
|
|
3943
|
-
|
|
3944
4740
|
`);
|
|
4741
|
+
} else {
|
|
4742
|
+
const otherCount = Object.keys(entries).length - names.length;
|
|
4743
|
+
if (otherCount > 0) {
|
|
4744
|
+
output.write(paint.dim(` ${otherCount} other MCP server(s) in this file are not managed here.
|
|
4745
|
+
`));
|
|
4746
|
+
}
|
|
4747
|
+
}
|
|
4748
|
+
output.write("\n");
|
|
4749
|
+
const items = [];
|
|
4750
|
+
if (names.length > 0) {
|
|
4751
|
+
items.push(...buildHostItems(names, entries));
|
|
4752
|
+
items.push({ separator: true });
|
|
3945
4753
|
}
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
menu = " a) add e) edit r) remove c) check p) print t) switch client q) quit";
|
|
3949
|
-
} else if (readable == true) {
|
|
3950
|
-
menu = " a) add t) switch client q) quit";
|
|
4754
|
+
if (readError == null) {
|
|
4755
|
+
items.push({ value: "add", key: "a", label: "Add a host" });
|
|
3951
4756
|
}
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
4757
|
+
const otherTargets = targets.filter((candidate) => candidate.id !== target.id);
|
|
4758
|
+
if (otherTargets.length > 0) {
|
|
4759
|
+
const detectedOthers = otherTargets.filter((candidate) => candidate.detected == true).map((candidate) => candidate.label);
|
|
4760
|
+
let switchHint = "";
|
|
4761
|
+
if (detectedOthers.length > 0) {
|
|
4762
|
+
switchHint = `also found: ${detectedOthers.join(", ")}`;
|
|
4763
|
+
}
|
|
4764
|
+
items.push({ value: "switch", key: "t", label: "Switch client", hint: switchHint });
|
|
4765
|
+
}
|
|
4766
|
+
items.push({ value: "quit", key: "q", label: "Quit" });
|
|
4767
|
+
let question = "No SSH hosts here yet";
|
|
4768
|
+
if (names.length === 1) {
|
|
4769
|
+
question = "1 SSH host \u2014 pick it to manage it";
|
|
4770
|
+
} else if (names.length > 1) {
|
|
4771
|
+
question = `${names.length} SSH hosts \u2014 pick one to manage it`;
|
|
4772
|
+
}
|
|
4773
|
+
let defaultValue = "add";
|
|
4774
|
+
if (names.length > 0) {
|
|
4775
|
+
defaultValue = `${HOST_VALUE_PREFIX}${names[0]}`;
|
|
4776
|
+
}
|
|
4777
|
+
const picked = await prompter.select(question, items, { defaultValue, cancelValue: "quit" });
|
|
4778
|
+
if (picked === "quit") {
|
|
3956
4779
|
return 0;
|
|
3957
4780
|
}
|
|
3958
|
-
if (
|
|
3959
|
-
const
|
|
4781
|
+
if (picked === "switch") {
|
|
4782
|
+
const switched = await prompter.select(
|
|
3960
4783
|
"Which client?",
|
|
3961
4784
|
targets.map((candidate) => {
|
|
3962
|
-
let
|
|
3963
|
-
if (candidate.detected ==
|
|
3964
|
-
|
|
4785
|
+
let hint = candidate.location;
|
|
4786
|
+
if (candidate.detected == false) {
|
|
4787
|
+
hint += " \xB7 not found on this machine";
|
|
3965
4788
|
}
|
|
3966
|
-
return {
|
|
4789
|
+
return { value: candidate.id, label: candidate.label, hint };
|
|
3967
4790
|
}),
|
|
3968
|
-
target.id
|
|
4791
|
+
{ defaultValue: target.id, cancelValue: target.id }
|
|
3969
4792
|
);
|
|
3970
|
-
target = targets.find((candidate) => candidate.id ===
|
|
3971
|
-
continue;
|
|
3972
|
-
}
|
|
3973
|
-
if (readable == false) {
|
|
3974
|
-
output.write(" Not available while the file cannot be read.\n");
|
|
4793
|
+
target = targets.find((candidate) => candidate.id === switched) ?? target;
|
|
3975
4794
|
continue;
|
|
3976
4795
|
}
|
|
3977
4796
|
try {
|
|
3978
|
-
if (
|
|
4797
|
+
if (picked === "add") {
|
|
3979
4798
|
await addOrEditHost(prompter, output, target, entries);
|
|
3980
4799
|
continue;
|
|
3981
4800
|
}
|
|
3982
|
-
if (
|
|
3983
|
-
|
|
3984
|
-
if (name == null) {
|
|
3985
|
-
continue;
|
|
3986
|
-
}
|
|
3987
|
-
if (action === "e") {
|
|
3988
|
-
await addOrEditHost(prompter, output, target, entries, name);
|
|
3989
|
-
} else if (action === "r") {
|
|
3990
|
-
const remove = await prompter.askYesNo(`Remove ${name} from ${target.location}?`, false);
|
|
3991
|
-
if (remove == true) {
|
|
3992
|
-
reportWrite(output, target.removeEntry(name), "Removed");
|
|
3993
|
-
}
|
|
3994
|
-
} else if (action === "c") {
|
|
3995
|
-
await checkHost(prompter, output, target, name, entries[name]);
|
|
3996
|
-
} else {
|
|
3997
|
-
printHost(output, name, entries[name]);
|
|
3998
|
-
}
|
|
3999
|
-
continue;
|
|
4801
|
+
if (picked.startsWith(HOST_VALUE_PREFIX) == true) {
|
|
4802
|
+
await manageHost(prompter, output, target, entries, picked.slice(HOST_VALUE_PREFIX.length));
|
|
4000
4803
|
}
|
|
4001
|
-
output.write(` Unknown choice "${action}".
|
|
4002
|
-
`);
|
|
4003
4804
|
} catch (ex) {
|
|
4004
4805
|
if (ex instanceof PromptClosedError) {
|
|
4005
4806
|
throw ex;
|
|
@@ -4448,7 +5249,7 @@ ${file.content}`);
|
|
|
4448
5249
|
const { profile, session: openSession } = resolveProfile(context, { session });
|
|
4449
5250
|
const resolvedLocalPath = expandHomePath(localPath);
|
|
4450
5251
|
requirePermittedLocalPath(resolvedLocalPath, "read");
|
|
4451
|
-
if (
|
|
5252
|
+
if (fs6.existsSync(resolvedLocalPath) == false) {
|
|
4452
5253
|
return textResult(`local file not found: ${resolvedLocalPath}`);
|
|
4453
5254
|
}
|
|
4454
5255
|
const transferredBytes = await withHostConnection(profile, openSession, async (connection) => {
|
|
@@ -4488,7 +5289,7 @@ ${file.content}`);
|
|
|
4488
5289
|
const { profile, session: openSession } = resolveProfile(context, { session });
|
|
4489
5290
|
const resolvedLocalPath = expandHomePath(localPath);
|
|
4490
5291
|
requirePermittedLocalPath(resolvedLocalPath, "write");
|
|
4491
|
-
|
|
5292
|
+
fs6.mkdirSync(path.dirname(resolvedLocalPath), { recursive: true });
|
|
4492
5293
|
const transferredBytes = await withHostConnection(profile, openSession, async (connection) => {
|
|
4493
5294
|
return await downloadFile(connection, remotePath, resolvedLocalPath);
|
|
4494
5295
|
});
|