@akms/mcp-ssh 0.1.2 → 0.1.4

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