@akms/mcp-ssh 0.1.2 → 0.1.3

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/lib/cli.js CHANGED
@@ -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.2";
29
+ var SERVER_VERSION = "0.1.3";
30
30
 
31
31
  // src/_defs/command-rules.ts
32
32
  var CRITICAL_PATH_PATTERN = new RegExp(
@@ -2634,1208 +2634,1306 @@ function summarizeEntry(entry) {
2634
2634
  notes: notes.join(", ")
2635
2635
  };
2636
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"
2637
+
2638
+ // src/modules/manage/prompter.ts
2639
+ var PromptClosedError = class extends Error {
2640
+ constructor() {
2641
+ super("input closed before the prompt was answered");
2642
+ this.name = "PromptClosedError";
2643
+ }
2674
2644
  };
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 [];
2645
+ var PromptBackError = class extends Error {
2646
+ constructor() {
2647
+ super("prompt left with esc");
2648
+ this.name = "PromptBackError";
2683
2649
  }
2650
+ };
2651
+ var ESC = "\x1B";
2652
+ var KEY_UP = [`${ESC}[A`, `${ESC}OA`];
2653
+ var KEY_DOWN = [`${ESC}[B`, `${ESC}OB`];
2654
+ var KEY_RIGHT = [`${ESC}[C`, `${ESC}OC`];
2655
+ var KEY_LEFT = [`${ESC}[D`, `${ESC}OD`];
2656
+ var KEY_HOME = [`${ESC}[H`, `${ESC}OH`, `${ESC}[1~`, ""];
2657
+ var KEY_END = [`${ESC}[F`, `${ESC}OF`, `${ESC}[4~`, ""];
2658
+ var KEY_DELETE = [`${ESC}[3~`];
2659
+ var KEY_ENTER = ["\r", "\n"];
2660
+ var KEY_BACKSPACE = ["\x7F", "\b"];
2661
+ var KEY_CTRL_C = "";
2662
+ var KEY_CTRL_D = "";
2663
+ var KEY_CLEAR_LINE = "";
2664
+ function splitKeys(chunk) {
2684
2665
  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;
2666
+ let index = 0;
2667
+ while (index < chunk.length) {
2668
+ const isSequence = chunk[index] === ESC && (chunk[index + 1] === "[" || chunk[index + 1] === "O");
2669
+ if (isSequence == true) {
2670
+ let end = index + 2;
2671
+ while (end < chunk.length && /[0-9;]/.test(chunk[end]) == true) {
2672
+ end += 1;
2695
2673
  }
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) {
2674
+ keys.push(chunk.slice(index, end + 1));
2675
+ index = end + 1;
2707
2676
  continue;
2708
2677
  }
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 });
2678
+ const codePoint = chunk.codePointAt(index) ?? 0;
2679
+ const character = String.fromCodePoint(codePoint);
2680
+ keys.push(character);
2681
+ index += character.length;
2719
2682
  }
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));
2683
+ return keys;
2728
2684
  }
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");
2685
+ function isPrintableKey(key) {
2686
+ if (key.startsWith(ESC) == true) {
2687
+ return false;
2737
2688
  }
2689
+ const codePoint = key.codePointAt(0) ?? 0;
2690
+ return codePoint >= 32 && codePoint !== 127;
2738
2691
  }
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;
2692
+ function charWidth(codePoint) {
2693
+ if (codePoint < 4352) {
2694
+ return 1;
2743
2695
  }
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)`;
2696
+ 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;
2697
+ if (isWide == true) {
2698
+ return 2;
2750
2699
  }
2751
- const answer = await prompter.askSecret(`${question} ${prompter.paint.dim(suffix)}`);
2752
- if (answer === CLEAR_ANSWER) {
2753
- return void 0;
2700
+ return 1;
2701
+ }
2702
+ function displayWidth(text) {
2703
+ let width = 0;
2704
+ for (const character of text) {
2705
+ width += charWidth(character.codePointAt(0) ?? 0);
2754
2706
  }
2755
- if (answer === "") {
2756
- return current;
2707
+ return width;
2708
+ }
2709
+ function padToWidth(text, width) {
2710
+ const missing = width - displayWidth(text);
2711
+ if (missing <= 0) {
2712
+ return text;
2757
2713
  }
2758
- return answer;
2714
+ return `${text}${" ".repeat(missing)}`;
2759
2715
  }
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 });
2716
+ function groupLabelWidths(items) {
2717
+ const widths = items.map(() => 0);
2718
+ let groupStart = 0;
2719
+ for (let index = 0; index <= items.length; index += 1) {
2720
+ const atBoundary = index === items.length || "value" in items[index] == false;
2721
+ if (atBoundary == false) {
2722
+ continue;
2770
2723
  }
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;
2724
+ let groupWidth = 0;
2725
+ for (let member = groupStart; member < index; member += 1) {
2726
+ const item = items[member];
2727
+ groupWidth = Math.max(groupWidth, displayWidth(item.label));
2782
2728
  }
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;
2729
+ for (let member = groupStart; member < index; member += 1) {
2730
+ widths[member] = groupWidth;
2788
2731
  }
2732
+ groupStart = index + 1;
2789
2733
  }
2734
+ return widths;
2790
2735
  }
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;
2736
+ function supportsUnicode() {
2737
+ if (process.platform !== "win32") {
2738
+ return true;
2817
2739
  }
2818
- return answers;
2740
+ return process.env.WT_SESSION != null || process.env.TERM_PROGRAM === "vscode" || process.env.TERMINAL_EMULATOR === "JetBrains-JediTerm" || process.env.ConEmuTask != null;
2819
2741
  }
2820
- function snapshotOf(state) {
2821
- return JSON.stringify(finalizeAnswers(state));
2742
+ function createStyle(enabled) {
2743
+ const paint = (open, close) => {
2744
+ return (text) => {
2745
+ if (enabled == false || text === "") {
2746
+ return text;
2747
+ }
2748
+ return `${ESC}[${open}m${text}${ESC}[${close}m`;
2749
+ };
2750
+ };
2751
+ return {
2752
+ accent: paint(36, 39),
2753
+ success: paint(32, 39),
2754
+ dim: paint(2, 22),
2755
+ bold: paint(1, 22)
2756
+ };
2822
2757
  }
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");
2758
+ var Prompter = class {
2759
+ constructor(input, output) {
2760
+ this.input = input;
2761
+ this.output = output;
2762
+ this.lineBuffer = "";
2763
+ this.pendingLines = [];
2764
+ this.lineWaiter = null;
2765
+ this.inputEnded = false;
2766
+ /** Set while a key-driven prompt owns the input. */
2767
+ this.keyHandler = null;
2768
+ /**
2769
+ * When true, an answered prompt leaves nothing behind on a TTY instead of its
2770
+ * `✔ question · answer` line. The settings form sets it: the form itself shows every
2771
+ * value, so it can redraw in place rather than scroll a trail of edits.
2772
+ */
2773
+ this.transient = false;
2774
+ const ttyInput = input;
2775
+ const ttyOutput = output;
2776
+ this.interactive = ttyInput.isTTY == true && ttyInput.setRawMode != null && ttyOutput.isTTY == true;
2777
+ this.style = createStyle(this.interactive == true && process.env.NO_COLOR == null);
2778
+ if (supportsUnicode() == true) {
2779
+ this.symbols = { question: "?", pointer: "\u276F", tick: "\u2714", rule: "\u2500" };
2780
+ } else {
2781
+ this.symbols = { question: "?", pointer: ">", tick: "\u221A", rule: "-" };
2782
+ }
2783
+ input.setEncoding("utf8");
2784
+ input.on("data", (chunk) => {
2785
+ this.onData(chunk);
2786
+ });
2787
+ input.on("end", () => {
2788
+ this.inputEnded = true;
2789
+ if (this.lineWaiter != null) {
2790
+ const waiter = this.lineWaiter;
2791
+ this.lineWaiter = null;
2792
+ waiter(null);
2793
+ }
2794
+ if (this.keyHandler != null) {
2795
+ this.keyHandler(KEY_CTRL_D);
2796
+ }
2797
+ });
2834
2798
  }
2835
- if (answers.auth === "password" && answers.password == null) {
2836
- missing.push("password");
2799
+ /** Stops reading so the process can exit once the command is done. */
2800
+ close() {
2801
+ if (this.interactive == true) {
2802
+ this.input.setRawMode?.(false);
2803
+ this.output.write(`${ESC}[?25h`);
2804
+ }
2805
+ this.input.pause();
2837
2806
  }
2838
- if (routeUsesJump(state.route) == true && answers.jumpHost == null) {
2839
- missing.push("jump-host");
2807
+ write(text) {
2808
+ this.output.write(text);
2840
2809
  }
2841
- if (routeUsesProxy(state.route) == true && answers.proxyCommand == null) {
2842
- missing.push("proxy");
2810
+ /** Styling for callers that print around the prompts; plain text off a TTY. */
2811
+ get paint() {
2812
+ return this.style;
2843
2813
  }
2844
- return missing;
2845
- }
2846
- function describePin(fingerprint) {
2847
- if (fingerprint == null) {
2848
- return "not pinned";
2814
+ /**
2815
+ * Free-text prompt. Enter accepts `defaultValue` when one is shown.
2816
+ *
2817
+ * @throws {PromptBackError} On Esc.
2818
+ * @throws {PromptClosedError} If the input closes before a line arrives.
2819
+ */
2820
+ async ask(question, defaultValue) {
2821
+ let suffix = "";
2822
+ if (defaultValue != null && defaultValue !== "") {
2823
+ suffix = ` ${this.style.dim(`(${defaultValue})`)}`;
2824
+ }
2825
+ const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)}${suffix} `;
2826
+ const typed = await this.readText(prompt, false);
2827
+ let answer = typed.trim();
2828
+ if (answer === "" && defaultValue != null) {
2829
+ answer = defaultValue;
2830
+ }
2831
+ let shown = answer;
2832
+ if (shown === "") {
2833
+ shown = this.style.dim("(empty)");
2834
+ }
2835
+ this.writeAnswered(question, shown);
2836
+ return answer;
2849
2837
  }
2850
- return `pinned \xB7 ${fingerprint.slice(0, 19)}\u2026`;
2851
- }
2852
- function valueOrRequired(value) {
2853
- if (value == null || value === "") {
2854
- return REQUIRED_MARK;
2838
+ /** Like `ask`, re-asking until something non-empty is entered. */
2839
+ async askRequired(question, defaultValue) {
2840
+ for (; ; ) {
2841
+ const answer = await this.ask(question, defaultValue);
2842
+ if (answer !== "") {
2843
+ return answer;
2844
+ }
2845
+ this.output.write(` ${this.style.dim("A value is required.")}
2846
+ `);
2847
+ }
2855
2848
  }
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}`;
2849
+ /**
2850
+ * @throws {PromptBackError} On Esc — the caller decides what "neither" means.
2851
+ * @throws {PromptClosedError} If the input closes before a line arrives.
2852
+ */
2853
+ async askYesNo(question, defaultValue) {
2854
+ let hint = "y/N";
2855
+ if (defaultValue == true) {
2856
+ hint = "Y/n";
2857
+ }
2858
+ for (; ; ) {
2859
+ const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)} ${this.style.dim(`(${hint})`)} `;
2860
+ const typed = await this.readText(prompt, false);
2861
+ const normalized = typed.trim().toLowerCase();
2862
+ let answer = null;
2863
+ if (normalized === "") {
2864
+ answer = defaultValue;
2865
+ } else if (normalized === "y" || normalized === "yes") {
2866
+ answer = true;
2867
+ } else if (normalized === "n" || normalized === "no") {
2868
+ answer = false;
2869
+ }
2870
+ if (answer != null) {
2871
+ let shown = "no";
2872
+ if (answer == true) {
2873
+ shown = "yes";
2874
+ }
2875
+ this.writeAnswered(question, shown);
2876
+ return answer;
2877
+ }
2878
+ this.output.write(` ${this.style.dim("Answer y or n.")}
2879
+ `);
2880
+ }
2862
2881
  }
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";
2882
+ /**
2883
+ * Picks one of `items`. On a TTY: ↑/↓ move, 1–9 jump, Enter picks, Esc goes back. Off a
2884
+ * TTY: a numbered list, answered by number, key or label.
2885
+ *
2886
+ * @throws {PromptBackError} On Esc without a `cancelValue`.
2887
+ * @throws {PromptClosedError} On Ctrl+C or when the input closes.
2888
+ */
2889
+ async select(question, items, options) {
2890
+ const choices = items.filter((item) => "value" in item);
2891
+ if (choices.length === 0) {
2892
+ throw new Error("select needs at least one choice");
2893
+ }
2894
+ let startIndex = items.findIndex((item) => "value" in item && item.value === options?.defaultValue);
2895
+ if (startIndex < 0) {
2896
+ startIndex = items.findIndex((item) => "value" in item);
2897
+ }
2898
+ let picked;
2899
+ if (this.interactive == true) {
2900
+ picked = await this.selectWithKeys(question, items, startIndex);
2901
+ } else {
2902
+ picked = await this.selectWithLines(question, items, startIndex);
2903
+ }
2904
+ if (picked != null) {
2905
+ return picked;
2906
+ }
2907
+ if (options?.cancelValue != null) {
2908
+ return options.cancelValue;
2909
+ }
2910
+ throw new PromptBackError();
2880
2911
  }
2881
- if (nameValue === "") {
2882
- nameValue = "ssh-<host>";
2912
+ /**
2913
+ * Reads a value without echoing it on a TTY. Empty means "none", or "keep" when the
2914
+ * caller says a value already exists.
2915
+ *
2916
+ * @throws {PromptBackError} On Esc.
2917
+ * @throws {PromptClosedError} On Ctrl+C or when the input closes.
2918
+ */
2919
+ async askSecret(question) {
2920
+ const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)} `;
2921
+ const secret = await this.readText(prompt, true);
2922
+ let shown = this.style.dim("(empty)");
2923
+ if (secret !== "") {
2924
+ shown = "\u2022".repeat(Math.min(secret.length, 8));
2925
+ }
2926
+ this.writeAnswered(question, shown);
2927
+ return secret;
2883
2928
  }
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";
2929
+ // ── input ──────────────────────────────────────────────────────────────
2930
+ /**
2931
+ * One text answer, through the line editor on a TTY and a line read off one. The prompt
2932
+ * is erased once answered; the caller writes the summary line.
2933
+ *
2934
+ * @throws {PromptBackError} On Esc, or a line of just `ESC` off a TTY.
2935
+ */
2936
+ async readText(prompt, secret) {
2937
+ if (this.interactive == false) {
2938
+ this.output.write(prompt);
2939
+ const line = await this.readLine();
2940
+ if (line == null) {
2941
+ throw new PromptClosedError();
2942
+ }
2943
+ if (line === ESC) {
2944
+ throw new PromptBackError();
2945
+ }
2946
+ return line;
2891
2947
  }
2892
- rows.push(fieldRow("passphrase", passphrase));
2948
+ const result = await this.editLine(prompt, secret);
2949
+ if (result.kind === "back") {
2950
+ throw new PromptBackError();
2951
+ }
2952
+ return result.text;
2893
2953
  }
2894
- if (answers.auth === "password") {
2895
- let password = REQUIRED_MARK;
2896
- if (answers.password != null) {
2897
- password = "set";
2954
+ readLine() {
2955
+ const queued = this.pendingLines.shift();
2956
+ if (queued != null) {
2957
+ return Promise.resolve(queued);
2898
2958
  }
2899
- rows.push(fieldRow("password", password, "stored in the config file as plain text"));
2959
+ if (this.inputEnded == true) {
2960
+ return Promise.resolve(null);
2961
+ }
2962
+ return new Promise((resolve) => {
2963
+ this.lineWaiter = resolve;
2964
+ });
2900
2965
  }
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";
2966
+ onData(chunk) {
2967
+ if (this.keyHandler != null) {
2968
+ for (const key of splitKeys(chunk)) {
2969
+ if (this.keyHandler == null) {
2970
+ break;
2971
+ }
2972
+ this.keyHandler(key);
2910
2973
  }
2911
- jumpUser = `same as login (${loginUser})`;
2974
+ return;
2975
+ }
2976
+ this.lineBuffer += chunk;
2977
+ let newlineIndex = this.lineBuffer.indexOf("\n");
2978
+ while (newlineIndex >= 0) {
2979
+ const line = this.lineBuffer.slice(0, newlineIndex).replace(/\r$/, "");
2980
+ this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1);
2981
+ if (this.lineWaiter != null) {
2982
+ const waiter = this.lineWaiter;
2983
+ this.lineWaiter = null;
2984
+ waiter(line);
2985
+ } else {
2986
+ this.pendingLines.push(line);
2987
+ }
2988
+ newlineIndex = this.lineBuffer.indexOf("\n");
2912
2989
  }
2913
- rows.push(fieldRow("jump-user", jumpUser));
2914
2990
  }
2915
- if (routeUsesProxy(state.route) == true) {
2916
- rows.push(fieldRow("proxy", valueOrRequired(answers.proxyCommand), "%h %p %r \u2192 first hop"));
2991
+ /**
2992
+ * A one-line editor in raw mode: insert at the cursor, Backspace / Delete, ←/→, Home /
2993
+ * End, Ctrl+U clears, Enter answers, Esc goes back. Secrets draw nothing after the
2994
+ * prompt. Cooked mode is not used for text on a TTY because it never reports Esc: the
2995
+ * terminal swallows it or stuffs the `ESC` byte into the value.
2996
+ */
2997
+ editLine(prompt, secret) {
2998
+ const ttyInput = this.input;
2999
+ const plainPrompt = prompt.replace(/\u001b\[[0-9;]*m/g, "");
3000
+ let characters = [];
3001
+ let cursor = 0;
3002
+ const draw = () => {
3003
+ let text = `\r${ESC}[2K${prompt}`;
3004
+ if (secret == false) {
3005
+ text += characters.join("");
3006
+ const column = displayWidth(plainPrompt) + displayWidth(characters.slice(0, cursor).join(""));
3007
+ text += `\r${ESC}[${column}C`;
3008
+ }
3009
+ this.output.write(text);
3010
+ };
3011
+ return new Promise((resolve, reject) => {
3012
+ const finish = () => {
3013
+ this.keyHandler = null;
3014
+ ttyInput.setRawMode?.(false);
3015
+ this.output.write(`\r${ESC}[2K`);
3016
+ };
3017
+ this.keyHandler = (key) => {
3018
+ if (key === KEY_CTRL_C || key === KEY_CTRL_D) {
3019
+ finish();
3020
+ reject(new PromptClosedError());
3021
+ return;
3022
+ }
3023
+ if (KEY_ENTER.includes(key) == true) {
3024
+ finish();
3025
+ resolve({ kind: "entered", text: characters.join("") });
3026
+ return;
3027
+ }
3028
+ if (key === ESC) {
3029
+ finish();
3030
+ resolve({ kind: "back" });
3031
+ return;
3032
+ }
3033
+ if (KEY_BACKSPACE.includes(key) == true) {
3034
+ if (cursor > 0) {
3035
+ characters.splice(cursor - 1, 1);
3036
+ cursor -= 1;
3037
+ }
3038
+ } else if (KEY_DELETE.includes(key) == true) {
3039
+ characters.splice(cursor, 1);
3040
+ } else if (KEY_LEFT.includes(key) == true) {
3041
+ cursor = Math.max(0, cursor - 1);
3042
+ } else if (KEY_RIGHT.includes(key) == true) {
3043
+ cursor = Math.min(characters.length, cursor + 1);
3044
+ } else if (KEY_HOME.includes(key) == true) {
3045
+ cursor = 0;
3046
+ } else if (KEY_END.includes(key) == true) {
3047
+ cursor = characters.length;
3048
+ } else if (key === KEY_CLEAR_LINE) {
3049
+ characters = [];
3050
+ cursor = 0;
3051
+ } else if (isPrintableKey(key) == true) {
3052
+ characters.splice(cursor, 0, key);
3053
+ cursor += 1;
3054
+ } else {
3055
+ return;
3056
+ }
3057
+ draw();
3058
+ };
3059
+ ttyInput.setRawMode?.(true);
3060
+ draw();
3061
+ });
2917
3062
  }
2918
- rows.push(fieldRow("description", answers.description ?? "none", "shown to the agent"));
2919
- let readonly = "no";
2920
- if (answers.readonly == true) {
2921
- readonly = "yes";
3063
+ async selectWithLines(question, items, startIndex) {
3064
+ const choices = items.filter((item) => "value" in item);
3065
+ const labelWidths = groupLabelWidths(items);
3066
+ this.output.write(`${this.symbols.question} ${question}
3067
+ `);
3068
+ let number = 0;
3069
+ for (let index = 0; index < items.length; index += 1) {
3070
+ const item = items[index];
3071
+ if ("value" in item == false) {
3072
+ this.output.write("\n");
3073
+ continue;
3074
+ }
3075
+ number += 1;
3076
+ let line = ` ${String(number).padStart(2)}) ${padToWidth(item.label, labelWidths[index])}`;
3077
+ if (item.hint != null && item.hint !== "") {
3078
+ line += ` ${item.hint}`;
3079
+ }
3080
+ this.output.write(`${line.trimEnd()}
3081
+ `);
3082
+ }
3083
+ const defaultChoice = items[startIndex];
3084
+ const defaultNumber = choices.indexOf(defaultChoice) + 1;
3085
+ for (; ; ) {
3086
+ this.output.write(`Choice (${defaultNumber}): `);
3087
+ const line = await this.readLine();
3088
+ if (line == null) {
3089
+ throw new PromptClosedError();
3090
+ }
3091
+ if (line === ESC) {
3092
+ return null;
3093
+ }
3094
+ const answer = line.trim();
3095
+ if (answer === "") {
3096
+ return defaultChoice.value;
3097
+ }
3098
+ const index = Number(answer);
3099
+ if (Number.isInteger(index) == true && index >= 1 && index <= choices.length) {
3100
+ return choices[index - 1].value;
3101
+ }
3102
+ const lowered = answer.toLowerCase();
3103
+ const matched = choices.find((choice) => {
3104
+ const key = choice.key ?? choice.value;
3105
+ return key.toLowerCase() === lowered || choice.label.toLowerCase() === lowered;
3106
+ });
3107
+ if (matched != null) {
3108
+ return matched.value;
3109
+ }
3110
+ this.output.write(` Pick a number from 1 to ${choices.length}.
3111
+ `);
3112
+ }
2922
3113
  }
2923
- rows.push(fieldRow("readonly", readonly, "enter toggles"));
2924
- let paths = "anywhere";
2925
- if (answers.allowedPaths.length > 0) {
2926
- paths = answers.allowedPaths.join(", ");
3114
+ // ── TTY rendering ──────────────────────────────────────────────────────
3115
+ columns() {
3116
+ const columns = this.output.columns;
3117
+ if (columns == null || columns < 20) {
3118
+ return 80;
3119
+ }
3120
+ return columns;
2927
3121
  }
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)));
3122
+ /** Joins styled parts, cutting the text (not the styles) once `width` columns are used. */
3123
+ fitLine(parts, width) {
3124
+ let used = 0;
3125
+ let line = "";
3126
+ for (const part of parts) {
3127
+ let text = "";
3128
+ for (const character of part.text) {
3129
+ const characterWidth = charWidth(character.codePointAt(0) ?? 0);
3130
+ if (used + characterWidth > width) {
3131
+ break;
3132
+ }
3133
+ used += characterWidth;
3134
+ text += character;
3135
+ }
3136
+ if (part.paint != null) {
3137
+ line += part.paint(text);
3138
+ } else {
3139
+ line += text;
3140
+ }
3141
+ if (used >= width) {
3142
+ break;
3143
+ }
3144
+ }
3145
+ return line;
2932
3146
  }
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";
3147
+ /** The one-line record an answered prompt leaves behind on a TTY. */
3148
+ writeAnswered(question, answer) {
3149
+ if (this.interactive == false || this.transient == true) {
3150
+ return;
3151
+ }
3152
+ this.output.write(`${this.style.success(this.symbols.tick)} ${question} ${this.style.dim("\xB7")} ${this.style.accent(answer)}
3153
+ `);
2939
3154
  }
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;
3155
+ /** @returns The picked value, or null on Esc. */
3156
+ selectWithKeys(question, items, startIndex) {
3157
+ const ttyInput = this.input;
3158
+ const selectableIndexes = items.flatMap((item, index) => {
3159
+ if ("value" in item) {
3160
+ return [index];
3161
+ }
3162
+ return [];
3163
+ });
3164
+ const labelWidths = groupLabelWidths(items);
3165
+ const ruleWidth = Math.min(Math.max(...labelWidths), 24);
3166
+ let active = startIndex;
3167
+ let renderedLines = 0;
3168
+ const draw = (lines) => {
3169
+ let text = "";
3170
+ if (renderedLines > 1) {
3171
+ text += `${ESC}[${renderedLines - 1}A`;
3172
+ }
3173
+ text += `\r${ESC}[J${lines.join("\n")}`;
3174
+ this.output.write(text);
3175
+ renderedLines = lines.length;
3176
+ };
3177
+ const buildLines = () => {
3178
+ const width = this.columns() - 1;
3179
+ const lines = [
3180
+ this.fitLine([
3181
+ { text: this.symbols.question, paint: this.style.accent },
3182
+ { text: " " },
3183
+ { text: question, paint: this.style.bold }
3184
+ ], width)
3185
+ ];
3186
+ for (let index = 0; index < items.length; index += 1) {
3187
+ const item = items[index];
3188
+ if ("value" in item == false) {
3189
+ lines.push(this.fitLine([{ text: ` ${this.symbols.rule.repeat(ruleWidth)}`, paint: this.style.dim }], width));
3190
+ continue;
3191
+ }
3192
+ const isActive = index === active;
3193
+ const parts = [];
3194
+ if (isActive == true) {
3195
+ parts.push({ text: `${this.symbols.pointer} `, paint: this.style.accent });
3196
+ parts.push({ text: padToWidth(item.label, labelWidths[index]), paint: this.style.accent });
3197
+ } else {
3198
+ parts.push({ text: " " });
3199
+ parts.push({ text: padToWidth(item.label, labelWidths[index]) });
3200
+ }
3201
+ if (item.hint != null && item.hint !== "") {
3202
+ parts.push({ text: ` ${item.hint}`, paint: this.style.dim });
3203
+ }
3204
+ lines.push(this.fitLine(parts, width));
3205
+ }
3206
+ lines.push(this.fitLine([{ text: "\u2191\u2193 move \xB7 enter select \xB7 esc back", paint: this.style.dim }], width));
3207
+ return lines;
3208
+ };
3209
+ const move = (step) => {
3210
+ const position = selectableIndexes.indexOf(active);
3211
+ const next = (position + step + selectableIndexes.length) % selectableIndexes.length;
3212
+ active = selectableIndexes[next];
3213
+ };
3214
+ return new Promise((resolve, reject) => {
3215
+ const finish = () => {
3216
+ this.keyHandler = null;
3217
+ ttyInput.setRawMode?.(false);
3218
+ draw([]);
3219
+ this.output.write(`${ESC}[?25h`);
3220
+ };
3221
+ this.keyHandler = (key) => {
3222
+ if (key === KEY_CTRL_C || key === KEY_CTRL_D) {
3223
+ finish();
3224
+ reject(new PromptClosedError());
3225
+ return;
3226
+ }
3227
+ if (KEY_ENTER.includes(key) == true) {
3228
+ const picked = items[active];
3229
+ finish();
3230
+ this.writeAnswered(question, picked.label);
3231
+ resolve(picked.value);
3232
+ return;
3233
+ }
3234
+ if (key === ESC) {
3235
+ finish();
3236
+ resolve(null);
3237
+ return;
3238
+ }
3239
+ if (KEY_UP.includes(key) == true) {
3240
+ move(-1);
3241
+ } else if (KEY_DOWN.includes(key) == true) {
3242
+ move(1);
3243
+ } else if (KEY_HOME.includes(key) == true) {
3244
+ active = selectableIndexes[0];
3245
+ } else if (KEY_END.includes(key) == true) {
3246
+ active = selectableIndexes[selectableIndexes.length - 1];
3247
+ } else if (/^[1-9]$/.test(key) == true && Number(key) <= selectableIndexes.length) {
3248
+ active = selectableIndexes[Number(key) - 1];
3249
+ } else {
3250
+ return;
3251
+ }
3252
+ draw(buildLines());
3253
+ };
3254
+ ttyInput.setRawMode?.(true);
3255
+ this.output.write(`${ESC}[?25l`);
3256
+ draw(buildLines());
3257
+ });
2948
3258
  }
2949
- const values = buildRows(state, destination).flatMap((row) => {
2950
- if ("value" in row) {
2951
- return [row.value];
2952
- }
3259
+ };
3260
+
3261
+ // src/modules/manage/host-form.ts
3262
+ var PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
3263
+ var NON_KEY_FILE_NAMES = ["config", "known_hosts", "known_hosts.old", "authorized_keys", "environment"];
3264
+ var MAX_KEY_FILE_BYTES = 64 * 1024;
3265
+ var CLEAR_ANSWER = "-";
3266
+ var OTHER_KEY_PATH = "\0other";
3267
+ var FIELD_NAME_WIDTH = 17;
3268
+ var SUB_FIELDS = ["key", "passphrase", "password", "jump-host", "jump-port", "jump-user", "proxy"];
3269
+ var FIELD_NAMES = {
3270
+ "host": "Host",
3271
+ "port": "Port",
3272
+ "user": "User",
3273
+ "name": "Server name",
3274
+ "auth": "Authentication",
3275
+ "key": "Private key",
3276
+ "passphrase": "Passphrase",
3277
+ "password": "Password",
3278
+ "route": "Route",
3279
+ "jump-host": "Jump host",
3280
+ "jump-port": "Jump port",
3281
+ "jump-user": "Jump user",
3282
+ "proxy": "Proxy command",
3283
+ "description": "Description",
3284
+ "readonly": "Read-only",
3285
+ "paths": "File paths",
3286
+ "host-key": "Host key",
3287
+ "jump-host-key": "Jump host key"
3288
+ };
3289
+ var AUTH_LABELS = {
3290
+ key: "Private key file",
3291
+ password: "Password",
3292
+ agent: "SSH agent"
3293
+ };
3294
+ var ROUTE_LABELS = {
3295
+ "direct": "Directly",
3296
+ "jump": "Through a jump host",
3297
+ "proxy": "Through a proxy command",
3298
+ "jump-proxy": "Proxy command, then a jump host"
3299
+ };
3300
+ var REQUIRED_MARK = "(required)";
3301
+ function findPrivateKeys() {
3302
+ const sshDirectory = path.join(os5.homedir(), ".ssh");
3303
+ let names = [];
3304
+ try {
3305
+ names = fs6.readdirSync(sshDirectory);
3306
+ } catch {
2953
3307
  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
3308
  }
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;
3309
+ const keys = [];
3310
+ for (const name of names) {
3311
+ if (name.endsWith(".pub") == true || NON_KEY_FILE_NAMES.includes(name) == true) {
3312
+ continue;
3313
+ }
3314
+ const fullPath = path.join(sshDirectory, name);
3315
+ let head = "";
3316
+ try {
3317
+ const stat = fs6.statSync(fullPath);
3318
+ if (stat.isFile() == false || stat.size > MAX_KEY_FILE_BYTES) {
3319
+ continue;
2983
3320
  }
3321
+ const descriptor = fs6.openSync(fullPath, "r");
3322
+ const buffer = Buffer.alloc(64);
3323
+ const bytesRead = fs6.readSync(descriptor, buffer, 0, buffer.length, 0);
3324
+ fs6.closeSync(descriptor);
3325
+ head = buffer.subarray(0, bytesRead).toString("utf8");
3326
+ } catch {
3327
+ continue;
2984
3328
  }
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;
3329
+ const isPemKey = head.startsWith("-----BEGIN") == true && head.includes("PRIVATE KEY") == true;
3330
+ const isPuttyKey = head.startsWith("PuTTY-User-Key-File") == true;
3331
+ if (isPemKey == false && isPuttyKey == false) {
3332
+ continue;
3333
+ }
3334
+ let hint = "";
3335
+ try {
3336
+ const publicKey = fs6.readFileSync(`${fullPath}.pub`, "utf8").trim().split(/\s+/);
3337
+ hint = publicKey[0].replace(/^ssh-/, "");
3338
+ if (publicKey.length > 2) {
3339
+ hint += ` \xB7 ${publicKey.slice(2).join(" ")}`;
2989
3340
  }
3341
+ } catch {
2990
3342
  }
2991
- state.lastTest = "ok";
2992
- state.testedSnapshot = snapshotOf(state);
2993
- return true;
3343
+ keys.push({ path: `~/.ssh/${name}`, hint });
2994
3344
  }
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");
3345
+ const rank = (key) => {
3346
+ const index = PREFERRED_KEY_NAMES.indexOf(path.basename(key.path));
3347
+ if (index < 0) {
3348
+ return PREFERRED_KEY_NAMES.length;
3002
3349
  }
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");
3350
+ return index;
3351
+ };
3352
+ return keys.sort((left, right) => rank(left) - rank(right) || left.path.localeCompare(right.path));
3353
+ }
3354
+ async function askPort(prompter, question, current) {
3355
+ for (; ; ) {
3356
+ const answer = await prompter.ask(question, String(current));
3357
+ const parsed = Number(answer);
3358
+ if (Number.isInteger(parsed) == true && parsed > 0 && parsed <= 65535) {
3359
+ return parsed;
3011
3360
  }
3361
+ prompter.write(" Enter a port number between 1 and 65535.\n");
3012
3362
  }
3013
- state.lastTest = "failed";
3014
- return false;
3015
3363
  }
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;
3364
+ async function askOptional(prompter, question, current) {
3365
+ const answer = await prompter.ask(question, current);
3366
+ if (answer === "" || answer === CLEAR_ANSWER) {
3367
+ return void 0;
3028
3368
  }
3029
- if (id === "port") {
3030
- answers.port = await askPort(prompter, "Port", answers.port);
3031
- return;
3369
+ return answer;
3370
+ }
3371
+ async function askOptionalSecret(prompter, question, current) {
3372
+ let suffix = "(empty for none)";
3373
+ if (current != null) {
3374
+ suffix = `(enter keeps current, ${CLEAR_ANSWER} clears)`;
3032
3375
  }
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;
3376
+ const answer = await prompter.askSecret(`${question} ${prompter.paint.dim(suffix)}`);
3377
+ if (answer === CLEAR_ANSWER) {
3378
+ return void 0;
3040
3379
  }
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
- }
3380
+ if (answer === "") {
3381
+ return current;
3055
3382
  }
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);
3383
+ return answer;
3384
+ }
3385
+ async function askKeyPath(prompter, current) {
3386
+ const found = findPrivateKeys();
3387
+ let picked = OTHER_KEY_PATH;
3388
+ if (found.length > 0 || current != null) {
3389
+ const items = [];
3390
+ if (current != null && found.some((key) => key.path === current) == false) {
3391
+ items.push({ value: current, label: current, hint: "current" });
3068
3392
  }
3069
- if (answers.auth === "password" && answers.password == null) {
3070
- answers.password = await askOptionalSecret(prompter, "Password", void 0);
3393
+ for (const key of found) {
3394
+ items.push({ value: key.path, label: key.path, hint: key.hint });
3071
3395
  }
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;
3396
+ items.push({ separator: true });
3397
+ items.push({ value: OTHER_KEY_PATH, key: "other", label: "Another path\u2026" });
3398
+ picked = await prompter.select("Private key", items, { defaultValue: current ?? found[0]?.path });
3081
3399
  }
3082
- if (id === "password") {
3083
- answers.password = await askOptionalSecret(prompter, "Password", answers.password);
3084
- return;
3400
+ if (picked !== OTHER_KEY_PATH) {
3401
+ return picked;
3085
3402
  }
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");
3403
+ for (; ; ) {
3404
+ const typed = await prompter.askRequired("Private key path");
3405
+ if (fs6.existsSync(expandHomePath(typed)) == true) {
3406
+ return typed;
3099
3407
  }
3100
- if (routeUsesProxy(state.route) == true && answers.proxyCommand == null) {
3101
- answers.proxyCommand = await prompter.askRequired("Proxy command", "cloudflared access ssh --hostname %h");
3408
+ prompter.write(` ${expandHomePath(typed)} does not exist.
3409
+ `);
3410
+ const useAnyway = await prompter.askYesNo("Use this path anyway?", false);
3411
+ if (useAnyway == true) {
3412
+ return typed;
3102
3413
  }
3103
- return;
3104
3414
  }
3105
- if (id === "jump-host") {
3106
- answers.jumpHost = await prompter.askRequired("Jump host", answers.jumpHost);
3107
- return;
3415
+ }
3416
+ function routeUsesJump(route) {
3417
+ return route === "jump" || route === "jump-proxy";
3418
+ }
3419
+ function routeUsesProxy(route) {
3420
+ return route === "proxy" || route === "jump-proxy";
3421
+ }
3422
+ function deriveServerName(host) {
3423
+ return `ssh-${host}`.replace(/[^A-Za-z0-9_-]+/g, "-").slice(0, 48);
3424
+ }
3425
+ function finalizeAnswers(state) {
3426
+ const answers = { ...state.answers };
3427
+ if (routeUsesJump(state.route) == false) {
3428
+ answers.jumpHost = void 0;
3429
+ answers.jumpPort = void 0;
3430
+ answers.jumpUser = void 0;
3431
+ answers.jumpHostKeyFingerprint = void 0;
3108
3432
  }
3109
- if (id === "jump-port") {
3110
- answers.jumpPort = await askPort(prompter, "Jump host port", answers.jumpPort ?? DEFAULT_SSH_PORT);
3111
- return;
3433
+ if (routeUsesProxy(state.route) == false) {
3434
+ answers.proxyCommand = void 0;
3112
3435
  }
3113
- if (id === "jump-user") {
3114
- answers.jumpUser = await askOptional(prompter, "Jump host user (empty = same as the login user)", answers.jumpUser);
3115
- return;
3436
+ if (answers.auth !== "key") {
3437
+ answers.keyPath = void 0;
3438
+ answers.passphrase = void 0;
3116
3439
  }
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;
3440
+ if (answers.auth !== "password") {
3441
+ answers.password = void 0;
3121
3442
  }
3122
- if (id === "description") {
3123
- answers.description = await askOptional(prompter, "Description shown to the agent", answers.description);
3124
- return;
3443
+ return answers;
3444
+ }
3445
+ function snapshotOf(state) {
3446
+ return JSON.stringify(finalizeAnswers(state));
3447
+ }
3448
+ function cloneState(state) {
3449
+ return {
3450
+ ...state,
3451
+ answers: {
3452
+ ...state.answers,
3453
+ allowedPaths: [...state.answers.allowedPaths],
3454
+ extraEnv: { ...state.answers.extraEnv }
3455
+ }
3456
+ };
3457
+ }
3458
+ function restoreState(state, snapshot) {
3459
+ const restored = cloneState(snapshot);
3460
+ state.answers = restored.answers;
3461
+ state.route = restored.route;
3462
+ state.nameTouched = restored.nameTouched;
3463
+ state.lastTest = restored.lastTest;
3464
+ state.testedSnapshot = restored.testedSnapshot;
3465
+ }
3466
+ function missingFields(state) {
3467
+ const { answers } = state;
3468
+ const missing = [];
3469
+ if (answers.host === "") {
3470
+ missing.push("host");
3125
3471
  }
3126
- if (id === "readonly") {
3127
- answers.readonly = answers.readonly == false;
3128
- return;
3472
+ if (answers.username === "") {
3473
+ missing.push("user");
3129
3474
  }
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;
3475
+ if (answers.auth === "key" && answers.keyPath == null) {
3476
+ missing.push("key");
3141
3477
  }
3142
- const isJump = id === "jump-host-key";
3143
- let pinned = answers.hostKeyFingerprint;
3144
- if (isJump == true) {
3145
- pinned = answers.jumpHostKeyFingerprint;
3478
+ if (answers.auth === "password" && answers.password == null) {
3479
+ missing.push("password");
3146
3480
  }
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;
3481
+ if (routeUsesJump(state.route) == true && answers.jumpHost == null) {
3482
+ missing.push("jump-host");
3155
3483
  }
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;
3484
+ if (routeUsesProxy(state.route) == true && answers.proxyCommand == null) {
3485
+ missing.push("proxy");
3161
3486
  }
3162
- await testConnection(prompter, output, state);
3487
+ return missing;
3163
3488
  }
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
- };
3489
+ function describePin(fingerprint) {
3490
+ if (fingerprint == null) {
3491
+ return "not pinned";
3196
3492
  }
3197
- const initialSnapshot = snapshotOf(state);
3198
- const originalName = existing?.name;
3199
- let title = "New host";
3200
- if (existing != null) {
3201
- title = `Editing ${existing.name}`;
3493
+ return `pinned \xB7 ${fingerprint.slice(0, 19)}\u2026`;
3494
+ }
3495
+ function valueOrRequired(value) {
3496
+ if (value == null || value === "") {
3497
+ return REQUIRED_MARK;
3202
3498
  }
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);
3499
+ return value;
3500
+ }
3501
+ function fieldRow(id, value, hint) {
3502
+ let name = FIELD_NAMES[id];
3503
+ if (SUB_FIELDS.includes(id) == true) {
3504
+ name = ` ${name}`;
3505
+ }
3506
+ return {
3507
+ value: id,
3508
+ key: id,
3509
+ label: `${name.padEnd(FIELD_NAME_WIDTH)}${value}`,
3510
+ hint
3511
+ };
3512
+ }
3513
+ function buildRows(state, destination) {
3514
+ const { answers } = state;
3515
+ const rows = [];
3516
+ rows.push(fieldRow("host", valueOrRequired(answers.host)));
3517
+ rows.push(fieldRow("port", String(answers.port)));
3518
+ rows.push(fieldRow("user", valueOrRequired(answers.username)));
3519
+ let nameValue = answers.name;
3520
+ let nameHint = "what the agent sees";
3521
+ if (state.nameTouched == false) {
3522
+ nameHint = "follows the host until you change it";
3523
+ }
3524
+ if (nameValue === "") {
3525
+ nameValue = "ssh-<host>";
3526
+ }
3527
+ rows.push(fieldRow("name", nameValue, nameHint));
3528
+ rows.push(fieldRow("auth", AUTH_LABELS[answers.auth]));
3529
+ if (answers.auth === "key") {
3530
+ rows.push(fieldRow("key", valueOrRequired(answers.keyPath)));
3531
+ let passphrase = "none";
3532
+ if (answers.passphrase != null) {
3533
+ passphrase = "set";
3280
3534
  }
3281
- } finally {
3282
- prompter.transient = previousTransient;
3535
+ rows.push(fieldRow("passphrase", passphrase));
3283
3536
  }
3284
- }
3285
-
3286
- // src/modules/manage/prompter.ts
3287
- var PromptClosedError = class extends Error {
3288
- constructor() {
3289
- super("input closed before the prompt was answered");
3290
- this.name = "PromptClosedError";
3537
+ if (answers.auth === "password") {
3538
+ let password = REQUIRED_MARK;
3539
+ if (answers.password != null) {
3540
+ password = "set";
3541
+ }
3542
+ rows.push(fieldRow("password", password, "stored in the config file as plain text"));
3291
3543
  }
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;
3544
+ rows.push(fieldRow("route", ROUTE_LABELS[state.route]));
3545
+ if (routeUsesJump(state.route) == true) {
3546
+ rows.push(fieldRow("jump-host", valueOrRequired(answers.jumpHost)));
3547
+ rows.push(fieldRow("jump-port", String(answers.jumpPort ?? DEFAULT_SSH_PORT)));
3548
+ let jumpUser = answers.jumpUser;
3549
+ if (jumpUser == null || jumpUser === answers.username) {
3550
+ let loginUser = answers.username;
3551
+ if (loginUser === "") {
3552
+ loginUser = "\u2026";
3311
3553
  }
3312
- keys.push(chunk.slice(index, end + 1));
3313
- index = end + 1;
3314
- continue;
3554
+ jumpUser = `same as login (${loginUser})`;
3315
3555
  }
3316
- const codePoint = chunk.codePointAt(index) ?? 0;
3317
- const character = String.fromCodePoint(codePoint);
3318
- keys.push(character);
3319
- index += character.length;
3556
+ rows.push(fieldRow("jump-user", jumpUser));
3320
3557
  }
3321
- return keys;
3322
- }
3323
- function charWidth(codePoint) {
3324
- if (codePoint < 4352) {
3325
- return 1;
3558
+ if (routeUsesProxy(state.route) == true) {
3559
+ rows.push(fieldRow("proxy", valueOrRequired(answers.proxyCommand), "%h %p %r \u2192 first hop"));
3326
3560
  }
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;
3561
+ rows.push(fieldRow("description", answers.description ?? "none", "shown to the agent"));
3562
+ let readonly = "no";
3563
+ if (answers.readonly == true) {
3564
+ readonly = "yes";
3330
3565
  }
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);
3566
+ rows.push(fieldRow("readonly", readonly, "enter toggles"));
3567
+ let paths = "anywhere";
3568
+ if (answers.allowedPaths.length > 0) {
3569
+ paths = answers.allowedPaths.join(", ");
3337
3570
  }
3338
- return width;
3339
- }
3340
- function padToWidth(text, width) {
3341
- const missing = width - displayWidth(text);
3342
- if (missing <= 0) {
3343
- return text;
3571
+ rows.push(fieldRow("paths", paths, "where the file tools may read and write"));
3572
+ rows.push(fieldRow("host-key", describePin(answers.hostKeyFingerprint)));
3573
+ if (routeUsesJump(state.route) == true) {
3574
+ rows.push(fieldRow("jump-host-key", describePin(answers.jumpHostKeyFingerprint)));
3344
3575
  }
3345
- return `${text}${" ".repeat(missing)}`;
3576
+ rows.push({ separator: true });
3577
+ let testHint = "not run yet";
3578
+ if (state.lastTest === "ok") {
3579
+ testHint = "last run: OK";
3580
+ } else if (state.lastTest === "failed") {
3581
+ testHint = "last run: failed";
3582
+ }
3583
+ rows.push({ value: "test", key: "test", label: "Test the connection", hint: testHint });
3584
+ rows.push({ value: "save", key: "save", label: "Save", hint: `to ${destination}` });
3585
+ rows.push({ value: "cancel", key: "cancel", label: "Cancel" });
3586
+ return rows;
3346
3587
  }
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));
3588
+ function nextCursor(state, destination, edited) {
3589
+ if (edited === "readonly") {
3590
+ return edited;
3591
+ }
3592
+ const values = buildRows(state, destination).flatMap((row) => {
3593
+ if ("value" in row) {
3594
+ return [row.value];
3359
3595
  }
3360
- for (let member = groupStart; member < index; member += 1) {
3361
- widths[member] = groupWidth;
3596
+ return [];
3597
+ });
3598
+ let index = values.indexOf(edited) + 1;
3599
+ if (SUB_FIELDS.includes(edited) == false) {
3600
+ while (index < values.length && SUB_FIELDS.includes(values[index]) == true) {
3601
+ index += 1;
3362
3602
  }
3363
- groupStart = index + 1;
3364
3603
  }
3365
- return widths;
3366
- }
3367
- function supportsUnicode() {
3368
- if (process.platform !== "win32") {
3369
- return true;
3604
+ const next = values[index];
3605
+ if (next == null || next === "test") {
3606
+ return "save";
3370
3607
  }
3371
- return process.env.WT_SESSION != null || process.env.TERM_PROGRAM === "vscode" || process.env.TERMINAL_EMULATOR === "JetBrains-JediTerm" || process.env.ConEmuTask != null;
3608
+ return next;
3372
3609
  }
3373
- function createStyle(enabled) {
3374
- const paint = (open, close) => {
3375
- return (text) => {
3376
- if (enabled == false || text === "") {
3377
- return text;
3610
+ async function testConnection(prompter, output, state) {
3611
+ const answers = finalizeAnswers(state);
3612
+ output.write("\n");
3613
+ const profile = profileFromEnv(output, answersToEntry(answers).env);
3614
+ if (profile == null) {
3615
+ output.write("\n");
3616
+ state.lastTest = "failed";
3617
+ return false;
3618
+ }
3619
+ const result = await runConnectionCheck(profile, output);
3620
+ output.write("\n");
3621
+ if (result.connected == true) {
3622
+ if (result.hostKeyFingerprint != null && state.answers.hostKeyFingerprint == null) {
3623
+ const pin = await prompter.askYesNo("Pin this host key? (a changed key then aborts the connection)", true);
3624
+ if (pin == true) {
3625
+ state.answers.hostKeyFingerprint = result.hostKeyFingerprint;
3378
3626
  }
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
- }
3389
- var Prompter = class {
3390
- constructor(input, output) {
3391
- this.input = input;
3392
- this.output = output;
3393
- this.lineBuffer = "";
3394
- this.pendingLines = [];
3395
- this.lineWaiter = null;
3396
- this.inputEnded = false;
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
3627
  }
3414
- input.setEncoding("utf8");
3415
- input.on("data", (chunk) => {
3416
- this.onData(chunk);
3417
- });
3418
- input.on("end", () => {
3419
- this.inputEnded = true;
3420
- if (this.lineWaiter != null) {
3421
- const waiter = this.lineWaiter;
3422
- this.lineWaiter = null;
3423
- waiter(null);
3424
- }
3425
- if (this.keyHandler != null) {
3426
- this.keyHandler(KEY_CTRL_D);
3628
+ if (result.jumpHostKeyFingerprint != null && routeUsesJump(state.route) == true && state.answers.jumpHostKeyFingerprint == null) {
3629
+ const pin = await prompter.askYesNo("Pin the jump host key too?", true);
3630
+ if (pin == true) {
3631
+ state.answers.jumpHostKeyFingerprint = result.jumpHostKeyFingerprint;
3427
3632
  }
3428
- });
3429
- }
3430
- /** Stops reading so the process can exit once the command is done. */
3431
- close() {
3432
- if (this.interactive == true) {
3433
- this.input.setRawMode?.(false);
3434
- this.output.write(`${ESC}[?25h`);
3435
3633
  }
3436
- this.input.pause();
3437
- }
3438
- write(text) {
3439
- this.output.write(text);
3634
+ state.lastTest = "ok";
3635
+ state.testedSnapshot = snapshotOf(state);
3636
+ return true;
3440
3637
  }
3441
- /** Styling for callers that print around the prompts; plain text off a TTY. */
3442
- get paint() {
3443
- return this.style;
3638
+ const hostKeyChanged = result.hostKeyFingerprint != null && state.answers.hostKeyFingerprint != null && result.hostKeyFingerprint !== state.answers.hostKeyFingerprint;
3639
+ if (hostKeyChanged == true) {
3640
+ output.write(" The host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
3641
+ const repin = await prompter.askYesNo(`Pin ${result.hostKeyFingerprint} instead?`, false);
3642
+ if (repin == true) {
3643
+ state.answers.hostKeyFingerprint = result.hostKeyFingerprint ?? void 0;
3644
+ output.write(" Pinned the new key \u2014 test again to confirm.\n");
3645
+ }
3444
3646
  }
3445
- /**
3446
- * Free-text prompt. Enter accepts `defaultValue` when one is shown.
3447
- *
3448
- * @throws {PromptClosedError} If the input closes before a line arrives.
3449
- */
3450
- async ask(question, defaultValue) {
3451
- let suffix = "";
3452
- if (defaultValue != null && defaultValue !== "") {
3453
- suffix = ` ${this.style.dim(`(${defaultValue})`)}`;
3647
+ const jumpKeyChanged = result.jumpHostKeyFingerprint != null && state.answers.jumpHostKeyFingerprint != null && result.jumpHostKeyFingerprint !== state.answers.jumpHostKeyFingerprint;
3648
+ if (jumpKeyChanged == true) {
3649
+ output.write(" The jump host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
3650
+ const repin = await prompter.askYesNo(`Pin ${result.jumpHostKeyFingerprint} instead?`, false);
3651
+ if (repin == true) {
3652
+ state.answers.jumpHostKeyFingerprint = result.jumpHostKeyFingerprint ?? void 0;
3653
+ output.write(" Pinned the new key \u2014 test again to confirm.\n");
3454
3654
  }
3455
- const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)}${suffix} `;
3456
- this.output.write(prompt);
3457
- const line = await this.readLine();
3458
- if (line == null) {
3459
- throw new PromptClosedError();
3655
+ }
3656
+ state.lastTest = "failed";
3657
+ return false;
3658
+ }
3659
+ async function editField(prompter, output, state, id) {
3660
+ const { answers } = state;
3661
+ if (id === "host") {
3662
+ let current;
3663
+ if (answers.host !== "") {
3664
+ current = answers.host;
3460
3665
  }
3461
- let answer = line.trim();
3462
- if (answer === "" && defaultValue != null) {
3463
- answer = defaultValue;
3666
+ answers.host = await prompter.askRequired("Host (hostname or IP)", current);
3667
+ if (state.nameTouched == false) {
3668
+ answers.name = deriveServerName(answers.host);
3464
3669
  }
3465
- this.collapseAnswered(prompt, line, question, answer);
3466
- return answer;
3670
+ return;
3467
3671
  }
3468
- /** Like `ask`, re-asking until something non-empty is entered. */
3469
- async askRequired(question, defaultValue) {
3470
- for (; ; ) {
3471
- const answer = await this.ask(question, defaultValue);
3472
- if (answer !== "") {
3473
- return answer;
3474
- }
3475
- this.output.write(` ${this.style.dim("A value is required.")}
3476
- `);
3672
+ if (id === "port") {
3673
+ answers.port = await askPort(prompter, "Port", answers.port);
3674
+ return;
3675
+ }
3676
+ if (id === "user") {
3677
+ let current;
3678
+ if (answers.username !== "") {
3679
+ current = answers.username;
3477
3680
  }
3681
+ answers.username = await prompter.askRequired("Login user", current);
3682
+ return;
3478
3683
  }
3479
- async askYesNo(question, defaultValue) {
3480
- let hint = "y/N";
3481
- if (defaultValue == true) {
3482
- hint = "Y/n";
3684
+ if (id === "name") {
3685
+ let current;
3686
+ if (answers.name !== "") {
3687
+ current = answers.name;
3483
3688
  }
3484
3689
  for (; ; ) {
3485
- const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)} ${this.style.dim(`(${hint})`)} `;
3486
- this.output.write(prompt);
3487
- const line = await this.readLine();
3488
- if (line == null) {
3489
- throw new PromptClosedError();
3490
- }
3491
- const normalized = line.trim().toLowerCase();
3492
- let answer = null;
3493
- if (normalized === "") {
3494
- answer = defaultValue;
3495
- } else if (normalized === "y" || normalized === "yes") {
3496
- answer = true;
3497
- } else if (normalized === "n" || normalized === "no") {
3498
- answer = false;
3499
- }
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;
3690
+ const name = await prompter.askRequired("Server name the agent sees", current);
3691
+ if (SERVER_NAME_PATTERN.test(name) == true) {
3692
+ answers.name = name;
3693
+ state.nameTouched = true;
3694
+ return;
3507
3695
  }
3508
- this.output.write(` ${this.style.dim("Answer y or n.")}
3509
- `);
3696
+ prompter.write(" Use letters, digits, '-' and '_' only (max 48) \u2014 the name becomes part of every tool name.\n");
3510
3697
  }
3511
3698
  }
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");
3522
- }
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);
3699
+ if (id === "auth") {
3700
+ answers.auth = await prompter.select(
3701
+ "Authentication",
3702
+ [
3703
+ { value: "key", label: AUTH_LABELS.key, hint: "only the path is stored \u2014 recommended" },
3704
+ { value: "password", label: AUTH_LABELS.password, hint: "stored in the config file as plain text" },
3705
+ { value: "agent", label: AUTH_LABELS.agent, hint: "keys already loaded in ssh-agent" }
3706
+ ],
3707
+ { defaultValue: answers.auth }
3708
+ );
3709
+ if (answers.auth === "key" && answers.keyPath == null) {
3710
+ answers.keyPath = await askKeyPath(prompter, void 0);
3526
3711
  }
3527
- if (this.interactive == true) {
3528
- const picked2 = await this.selectWithKeys(question, items, startIndex, options?.cancelValue);
3529
- return picked2;
3712
+ if (answers.auth === "password" && answers.password == null) {
3713
+ answers.password = await askOptionalSecret(prompter, "Password", void 0);
3530
3714
  }
3531
- const picked = await this.selectWithLines(question, items, startIndex);
3532
- return picked;
3715
+ return;
3533
3716
  }
3534
- /**
3535
- * Reads a value without echoing it on a TTY. Empty means "none", or "keep" when the
3536
- * caller says a value already exists.
3537
- *
3538
- * @throws {PromptClosedError} On Ctrl+C or when the input closes.
3539
- */
3540
- async askSecret(question) {
3541
- if (this.interactive == false) {
3542
- const answer = await this.ask(question);
3543
- return answer;
3544
- }
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
- });
3717
+ if (id === "key") {
3718
+ answers.keyPath = await askKeyPath(prompter, answers.keyPath);
3719
+ return;
3582
3720
  }
3583
- // ── line mode ──────────────────────────────────────────────────────────
3584
- readLine() {
3585
- const queued = this.pendingLines.shift();
3586
- if (queued != null) {
3587
- return Promise.resolve(queued);
3588
- }
3589
- if (this.inputEnded == true) {
3590
- return Promise.resolve(null);
3591
- }
3592
- return new Promise((resolve) => {
3593
- this.lineWaiter = resolve;
3594
- });
3721
+ if (id === "passphrase") {
3722
+ answers.passphrase = await askOptionalSecret(prompter, "Key passphrase", answers.passphrase);
3723
+ return;
3595
3724
  }
3596
- onData(chunk) {
3597
- if (this.keyHandler != null) {
3598
- for (const key of splitKeys(chunk)) {
3599
- if (this.keyHandler == null) {
3600
- break;
3601
- }
3602
- this.keyHandler(key);
3603
- }
3604
- return;
3605
- }
3606
- this.lineBuffer += chunk;
3607
- let newlineIndex = this.lineBuffer.indexOf("\n");
3608
- while (newlineIndex >= 0) {
3609
- const line = this.lineBuffer.slice(0, newlineIndex).replace(/\r$/, "");
3610
- this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1);
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
- }
3618
- newlineIndex = this.lineBuffer.indexOf("\n");
3619
- }
3725
+ if (id === "password") {
3726
+ answers.password = await askOptionalSecret(prompter, "Password", answers.password);
3727
+ return;
3620
3728
  }
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()}
3639
- `);
3729
+ if (id === "route") {
3730
+ state.route = await prompter.select(
3731
+ "Route",
3732
+ [
3733
+ { value: "direct", label: ROUTE_LABELS.direct },
3734
+ { value: "jump", label: ROUTE_LABELS.jump, hint: "ssh -J" },
3735
+ { value: "proxy", label: ROUTE_LABELS.proxy, hint: "e.g. cloudflared access ssh" },
3736
+ { value: "jump-proxy", label: ROUTE_LABELS["jump-proxy"], hint: "cloudflared to the bastion, then on to the host" }
3737
+ ],
3738
+ { defaultValue: state.route }
3739
+ );
3740
+ if (routeUsesJump(state.route) == true && answers.jumpHost == null) {
3741
+ answers.jumpHost = await prompter.askRequired("Jump host");
3640
3742
  }
3641
- const defaultChoice = items[startIndex];
3642
- const defaultNumber = choices.indexOf(defaultChoice) + 1;
3643
- for (; ; ) {
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;
3664
- }
3665
- this.output.write(` Pick a number from 1 to ${choices.length}.
3666
- `);
3743
+ if (routeUsesProxy(state.route) == true && answers.proxyCommand == null) {
3744
+ answers.proxyCommand = await prompter.askRequired("Proxy command", "cloudflared access ssh --hostname %h");
3667
3745
  }
3746
+ return;
3747
+ }
3748
+ if (id === "jump-host") {
3749
+ answers.jumpHost = await prompter.askRequired("Jump host", answers.jumpHost);
3750
+ return;
3751
+ }
3752
+ if (id === "jump-port") {
3753
+ answers.jumpPort = await askPort(prompter, "Jump host port", answers.jumpPort ?? DEFAULT_SSH_PORT);
3754
+ return;
3755
+ }
3756
+ if (id === "jump-user") {
3757
+ answers.jumpUser = await askOptional(prompter, "Jump host user (empty = same as the login user)", answers.jumpUser);
3758
+ return;
3759
+ }
3760
+ if (id === "proxy") {
3761
+ output.write(prompter.paint.dim(" %h / %p / %r expand to the first hop's host, port and user.\n"));
3762
+ answers.proxyCommand = await prompter.askRequired("Proxy command", answers.proxyCommand ?? "cloudflared access ssh --hostname %h");
3763
+ return;
3764
+ }
3765
+ if (id === "description") {
3766
+ answers.description = await askOptional(prompter, "Description shown to the agent", answers.description);
3767
+ return;
3768
+ }
3769
+ if (id === "readonly") {
3770
+ answers.readonly = answers.readonly == false;
3771
+ return;
3668
3772
  }
3669
- // ── TTY rendering ──────────────────────────────────────────────────────
3670
- columns() {
3671
- const columns = this.output.columns;
3672
- if (columns == null || columns < 20) {
3673
- return 80;
3773
+ if (id === "paths") {
3774
+ let current;
3775
+ if (answers.allowedPaths.length > 0) {
3776
+ current = answers.allowedPaths.join(",");
3674
3777
  }
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) {
3697
- break;
3698
- }
3778
+ const typed = await askOptional(prompter, "Limit file tools to path prefixes (comma-separated, empty = anywhere)", current);
3779
+ answers.allowedPaths = [];
3780
+ if (typed != null) {
3781
+ answers.allowedPaths = typed.split(",").map((item) => item.trim()).filter((item) => item !== "");
3699
3782
  }
3700
- return line;
3783
+ return;
3701
3784
  }
3702
- /** The one-line record an answered prompt leaves behind on a TTY. */
3703
- writeAnswered(question, answer) {
3704
- if (this.transient == true) {
3705
- return;
3785
+ const isJump = id === "jump-host-key";
3786
+ let pinned = answers.hostKeyFingerprint;
3787
+ if (isJump == true) {
3788
+ pinned = answers.jumpHostKeyFingerprint;
3789
+ }
3790
+ if (pinned != null) {
3791
+ const unpin = await prompter.askYesNo(`Unpin ${pinned}?`, false);
3792
+ if (unpin == true && isJump == true) {
3793
+ answers.jumpHostKeyFingerprint = void 0;
3794
+ } else if (unpin == true) {
3795
+ answers.hostKeyFingerprint = void 0;
3706
3796
  }
3707
- this.output.write(`${this.style.success(this.symbols.tick)} ${question} ${this.style.dim("\xB7")} ${this.style.accent(answer)}
3797
+ return;
3798
+ }
3799
+ const missing = missingFields(state);
3800
+ if (missing.length > 0) {
3801
+ output.write(` Fill in ${missing.map((field) => FIELD_NAMES[field]).join(", ")} first \u2014 the key is pinned by testing the connection.
3708
3802
  `);
3803
+ return;
3709
3804
  }
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)");
3805
+ await testConnection(prompter, output, state);
3806
+ }
3807
+ async function runHostForm(prompter, output, options) {
3808
+ const { existing, takenNames, destination } = options;
3809
+ let state;
3810
+ if (existing != null) {
3811
+ let route = "direct";
3812
+ if (existing.jumpHost != null && existing.proxyCommand != null) {
3813
+ route = "jump-proxy";
3814
+ } else if (existing.jumpHost != null) {
3815
+ route = "jump";
3816
+ } else if (existing.proxyCommand != null) {
3817
+ route = "proxy";
3725
3818
  }
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];
3819
+ state = { answers: { ...existing }, route, nameTouched: true, lastTest: "never", testedSnapshot: null };
3820
+ } else {
3821
+ const foundKeys = findPrivateKeys();
3822
+ state = {
3823
+ answers: {
3824
+ name: "",
3825
+ host: "",
3826
+ port: DEFAULT_SSH_PORT,
3827
+ username: "",
3828
+ auth: "key",
3829
+ keyPath: foundKeys[0]?.path,
3830
+ readonly: false,
3831
+ allowedPaths: [],
3832
+ extraEnv: {}
3833
+ },
3834
+ route: "direct",
3835
+ nameTouched: false,
3836
+ lastTest: "never",
3837
+ testedSnapshot: null
3789
3838
  };
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;
3839
+ }
3840
+ const initialSnapshot = snapshotOf(state);
3841
+ const originalName = existing?.name;
3842
+ let title = "New host";
3843
+ if (existing != null) {
3844
+ title = `Editing ${existing.name}`;
3845
+ }
3846
+ output.write(prompter.paint.dim(`
3847
+ Pick a setting to change it. In a text field, Enter keeps the value shown and ${CLEAR_ANSWER} clears it.
3848
+
3849
+ `));
3850
+ const previousTransient = prompter.transient;
3851
+ prompter.transient = true;
3852
+ try {
3853
+ let cursor = "host";
3854
+ for (; ; ) {
3855
+ const picked = await prompter.select(title, buildRows(state, destination), { defaultValue: cursor, cancelValue: "cancel" });
3856
+ const before = cloneState(state);
3857
+ try {
3858
+ if (picked === "cancel") {
3859
+ if (snapshotOf(state) !== initialSnapshot) {
3860
+ const discard = await prompter.askYesNo("Discard the changes?", false);
3861
+ if (discard == false) {
3862
+ continue;
3863
+ }
3864
+ }
3865
+ return null;
3802
3866
  }
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;
3867
+ const missing = missingFields(state);
3868
+ if (picked === "test") {
3869
+ if (missing.length > 0) {
3870
+ output.write(` Fill in ${missing.map((field) => FIELD_NAMES[field]).join(", ")} first.
3871
+ `);
3872
+ cursor = missing[0];
3873
+ continue;
3874
+ }
3875
+ const connected = await testConnection(prompter, output, state);
3876
+ cursor = "test";
3877
+ if (connected == true) {
3878
+ cursor = "save";
3879
+ }
3880
+ continue;
3809
3881
  }
3810
- if (key === ESC) {
3811
- if (cancelValue == null) {
3812
- return;
3882
+ if (picked === "save") {
3883
+ if (missing.length > 0) {
3884
+ output.write(` Fill in ${missing.map((field) => FIELD_NAMES[field]).join(", ")} first.
3885
+ `);
3886
+ cursor = missing[0];
3887
+ continue;
3813
3888
  }
3814
- finish();
3815
- resolve(cancelValue);
3816
- return;
3889
+ const name = state.answers.name;
3890
+ if (name !== originalName && takenNames.includes(name) == true) {
3891
+ const replace = await prompter.askYesNo(`"${name}" already exists in this file. Replace it?`, false);
3892
+ if (replace == false) {
3893
+ cursor = "name";
3894
+ continue;
3895
+ }
3896
+ }
3897
+ if (snapshotOf(state) !== state.testedSnapshot) {
3898
+ const testFirst = await prompter.askYesNo("Test the connection before saving?", true);
3899
+ if (testFirst == true) {
3900
+ const connected = await testConnection(prompter, output, state);
3901
+ if (connected == false) {
3902
+ const next = await prompter.select(
3903
+ "The check failed. What now?",
3904
+ [
3905
+ { value: "back", key: "back", label: "Back to the settings" },
3906
+ { value: "save", key: "save", label: "Save anyway", hint: "the host may be unreachable from here" },
3907
+ { value: "discard", key: "discard", label: "Discard", hint: "nothing is written" }
3908
+ ],
3909
+ { defaultValue: "back", cancelValue: "back" }
3910
+ );
3911
+ if (next === "discard") {
3912
+ return null;
3913
+ }
3914
+ if (next === "back") {
3915
+ cursor = "test";
3916
+ continue;
3917
+ }
3918
+ }
3919
+ }
3920
+ }
3921
+ return finalizeAnswers(state);
3817
3922
  }
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;
3923
+ await editField(prompter, output, state, picked);
3924
+ cursor = nextCursor(state, destination, picked);
3925
+ } catch (ex) {
3926
+ if (ex instanceof PromptBackError == false) {
3927
+ throw ex;
3830
3928
  }
3831
- draw(buildLines());
3832
- };
3833
- ttyInput.setRawMode?.(true);
3834
- this.output.write(`${ESC}[?25l`);
3835
- draw(buildLines());
3836
- });
3929
+ restoreState(state, before);
3930
+ cursor = picked;
3931
+ }
3932
+ }
3933
+ } finally {
3934
+ prompter.transient = previousTransient;
3837
3935
  }
3838
- };
3936
+ }
3839
3937
  function toMcpServerEntry(value) {
3840
3938
  if (typeof value !== "object" || value == null) {
3841
3939
  return null;
@@ -4568,7 +4666,7 @@ async function addOrEditHost(prompter, output, target, entries, existingName) {
4568
4666
  if (existingName != null) {
4569
4667
  existing = entryToAnswers(existingName, entries[existingName]);
4570
4668
  }
4571
- const answers = await runHostForm(prompter, output, {
4669
+ let answers = await runHostForm(prompter, output, {
4572
4670
  existing,
4573
4671
  takenNames: Object.keys(entries),
4574
4672
  destination: target.label
@@ -4577,18 +4675,40 @@ async function addOrEditHost(prompter, output, target, entries, existingName) {
4577
4675
  output.write(" Cancelled \u2014 nothing was written.\n\n");
4578
4676
  return;
4579
4677
  }
4580
- const entry = answersToEntry(answers);
4581
- output.write(`
4678
+ let entry = answersToEntry(answers);
4679
+ for (; ; ) {
4680
+ output.write(`
4582
4681
  ${prompter.paint.dim(`This goes into ${target.location}:`)}
4583
4682
 
4584
4683
  `);
4585
- output.write(`${indent(formatForTarget(target, answers.name, maskSecrets(entry)))}
4684
+ output.write(`${indent(formatForTarget(target, answers.name, maskSecrets(entry)))}
4586
4685
 
4587
4686
  `);
4588
- const confirmed = await prompter.askYesNo("Write it?", true);
4589
- if (confirmed == false) {
4590
- output.write(" Not written. Pick the host and choose Print to get the entry ready to paste.\n\n");
4591
- return;
4687
+ let confirmed;
4688
+ try {
4689
+ confirmed = await prompter.askYesNo("Write it?", true);
4690
+ } catch (ex) {
4691
+ if (ex instanceof PromptBackError == false) {
4692
+ throw ex;
4693
+ }
4694
+ const revised = await runHostForm(prompter, output, {
4695
+ existing: answers,
4696
+ takenNames: Object.keys(entries),
4697
+ destination: target.label
4698
+ });
4699
+ if (revised == null) {
4700
+ output.write(" Cancelled \u2014 nothing was written.\n\n");
4701
+ return;
4702
+ }
4703
+ answers = revised;
4704
+ entry = answersToEntry(answers);
4705
+ continue;
4706
+ }
4707
+ if (confirmed == false) {
4708
+ output.write(" Not written. Pick the host and choose Print to get the entry ready to paste.\n\n");
4709
+ return;
4710
+ }
4711
+ break;
4592
4712
  }
4593
4713
  const written = target.writeEntry(answers.name, entry);
4594
4714
  reportWrite(prompter, written, "Written");
@@ -4705,7 +4825,7 @@ async function manageHost(prompter, output, target, entries, name) {
4705
4825
  }
4706
4826
  }
4707
4827
  }
4708
- async function runConfigCommand(options) {
4828
+ async function runSetupCommand(options) {
4709
4829
  const { input, output } = options;
4710
4830
  const targets = options.targets ?? listConfigTargets();
4711
4831
  let target = targets[0];
@@ -4805,6 +4925,9 @@ ${paint.bold(target.label)} ${paint.dim(target.location)}
4805
4925
  if (ex instanceof PromptClosedError) {
4806
4926
  throw ex;
4807
4927
  }
4928
+ if (ex instanceof PromptBackError) {
4929
+ continue;
4930
+ }
4808
4931
  output.write(` Failed: ${errorMessage(ex)}
4809
4932
 
4810
4933
  `);
@@ -5512,11 +5635,12 @@ function createSshMcpServer() {
5512
5635
  }
5513
5636
 
5514
5637
  // src/cli.ts
5638
+ var SETUP_COMMAND_NAMES = ["setup", "config"];
5515
5639
  function parseArguments(argv) {
5516
- const parsed = { showHelp: false, showVersion: false, checkOnly: false, configMode: false };
5640
+ const parsed = { showHelp: false, showVersion: false, checkOnly: false, setupMode: false };
5517
5641
  let rest = argv;
5518
- if (argv[0] === "config") {
5519
- parsed.configMode = true;
5642
+ if (argv[0] != null && SETUP_COMMAND_NAMES.includes(argv[0]) == true) {
5643
+ parsed.setupMode = true;
5520
5644
  rest = argv.slice(1);
5521
5645
  }
5522
5646
  for (let index = 0; index < rest.length; index += 1) {
@@ -5550,17 +5674,17 @@ var HELP_TEXT = [
5550
5674
  "",
5551
5675
  "Usage:",
5552
5676
  " akms-mcp-ssh [--check]",
5553
- " akms-mcp-ssh config [--target <client>]",
5677
+ " akms-mcp-ssh setup [--target <client>]",
5554
5678
  "",
5555
5679
  "Commands:",
5556
- " config Manage the SSH hosts registered in an MCP client's config \u2014",
5680
+ " setup Manage the SSH hosts registered in an MCP client's config \u2014",
5557
5681
  " list, add, edit, remove, check, print. Asks plain questions,",
5558
5682
  " so the SSH_* names below never have to be typed.",
5559
5683
  "",
5560
5684
  "Options:",
5561
5685
  " --check Connect to the configured host, report the result, and exit.",
5562
5686
  " Prints the host key fingerprint so it can be pinned.",
5563
- " --target <id> config: claude-code | codex | claude-desktop | cursor",
5687
+ " --target <id> setup: claude-code | codex | claude-desktop | cursor",
5564
5688
  " (default: the first one installed)",
5565
5689
  " -v, --version Print the version and exit",
5566
5690
  " -h, --help Print this help and exit",
@@ -5594,7 +5718,7 @@ var HELP_TEXT = [
5594
5718
  ].join("\n");
5595
5719
  async function runCheckCommand(profile) {
5596
5720
  if (profile == null) {
5597
- 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
5721
+ process.stdout.write(`No SSH host is registered \u2014 set ${SINGLE_HOST_ENV.HOST} and ${SINGLE_HOST_ENV.USER}, or run: akms-mcp-ssh setup
5598
5722
  `);
5599
5723
  return 1;
5600
5724
  }
@@ -5617,11 +5741,11 @@ async function main() {
5617
5741
  `);
5618
5742
  return;
5619
5743
  }
5620
- if (cliArguments.configMode == true) {
5744
+ if (cliArguments.setupMode == true) {
5621
5745
  if (process.env[LOG_LEVEL_ENV] == null) {
5622
5746
  process.env[LOG_LEVEL_ENV] = "warn";
5623
5747
  }
5624
- const exitCode = await runConfigCommand({
5748
+ const exitCode = await runSetupCommand({
5625
5749
  input: process.stdin,
5626
5750
  output: process.stdout,
5627
5751
  targetId: cliArguments.targetId