@akms/mcp-ssh 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cli.js CHANGED
@@ -26,7 +26,7 @@ var CWD_MARKER = "__AKMS_SSH_CWD__";
26
26
  var MAX_DIRECTORY_ENTRIES = 1e3;
27
27
  var SERVER_NAME = "akms-mcp-ssh";
28
28
  var PACKAGE_NAME = "@akms/mcp-ssh";
29
- var SERVER_VERSION = "0.1.1";
29
+ var SERVER_VERSION = "0.1.3";
30
30
 
31
31
  // src/_defs/command-rules.ts
32
32
  var CRITICAL_PATH_PATTERN = new RegExp(
@@ -2359,6 +2359,23 @@ async function downloadFile(connection, remotePath, localPath) {
2359
2359
  }
2360
2360
 
2361
2361
  // src/modules/manage/connection-check.ts
2362
+ function profileFromEnv(output, env) {
2363
+ try {
2364
+ const profile = loadHostProfile(env);
2365
+ if (profile == null) {
2366
+ output.write(" The entry has no host.\n");
2367
+ }
2368
+ return profile;
2369
+ } catch (ex) {
2370
+ let detail = String(ex);
2371
+ if (ex instanceof Error) {
2372
+ detail = ex.message;
2373
+ }
2374
+ output.write(` The server would refuse to start with this entry: ${detail}
2375
+ `);
2376
+ return null;
2377
+ }
2378
+ }
2362
2379
  function describeAuth(profile) {
2363
2380
  if (profile.privateKeyPath != null) {
2364
2381
  return `key ${profile.privateKeyPath}`;
@@ -2625,15 +2642,25 @@ var PromptClosedError = class extends Error {
2625
2642
  this.name = "PromptClosedError";
2626
2643
  }
2627
2644
  };
2645
+ var PromptBackError = class extends Error {
2646
+ constructor() {
2647
+ super("prompt left with esc");
2648
+ this.name = "PromptBackError";
2649
+ }
2650
+ };
2628
2651
  var ESC = "\x1B";
2629
- var KEY_UP = [`${ESC}[A`, `${ESC}OA`, "k"];
2630
- var KEY_DOWN = [`${ESC}[B`, `${ESC}OB`, "j"];
2631
- var KEY_HOME = [`${ESC}[H`, `${ESC}OH`, `${ESC}[1~`];
2632
- var KEY_END = [`${ESC}[F`, `${ESC}OF`, `${ESC}[4~`];
2652
+ var KEY_UP = [`${ESC}[A`, `${ESC}OA`];
2653
+ var KEY_DOWN = [`${ESC}[B`, `${ESC}OB`];
2654
+ var KEY_RIGHT = [`${ESC}[C`, `${ESC}OC`];
2655
+ var KEY_LEFT = [`${ESC}[D`, `${ESC}OD`];
2656
+ var KEY_HOME = [`${ESC}[H`, `${ESC}OH`, `${ESC}[1~`, ""];
2657
+ var KEY_END = [`${ESC}[F`, `${ESC}OF`, `${ESC}[4~`, ""];
2658
+ var KEY_DELETE = [`${ESC}[3~`];
2633
2659
  var KEY_ENTER = ["\r", "\n"];
2634
2660
  var KEY_BACKSPACE = ["\x7F", "\b"];
2635
2661
  var KEY_CTRL_C = "";
2636
2662
  var KEY_CTRL_D = "";
2663
+ var KEY_CLEAR_LINE = "";
2637
2664
  function splitKeys(chunk) {
2638
2665
  const keys = [];
2639
2666
  let index = 0;
@@ -2655,6 +2682,13 @@ function splitKeys(chunk) {
2655
2682
  }
2656
2683
  return keys;
2657
2684
  }
2685
+ function isPrintableKey(key) {
2686
+ if (key.startsWith(ESC) == true) {
2687
+ return false;
2688
+ }
2689
+ const codePoint = key.codePointAt(0) ?? 0;
2690
+ return codePoint >= 32 && codePoint !== 127;
2691
+ }
2658
2692
  function charWidth(codePoint) {
2659
2693
  if (codePoint < 4352) {
2660
2694
  return 1;
@@ -2729,8 +2763,14 @@ var Prompter = class {
2729
2763
  this.pendingLines = [];
2730
2764
  this.lineWaiter = null;
2731
2765
  this.inputEnded = false;
2732
- /** Set while a key-driven prompt (select, secret) owns the input. */
2766
+ /** Set while a key-driven prompt owns the input. */
2733
2767
  this.keyHandler = null;
2768
+ /**
2769
+ * When true, an answered prompt leaves nothing behind on a TTY instead of its
2770
+ * `✔ question · answer` line. The settings form sets it: the form itself shows every
2771
+ * value, so it can redraw in place rather than scroll a trail of edits.
2772
+ */
2773
+ this.transient = false;
2734
2774
  const ttyInput = input;
2735
2775
  const ttyOutput = output;
2736
2776
  this.interactive = ttyInput.isTTY == true && ttyInput.setRawMode != null && ttyOutput.isTTY == true;
@@ -2774,6 +2814,7 @@ var Prompter = class {
2774
2814
  /**
2775
2815
  * Free-text prompt. Enter accepts `defaultValue` when one is shown.
2776
2816
  *
2817
+ * @throws {PromptBackError} On Esc.
2777
2818
  * @throws {PromptClosedError} If the input closes before a line arrives.
2778
2819
  */
2779
2820
  async ask(question, defaultValue) {
@@ -2782,16 +2823,16 @@ var Prompter = class {
2782
2823
  suffix = ` ${this.style.dim(`(${defaultValue})`)}`;
2783
2824
  }
2784
2825
  const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)}${suffix} `;
2785
- this.output.write(prompt);
2786
- const line = await this.readLine();
2787
- if (line == null) {
2788
- throw new PromptClosedError();
2789
- }
2790
- let answer = line.trim();
2826
+ const typed = await this.readText(prompt, false);
2827
+ let answer = typed.trim();
2791
2828
  if (answer === "" && defaultValue != null) {
2792
2829
  answer = defaultValue;
2793
2830
  }
2794
- this.collapseAnswered(prompt, line, question, answer);
2831
+ let shown = answer;
2832
+ if (shown === "") {
2833
+ shown = this.style.dim("(empty)");
2834
+ }
2835
+ this.writeAnswered(question, shown);
2795
2836
  return answer;
2796
2837
  }
2797
2838
  /** Like `ask`, re-asking until something non-empty is entered. */
@@ -2805,6 +2846,10 @@ var Prompter = class {
2805
2846
  `);
2806
2847
  }
2807
2848
  }
2849
+ /**
2850
+ * @throws {PromptBackError} On Esc — the caller decides what "neither" means.
2851
+ * @throws {PromptClosedError} If the input closes before a line arrives.
2852
+ */
2808
2853
  async askYesNo(question, defaultValue) {
2809
2854
  let hint = "y/N";
2810
2855
  if (defaultValue == true) {
@@ -2812,12 +2857,8 @@ var Prompter = class {
2812
2857
  }
2813
2858
  for (; ; ) {
2814
2859
  const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)} ${this.style.dim(`(${hint})`)} `;
2815
- this.output.write(prompt);
2816
- const line = await this.readLine();
2817
- if (line == null) {
2818
- throw new PromptClosedError();
2819
- }
2820
- const normalized = line.trim().toLowerCase();
2860
+ const typed = await this.readText(prompt, false);
2861
+ const normalized = typed.trim().toLowerCase();
2821
2862
  let answer = null;
2822
2863
  if (normalized === "") {
2823
2864
  answer = defaultValue;
@@ -2831,7 +2872,7 @@ var Prompter = class {
2831
2872
  if (answer == true) {
2832
2873
  shown = "yes";
2833
2874
  }
2834
- this.collapseAnswered(prompt, line, question, shown);
2875
+ this.writeAnswered(question, shown);
2835
2876
  return answer;
2836
2877
  }
2837
2878
  this.output.write(` ${this.style.dim("Answer y or n.")}
@@ -2839,9 +2880,10 @@ var Prompter = class {
2839
2880
  }
2840
2881
  }
2841
2882
  /**
2842
- * Picks one of `items`. On a TTY: ↑/↓ (or k/j) move, 1–9 jump, Enter picks, Esc returns
2843
- * `cancelValue`. Off a TTY: a numbered list, answered by number, key or label.
2883
+ * Picks one of `items`. On a TTY: ↑/↓ move, 1–9 jump, Enter picks, Esc goes back. Off a
2884
+ * TTY: a numbered list, answered by number, key or label.
2844
2885
  *
2886
+ * @throws {PromptBackError} On Esc without a `cancelValue`.
2845
2887
  * @throws {PromptClosedError} On Ctrl+C or when the input closes.
2846
2888
  */
2847
2889
  async select(question, items, options) {
@@ -2853,63 +2895,62 @@ var Prompter = class {
2853
2895
  if (startIndex < 0) {
2854
2896
  startIndex = items.findIndex((item) => "value" in item);
2855
2897
  }
2898
+ let picked;
2856
2899
  if (this.interactive == true) {
2857
- const picked2 = await this.selectWithKeys(question, items, startIndex, options?.cancelValue);
2858
- return picked2;
2900
+ picked = await this.selectWithKeys(question, items, startIndex);
2901
+ } else {
2902
+ picked = await this.selectWithLines(question, items, startIndex);
2859
2903
  }
2860
- const picked = await this.selectWithLines(question, items, startIndex);
2861
- return picked;
2904
+ if (picked != null) {
2905
+ return picked;
2906
+ }
2907
+ if (options?.cancelValue != null) {
2908
+ return options.cancelValue;
2909
+ }
2910
+ throw new PromptBackError();
2862
2911
  }
2863
2912
  /**
2864
2913
  * Reads a value without echoing it on a TTY. Empty means "none", or "keep" when the
2865
2914
  * caller says a value already exists.
2866
2915
  *
2916
+ * @throws {PromptBackError} On Esc.
2867
2917
  * @throws {PromptClosedError} On Ctrl+C or when the input closes.
2868
2918
  */
2869
2919
  async askSecret(question) {
2920
+ const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)} `;
2921
+ const secret = await this.readText(prompt, true);
2922
+ let shown = this.style.dim("(empty)");
2923
+ if (secret !== "") {
2924
+ shown = "\u2022".repeat(Math.min(secret.length, 8));
2925
+ }
2926
+ this.writeAnswered(question, shown);
2927
+ return secret;
2928
+ }
2929
+ // ── input ──────────────────────────────────────────────────────────────
2930
+ /**
2931
+ * One text answer, through the line editor on a TTY and a line read off one. The prompt
2932
+ * is erased once answered; the caller writes the summary line.
2933
+ *
2934
+ * @throws {PromptBackError} On Esc, or a line of just `ESC` off a TTY.
2935
+ */
2936
+ async readText(prompt, secret) {
2870
2937
  if (this.interactive == false) {
2871
- const answer = await this.ask(question);
2872
- return answer;
2938
+ this.output.write(prompt);
2939
+ const line = await this.readLine();
2940
+ if (line == null) {
2941
+ throw new PromptClosedError();
2942
+ }
2943
+ if (line === ESC) {
2944
+ throw new PromptBackError();
2945
+ }
2946
+ return line;
2873
2947
  }
2874
- const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)} `;
2875
- this.output.write(prompt);
2876
- const ttyInput = this.input;
2877
- return new Promise((resolve, reject) => {
2878
- let buffer = "";
2879
- const finish = () => {
2880
- this.keyHandler = null;
2881
- ttyInput.setRawMode?.(false);
2882
- this.output.write(`\r${ESC}[2K`);
2883
- };
2884
- this.keyHandler = (key) => {
2885
- if (key === KEY_CTRL_C || key === KEY_CTRL_D) {
2886
- finish();
2887
- reject(new PromptClosedError());
2888
- return;
2889
- }
2890
- if (KEY_ENTER.includes(key) == true) {
2891
- finish();
2892
- let shown = this.style.dim("(empty)");
2893
- if (buffer !== "") {
2894
- shown = "\u2022".repeat(Math.min(buffer.length, 8));
2895
- }
2896
- this.writeAnswered(question, shown);
2897
- resolve(buffer);
2898
- return;
2899
- }
2900
- if (KEY_BACKSPACE.includes(key) == true) {
2901
- buffer = Array.from(buffer).slice(0, -1).join("");
2902
- return;
2903
- }
2904
- if (key.startsWith(ESC) == true) {
2905
- return;
2906
- }
2907
- buffer += key;
2908
- };
2909
- ttyInput.setRawMode?.(true);
2910
- });
2948
+ const result = await this.editLine(prompt, secret);
2949
+ if (result.kind === "back") {
2950
+ throw new PromptBackError();
2951
+ }
2952
+ return result.text;
2911
2953
  }
2912
- // ── line mode ──────────────────────────────────────────────────────────
2913
2954
  readLine() {
2914
2955
  const queued = this.pendingLines.shift();
2915
2956
  if (queued != null) {
@@ -2947,6 +2988,78 @@ var Prompter = class {
2947
2988
  newlineIndex = this.lineBuffer.indexOf("\n");
2948
2989
  }
2949
2990
  }
2991
+ /**
2992
+ * A one-line editor in raw mode: insert at the cursor, Backspace / Delete, ←/→, Home /
2993
+ * End, Ctrl+U clears, Enter answers, Esc goes back. Secrets draw nothing after the
2994
+ * prompt. Cooked mode is not used for text on a TTY because it never reports Esc: the
2995
+ * terminal swallows it or stuffs the `ESC` byte into the value.
2996
+ */
2997
+ editLine(prompt, secret) {
2998
+ const ttyInput = this.input;
2999
+ const plainPrompt = prompt.replace(/\u001b\[[0-9;]*m/g, "");
3000
+ let characters = [];
3001
+ let cursor = 0;
3002
+ const draw = () => {
3003
+ let text = `\r${ESC}[2K${prompt}`;
3004
+ if (secret == false) {
3005
+ text += characters.join("");
3006
+ const column = displayWidth(plainPrompt) + displayWidth(characters.slice(0, cursor).join(""));
3007
+ text += `\r${ESC}[${column}C`;
3008
+ }
3009
+ this.output.write(text);
3010
+ };
3011
+ return new Promise((resolve, reject) => {
3012
+ const finish = () => {
3013
+ this.keyHandler = null;
3014
+ ttyInput.setRawMode?.(false);
3015
+ this.output.write(`\r${ESC}[2K`);
3016
+ };
3017
+ this.keyHandler = (key) => {
3018
+ if (key === KEY_CTRL_C || key === KEY_CTRL_D) {
3019
+ finish();
3020
+ reject(new PromptClosedError());
3021
+ return;
3022
+ }
3023
+ if (KEY_ENTER.includes(key) == true) {
3024
+ finish();
3025
+ resolve({ kind: "entered", text: characters.join("") });
3026
+ return;
3027
+ }
3028
+ if (key === ESC) {
3029
+ finish();
3030
+ resolve({ kind: "back" });
3031
+ return;
3032
+ }
3033
+ if (KEY_BACKSPACE.includes(key) == true) {
3034
+ if (cursor > 0) {
3035
+ characters.splice(cursor - 1, 1);
3036
+ cursor -= 1;
3037
+ }
3038
+ } else if (KEY_DELETE.includes(key) == true) {
3039
+ characters.splice(cursor, 1);
3040
+ } else if (KEY_LEFT.includes(key) == true) {
3041
+ cursor = Math.max(0, cursor - 1);
3042
+ } else if (KEY_RIGHT.includes(key) == true) {
3043
+ cursor = Math.min(characters.length, cursor + 1);
3044
+ } else if (KEY_HOME.includes(key) == true) {
3045
+ cursor = 0;
3046
+ } else if (KEY_END.includes(key) == true) {
3047
+ cursor = characters.length;
3048
+ } else if (key === KEY_CLEAR_LINE) {
3049
+ characters = [];
3050
+ cursor = 0;
3051
+ } else if (isPrintableKey(key) == true) {
3052
+ characters.splice(cursor, 0, key);
3053
+ cursor += 1;
3054
+ } else {
3055
+ return;
3056
+ }
3057
+ draw();
3058
+ };
3059
+ ttyInput.setRawMode?.(true);
3060
+ draw();
3061
+ });
3062
+ }
2950
3063
  async selectWithLines(question, items, startIndex) {
2951
3064
  const choices = items.filter((item) => "value" in item);
2952
3065
  const labelWidths = groupLabelWidths(items);
@@ -2975,6 +3088,9 @@ var Prompter = class {
2975
3088
  if (line == null) {
2976
3089
  throw new PromptClosedError();
2977
3090
  }
3091
+ if (line === ESC) {
3092
+ return null;
3093
+ }
2978
3094
  const answer = line.trim();
2979
3095
  if (answer === "") {
2980
3096
  return defaultChoice.value;
@@ -3030,28 +3146,14 @@ var Prompter = class {
3030
3146
  }
3031
3147
  /** The one-line record an answered prompt leaves behind on a TTY. */
3032
3148
  writeAnswered(question, answer) {
3033
- this.output.write(`${this.style.success(this.symbols.tick)} ${question} ${this.style.dim("\xB7")} ${this.style.accent(answer)}
3034
- `);
3035
- }
3036
- /**
3037
- * Replaces a cooked-mode prompt and its echoed answer with the one-line record. The
3038
- * rows are worked out from the widths, since a long answer wraps.
3039
- */
3040
- collapseAnswered(prompt, typed, question, answer) {
3041
- if (this.interactive == false) {
3149
+ if (this.interactive == false || this.transient == true) {
3042
3150
  return;
3043
3151
  }
3044
- const plainPrompt = prompt.replace(/\u001b\[[0-9;]*m/g, "");
3045
- const used = displayWidth(plainPrompt) + displayWidth(typed);
3046
- const rows = Math.max(1, Math.ceil(used / this.columns()));
3047
- this.output.write(`${ESC}[${rows}A\r${ESC}[J`);
3048
- let shown = answer;
3049
- if (shown === "") {
3050
- shown = this.style.dim("(empty)");
3051
- }
3052
- this.writeAnswered(question, shown);
3152
+ this.output.write(`${this.style.success(this.symbols.tick)} ${question} ${this.style.dim("\xB7")} ${this.style.accent(answer)}
3153
+ `);
3053
3154
  }
3054
- selectWithKeys(question, items, startIndex, cancelValue) {
3155
+ /** @returns The picked value, or null on Esc. */
3156
+ selectWithKeys(question, items, startIndex) {
3055
3157
  const ttyInput = this.input;
3056
3158
  const selectableIndexes = items.flatMap((item, index) => {
3057
3159
  if ("value" in item) {
@@ -3101,11 +3203,7 @@ var Prompter = class {
3101
3203
  }
3102
3204
  lines.push(this.fitLine(parts, width));
3103
3205
  }
3104
- let footer = "\u2191\u2193 move \xB7 enter select";
3105
- if (cancelValue != null) {
3106
- footer += " \xB7 esc back";
3107
- }
3108
- lines.push(this.fitLine([{ text: footer, paint: this.style.dim }], width));
3206
+ lines.push(this.fitLine([{ text: "\u2191\u2193 move \xB7 enter select \xB7 esc back", paint: this.style.dim }], width));
3109
3207
  return lines;
3110
3208
  };
3111
3209
  const move = (step) => {
@@ -3134,11 +3232,8 @@ var Prompter = class {
3134
3232
  return;
3135
3233
  }
3136
3234
  if (key === ESC) {
3137
- if (cancelValue == null) {
3138
- return;
3139
- }
3140
3235
  finish();
3141
- resolve(cancelValue);
3236
+ resolve(null);
3142
3237
  return;
3143
3238
  }
3144
3239
  if (KEY_UP.includes(key) == true) {
@@ -3162,11 +3257,47 @@ var Prompter = class {
3162
3257
  });
3163
3258
  }
3164
3259
  };
3260
+
3261
+ // src/modules/manage/host-form.ts
3165
3262
  var PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
3166
3263
  var NON_KEY_FILE_NAMES = ["config", "known_hosts", "known_hosts.old", "authorized_keys", "environment"];
3167
3264
  var MAX_KEY_FILE_BYTES = 64 * 1024;
3168
3265
  var CLEAR_ANSWER = "-";
3169
3266
  var OTHER_KEY_PATH = "\0other";
3267
+ var FIELD_NAME_WIDTH = 17;
3268
+ var SUB_FIELDS = ["key", "passphrase", "password", "jump-host", "jump-port", "jump-user", "proxy"];
3269
+ var FIELD_NAMES = {
3270
+ "host": "Host",
3271
+ "port": "Port",
3272
+ "user": "User",
3273
+ "name": "Server name",
3274
+ "auth": "Authentication",
3275
+ "key": "Private key",
3276
+ "passphrase": "Passphrase",
3277
+ "password": "Password",
3278
+ "route": "Route",
3279
+ "jump-host": "Jump host",
3280
+ "jump-port": "Jump port",
3281
+ "jump-user": "Jump user",
3282
+ "proxy": "Proxy command",
3283
+ "description": "Description",
3284
+ "readonly": "Read-only",
3285
+ "paths": "File paths",
3286
+ "host-key": "Host key",
3287
+ "jump-host-key": "Jump host key"
3288
+ };
3289
+ var AUTH_LABELS = {
3290
+ key: "Private key file",
3291
+ password: "Password",
3292
+ agent: "SSH agent"
3293
+ };
3294
+ var ROUTE_LABELS = {
3295
+ "direct": "Directly",
3296
+ "jump": "Through a jump host",
3297
+ "proxy": "Through a proxy command",
3298
+ "jump-proxy": "Proxy command, then a jump host"
3299
+ };
3300
+ var REQUIRED_MARK = "(required)";
3170
3301
  function findPrivateKeys() {
3171
3302
  const sshDirectory = path.join(os5.homedir(), ".ssh");
3172
3303
  let names = [];
@@ -3282,121 +3413,526 @@ async function askKeyPath(prompter, current) {
3282
3413
  }
3283
3414
  }
3284
3415
  }
3285
- async function askHostQuestions(prompter, existing) {
3286
- if (existing != null) {
3287
- prompter.write(prompter.paint.dim(` Enter keeps the value in brackets; ${CLEAR_ANSWER} clears an optional one.
3288
-
3289
- `));
3416
+ function routeUsesJump(route) {
3417
+ return route === "jump" || route === "jump-proxy";
3418
+ }
3419
+ function routeUsesProxy(route) {
3420
+ return route === "proxy" || route === "jump-proxy";
3421
+ }
3422
+ function deriveServerName(host) {
3423
+ return `ssh-${host}`.replace(/[^A-Za-z0-9_-]+/g, "-").slice(0, 48);
3424
+ }
3425
+ function finalizeAnswers(state) {
3426
+ const answers = { ...state.answers };
3427
+ if (routeUsesJump(state.route) == false) {
3428
+ answers.jumpHost = void 0;
3429
+ answers.jumpPort = void 0;
3430
+ answers.jumpUser = void 0;
3431
+ answers.jumpHostKeyFingerprint = void 0;
3290
3432
  }
3291
- const host = await prompter.askRequired("Host (hostname or IP)", existing?.host);
3292
- const port = await askPort(prompter, "Port", existing?.port ?? DEFAULT_SSH_PORT);
3293
- const username = await prompter.askRequired("Login user", existing?.username);
3294
- let name = "";
3295
- const defaultName = existing?.name ?? `ssh-${host}`.replace(/[^A-Za-z0-9_-]+/g, "-").slice(0, 48);
3296
- for (; ; ) {
3297
- name = await prompter.askRequired("Server name the agent sees", defaultName);
3298
- if (SERVER_NAME_PATTERN.test(name) == true) {
3299
- break;
3433
+ if (routeUsesProxy(state.route) == false) {
3434
+ answers.proxyCommand = void 0;
3435
+ }
3436
+ if (answers.auth !== "key") {
3437
+ answers.keyPath = void 0;
3438
+ answers.passphrase = void 0;
3439
+ }
3440
+ if (answers.auth !== "password") {
3441
+ answers.password = void 0;
3442
+ }
3443
+ return answers;
3444
+ }
3445
+ function snapshotOf(state) {
3446
+ return JSON.stringify(finalizeAnswers(state));
3447
+ }
3448
+ function cloneState(state) {
3449
+ return {
3450
+ ...state,
3451
+ answers: {
3452
+ ...state.answers,
3453
+ allowedPaths: [...state.answers.allowedPaths],
3454
+ extraEnv: { ...state.answers.extraEnv }
3300
3455
  }
3301
- prompter.write(" Use letters, digits, '-' and '_' only (max 48) \u2014 the name becomes part of every tool name.\n");
3456
+ };
3457
+ }
3458
+ function restoreState(state, snapshot) {
3459
+ const restored = cloneState(snapshot);
3460
+ state.answers = restored.answers;
3461
+ state.route = restored.route;
3462
+ state.nameTouched = restored.nameTouched;
3463
+ state.lastTest = restored.lastTest;
3464
+ state.testedSnapshot = restored.testedSnapshot;
3465
+ }
3466
+ function missingFields(state) {
3467
+ const { answers } = state;
3468
+ const missing = [];
3469
+ if (answers.host === "") {
3470
+ missing.push("host");
3302
3471
  }
3303
- const auth = await prompter.select(
3304
- "Authentication",
3305
- [
3306
- { value: "key", label: "Private key file", hint: "only the path is stored \u2014 recommended" },
3307
- { value: "password", label: "Password", hint: "stored in the config file as plain text" },
3308
- { value: "agent", label: "SSH agent", hint: "keys already loaded in ssh-agent" }
3309
- ],
3310
- { defaultValue: existing?.auth ?? "key" }
3311
- );
3312
- let keyPath;
3313
- let passphrase;
3314
- let password;
3315
- if (auth === "key") {
3316
- keyPath = await askKeyPath(prompter, existing?.keyPath);
3317
- passphrase = await askOptionalSecret(prompter, "Key passphrase", existing?.passphrase);
3318
- }
3319
- if (auth === "password") {
3320
- password = await askOptionalSecret(prompter, "Password", existing?.password);
3321
- if (password == null) {
3322
- prompter.write(" No password given \u2014 falling back to the ssh agent.\n");
3323
- }
3324
- }
3325
- let currentRoute = "direct";
3326
- if (existing?.jumpHost != null && existing.proxyCommand != null) {
3327
- currentRoute = "jump-proxy";
3328
- } else if (existing?.jumpHost != null) {
3329
- currentRoute = "jump";
3330
- } else if (existing?.proxyCommand != null) {
3331
- currentRoute = "proxy";
3332
- }
3333
- const route = await prompter.select(
3334
- "How is it reached?",
3335
- [
3336
- { value: "direct", label: "Directly" },
3337
- { value: "jump", label: "Through a jump host", hint: "ssh -J" },
3338
- { value: "proxy", label: "Through a proxy command", hint: "e.g. cloudflared access ssh" },
3339
- { value: "jump-proxy", label: "Proxy command, then a jump host", hint: "cloudflared to the bastion, then on to the host" }
3340
- ],
3341
- { defaultValue: currentRoute }
3342
- );
3343
- let jumpHost;
3344
- let jumpPort;
3345
- let jumpUser;
3346
- let proxyCommand;
3347
- if (route === "jump" || route === "jump-proxy") {
3348
- jumpHost = await prompter.askRequired("Jump host", existing?.jumpHost);
3349
- jumpPort = await askPort(prompter, "Jump host port", existing?.jumpPort ?? DEFAULT_SSH_PORT);
3350
- jumpUser = await prompter.askRequired("Jump host user", existing?.jumpUser ?? username);
3351
- }
3352
- if (route === "proxy" || route === "jump-proxy") {
3353
- prompter.write(prompter.paint.dim(" %h / %p / %r expand to the first hop's host, port and user.\n"));
3354
- proxyCommand = await prompter.askRequired("Proxy command", existing?.proxyCommand ?? "cloudflared access ssh --hostname %h");
3355
- }
3356
- const description = await askOptional(prompter, "Description shown to the agent", existing?.description);
3357
- const readonly = await prompter.askYesNo("Read-only? (rejects writes, redirections, uploads)", existing?.readonly ?? false);
3358
- let currentAllowedPaths;
3359
- if (existing != null && existing.allowedPaths.length > 0) {
3360
- currentAllowedPaths = existing.allowedPaths.join(",");
3472
+ if (answers.username === "") {
3473
+ missing.push("user");
3361
3474
  }
3362
- let allowedPaths = [];
3363
- const allowedPathsAnswer = await askOptional(
3364
- prompter,
3365
- "Limit file tools to path prefixes (comma-separated, empty = anywhere)",
3366
- currentAllowedPaths
3367
- );
3368
- if (allowedPathsAnswer != null) {
3369
- allowedPaths = allowedPathsAnswer.split(",").map((item) => item.trim()).filter((item) => item !== "");
3475
+ if (answers.auth === "key" && answers.keyPath == null) {
3476
+ missing.push("key");
3370
3477
  }
3371
- let resolvedAuth = auth;
3372
- if (auth === "password" && password == null) {
3373
- resolvedAuth = "agent";
3478
+ if (answers.auth === "password" && answers.password == null) {
3479
+ missing.push("password");
3374
3480
  }
3375
- const answers = {
3376
- name,
3377
- host,
3378
- port,
3379
- username,
3380
- auth: resolvedAuth,
3381
- keyPath,
3382
- passphrase,
3383
- password,
3384
- description,
3385
- jumpHost,
3386
- jumpPort,
3387
- jumpUser,
3388
- proxyCommand,
3389
- readonly,
3390
- allowedPaths,
3391
- extraEnv: existing?.extraEnv ?? {}
3481
+ if (routeUsesJump(state.route) == true && answers.jumpHost == null) {
3482
+ missing.push("jump-host");
3483
+ }
3484
+ if (routeUsesProxy(state.route) == true && answers.proxyCommand == null) {
3485
+ missing.push("proxy");
3486
+ }
3487
+ return missing;
3488
+ }
3489
+ function describePin(fingerprint) {
3490
+ if (fingerprint == null) {
3491
+ return "not pinned";
3492
+ }
3493
+ return `pinned \xB7 ${fingerprint.slice(0, 19)}\u2026`;
3494
+ }
3495
+ function valueOrRequired(value) {
3496
+ if (value == null || value === "") {
3497
+ return REQUIRED_MARK;
3498
+ }
3499
+ return value;
3500
+ }
3501
+ function fieldRow(id, value, hint) {
3502
+ let name = FIELD_NAMES[id];
3503
+ if (SUB_FIELDS.includes(id) == true) {
3504
+ name = ` ${name}`;
3505
+ }
3506
+ return {
3507
+ value: id,
3508
+ key: id,
3509
+ label: `${name.padEnd(FIELD_NAME_WIDTH)}${value}`,
3510
+ hint
3392
3511
  };
3393
- if (existing != null && existing.host === host && existing.port === port) {
3394
- answers.hostKeyFingerprint = existing.hostKeyFingerprint;
3512
+ }
3513
+ function buildRows(state, destination) {
3514
+ const { answers } = state;
3515
+ const rows = [];
3516
+ rows.push(fieldRow("host", valueOrRequired(answers.host)));
3517
+ rows.push(fieldRow("port", String(answers.port)));
3518
+ rows.push(fieldRow("user", valueOrRequired(answers.username)));
3519
+ let nameValue = answers.name;
3520
+ let nameHint = "what the agent sees";
3521
+ if (state.nameTouched == false) {
3522
+ nameHint = "follows the host until you change it";
3523
+ }
3524
+ if (nameValue === "") {
3525
+ nameValue = "ssh-<host>";
3526
+ }
3527
+ rows.push(fieldRow("name", nameValue, nameHint));
3528
+ rows.push(fieldRow("auth", AUTH_LABELS[answers.auth]));
3529
+ if (answers.auth === "key") {
3530
+ rows.push(fieldRow("key", valueOrRequired(answers.keyPath)));
3531
+ let passphrase = "none";
3532
+ if (answers.passphrase != null) {
3533
+ passphrase = "set";
3534
+ }
3535
+ rows.push(fieldRow("passphrase", passphrase));
3536
+ }
3537
+ if (answers.auth === "password") {
3538
+ let password = REQUIRED_MARK;
3539
+ if (answers.password != null) {
3540
+ password = "set";
3541
+ }
3542
+ rows.push(fieldRow("password", password, "stored in the config file as plain text"));
3543
+ }
3544
+ rows.push(fieldRow("route", ROUTE_LABELS[state.route]));
3545
+ if (routeUsesJump(state.route) == true) {
3546
+ rows.push(fieldRow("jump-host", valueOrRequired(answers.jumpHost)));
3547
+ rows.push(fieldRow("jump-port", String(answers.jumpPort ?? DEFAULT_SSH_PORT)));
3548
+ let jumpUser = answers.jumpUser;
3549
+ if (jumpUser == null || jumpUser === answers.username) {
3550
+ let loginUser = answers.username;
3551
+ if (loginUser === "") {
3552
+ loginUser = "\u2026";
3553
+ }
3554
+ jumpUser = `same as login (${loginUser})`;
3555
+ }
3556
+ rows.push(fieldRow("jump-user", jumpUser));
3395
3557
  }
3396
- if (existing != null && existing.jumpHost === jumpHost && existing.jumpPort === jumpPort) {
3397
- answers.jumpHostKeyFingerprint = existing.jumpHostKeyFingerprint;
3558
+ if (routeUsesProxy(state.route) == true) {
3559
+ rows.push(fieldRow("proxy", valueOrRequired(answers.proxyCommand), "%h %p %r \u2192 first hop"));
3560
+ }
3561
+ rows.push(fieldRow("description", answers.description ?? "none", "shown to the agent"));
3562
+ let readonly = "no";
3563
+ if (answers.readonly == true) {
3564
+ readonly = "yes";
3565
+ }
3566
+ rows.push(fieldRow("readonly", readonly, "enter toggles"));
3567
+ let paths = "anywhere";
3568
+ if (answers.allowedPaths.length > 0) {
3569
+ paths = answers.allowedPaths.join(", ");
3570
+ }
3571
+ rows.push(fieldRow("paths", paths, "where the file tools may read and write"));
3572
+ rows.push(fieldRow("host-key", describePin(answers.hostKeyFingerprint)));
3573
+ if (routeUsesJump(state.route) == true) {
3574
+ rows.push(fieldRow("jump-host-key", describePin(answers.jumpHostKeyFingerprint)));
3575
+ }
3576
+ rows.push({ separator: true });
3577
+ let testHint = "not run yet";
3578
+ if (state.lastTest === "ok") {
3579
+ testHint = "last run: OK";
3580
+ } else if (state.lastTest === "failed") {
3581
+ testHint = "last run: failed";
3582
+ }
3583
+ rows.push({ value: "test", key: "test", label: "Test the connection", hint: testHint });
3584
+ rows.push({ value: "save", key: "save", label: "Save", hint: `to ${destination}` });
3585
+ rows.push({ value: "cancel", key: "cancel", label: "Cancel" });
3586
+ return rows;
3587
+ }
3588
+ function nextCursor(state, destination, edited) {
3589
+ if (edited === "readonly") {
3590
+ return edited;
3591
+ }
3592
+ const values = buildRows(state, destination).flatMap((row) => {
3593
+ if ("value" in row) {
3594
+ return [row.value];
3595
+ }
3596
+ return [];
3597
+ });
3598
+ let index = values.indexOf(edited) + 1;
3599
+ if (SUB_FIELDS.includes(edited) == false) {
3600
+ while (index < values.length && SUB_FIELDS.includes(values[index]) == true) {
3601
+ index += 1;
3602
+ }
3603
+ }
3604
+ const next = values[index];
3605
+ if (next == null || next === "test") {
3606
+ return "save";
3607
+ }
3608
+ return next;
3609
+ }
3610
+ async function testConnection(prompter, output, state) {
3611
+ const answers = finalizeAnswers(state);
3612
+ output.write("\n");
3613
+ const profile = profileFromEnv(output, answersToEntry(answers).env);
3614
+ if (profile == null) {
3615
+ output.write("\n");
3616
+ state.lastTest = "failed";
3617
+ return false;
3618
+ }
3619
+ const result = await runConnectionCheck(profile, output);
3620
+ output.write("\n");
3621
+ if (result.connected == true) {
3622
+ if (result.hostKeyFingerprint != null && state.answers.hostKeyFingerprint == null) {
3623
+ const pin = await prompter.askYesNo("Pin this host key? (a changed key then aborts the connection)", true);
3624
+ if (pin == true) {
3625
+ state.answers.hostKeyFingerprint = result.hostKeyFingerprint;
3626
+ }
3627
+ }
3628
+ if (result.jumpHostKeyFingerprint != null && routeUsesJump(state.route) == true && state.answers.jumpHostKeyFingerprint == null) {
3629
+ const pin = await prompter.askYesNo("Pin the jump host key too?", true);
3630
+ if (pin == true) {
3631
+ state.answers.jumpHostKeyFingerprint = result.jumpHostKeyFingerprint;
3632
+ }
3633
+ }
3634
+ state.lastTest = "ok";
3635
+ state.testedSnapshot = snapshotOf(state);
3636
+ return true;
3637
+ }
3638
+ const hostKeyChanged = result.hostKeyFingerprint != null && state.answers.hostKeyFingerprint != null && result.hostKeyFingerprint !== state.answers.hostKeyFingerprint;
3639
+ if (hostKeyChanged == true) {
3640
+ output.write(" The host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
3641
+ const repin = await prompter.askYesNo(`Pin ${result.hostKeyFingerprint} instead?`, false);
3642
+ if (repin == true) {
3643
+ state.answers.hostKeyFingerprint = result.hostKeyFingerprint ?? void 0;
3644
+ output.write(" Pinned the new key \u2014 test again to confirm.\n");
3645
+ }
3646
+ }
3647
+ const jumpKeyChanged = result.jumpHostKeyFingerprint != null && state.answers.jumpHostKeyFingerprint != null && result.jumpHostKeyFingerprint !== state.answers.jumpHostKeyFingerprint;
3648
+ if (jumpKeyChanged == true) {
3649
+ output.write(" The jump host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
3650
+ const repin = await prompter.askYesNo(`Pin ${result.jumpHostKeyFingerprint} instead?`, false);
3651
+ if (repin == true) {
3652
+ state.answers.jumpHostKeyFingerprint = result.jumpHostKeyFingerprint ?? void 0;
3653
+ output.write(" Pinned the new key \u2014 test again to confirm.\n");
3654
+ }
3655
+ }
3656
+ state.lastTest = "failed";
3657
+ return false;
3658
+ }
3659
+ async function editField(prompter, output, state, id) {
3660
+ const { answers } = state;
3661
+ if (id === "host") {
3662
+ let current;
3663
+ if (answers.host !== "") {
3664
+ current = answers.host;
3665
+ }
3666
+ answers.host = await prompter.askRequired("Host (hostname or IP)", current);
3667
+ if (state.nameTouched == false) {
3668
+ answers.name = deriveServerName(answers.host);
3669
+ }
3670
+ return;
3671
+ }
3672
+ if (id === "port") {
3673
+ answers.port = await askPort(prompter, "Port", answers.port);
3674
+ return;
3675
+ }
3676
+ if (id === "user") {
3677
+ let current;
3678
+ if (answers.username !== "") {
3679
+ current = answers.username;
3680
+ }
3681
+ answers.username = await prompter.askRequired("Login user", current);
3682
+ return;
3683
+ }
3684
+ if (id === "name") {
3685
+ let current;
3686
+ if (answers.name !== "") {
3687
+ current = answers.name;
3688
+ }
3689
+ for (; ; ) {
3690
+ const name = await prompter.askRequired("Server name the agent sees", current);
3691
+ if (SERVER_NAME_PATTERN.test(name) == true) {
3692
+ answers.name = name;
3693
+ state.nameTouched = true;
3694
+ return;
3695
+ }
3696
+ prompter.write(" Use letters, digits, '-' and '_' only (max 48) \u2014 the name becomes part of every tool name.\n");
3697
+ }
3698
+ }
3699
+ if (id === "auth") {
3700
+ answers.auth = await prompter.select(
3701
+ "Authentication",
3702
+ [
3703
+ { value: "key", label: AUTH_LABELS.key, hint: "only the path is stored \u2014 recommended" },
3704
+ { value: "password", label: AUTH_LABELS.password, hint: "stored in the config file as plain text" },
3705
+ { value: "agent", label: AUTH_LABELS.agent, hint: "keys already loaded in ssh-agent" }
3706
+ ],
3707
+ { defaultValue: answers.auth }
3708
+ );
3709
+ if (answers.auth === "key" && answers.keyPath == null) {
3710
+ answers.keyPath = await askKeyPath(prompter, void 0);
3711
+ }
3712
+ if (answers.auth === "password" && answers.password == null) {
3713
+ answers.password = await askOptionalSecret(prompter, "Password", void 0);
3714
+ }
3715
+ return;
3716
+ }
3717
+ if (id === "key") {
3718
+ answers.keyPath = await askKeyPath(prompter, answers.keyPath);
3719
+ return;
3720
+ }
3721
+ if (id === "passphrase") {
3722
+ answers.passphrase = await askOptionalSecret(prompter, "Key passphrase", answers.passphrase);
3723
+ return;
3724
+ }
3725
+ if (id === "password") {
3726
+ answers.password = await askOptionalSecret(prompter, "Password", answers.password);
3727
+ return;
3728
+ }
3729
+ if (id === "route") {
3730
+ state.route = await prompter.select(
3731
+ "Route",
3732
+ [
3733
+ { value: "direct", label: ROUTE_LABELS.direct },
3734
+ { value: "jump", label: ROUTE_LABELS.jump, hint: "ssh -J" },
3735
+ { value: "proxy", label: ROUTE_LABELS.proxy, hint: "e.g. cloudflared access ssh" },
3736
+ { value: "jump-proxy", label: ROUTE_LABELS["jump-proxy"], hint: "cloudflared to the bastion, then on to the host" }
3737
+ ],
3738
+ { defaultValue: state.route }
3739
+ );
3740
+ if (routeUsesJump(state.route) == true && answers.jumpHost == null) {
3741
+ answers.jumpHost = await prompter.askRequired("Jump host");
3742
+ }
3743
+ if (routeUsesProxy(state.route) == true && answers.proxyCommand == null) {
3744
+ answers.proxyCommand = await prompter.askRequired("Proxy command", "cloudflared access ssh --hostname %h");
3745
+ }
3746
+ return;
3747
+ }
3748
+ if (id === "jump-host") {
3749
+ answers.jumpHost = await prompter.askRequired("Jump host", answers.jumpHost);
3750
+ return;
3751
+ }
3752
+ if (id === "jump-port") {
3753
+ answers.jumpPort = await askPort(prompter, "Jump host port", answers.jumpPort ?? DEFAULT_SSH_PORT);
3754
+ return;
3755
+ }
3756
+ if (id === "jump-user") {
3757
+ answers.jumpUser = await askOptional(prompter, "Jump host user (empty = same as the login user)", answers.jumpUser);
3758
+ return;
3759
+ }
3760
+ if (id === "proxy") {
3761
+ output.write(prompter.paint.dim(" %h / %p / %r expand to the first hop's host, port and user.\n"));
3762
+ answers.proxyCommand = await prompter.askRequired("Proxy command", answers.proxyCommand ?? "cloudflared access ssh --hostname %h");
3763
+ return;
3764
+ }
3765
+ if (id === "description") {
3766
+ answers.description = await askOptional(prompter, "Description shown to the agent", answers.description);
3767
+ return;
3768
+ }
3769
+ if (id === "readonly") {
3770
+ answers.readonly = answers.readonly == false;
3771
+ return;
3772
+ }
3773
+ if (id === "paths") {
3774
+ let current;
3775
+ if (answers.allowedPaths.length > 0) {
3776
+ current = answers.allowedPaths.join(",");
3777
+ }
3778
+ const typed = await askOptional(prompter, "Limit file tools to path prefixes (comma-separated, empty = anywhere)", current);
3779
+ answers.allowedPaths = [];
3780
+ if (typed != null) {
3781
+ answers.allowedPaths = typed.split(",").map((item) => item.trim()).filter((item) => item !== "");
3782
+ }
3783
+ return;
3784
+ }
3785
+ const isJump = id === "jump-host-key";
3786
+ let pinned = answers.hostKeyFingerprint;
3787
+ if (isJump == true) {
3788
+ pinned = answers.jumpHostKeyFingerprint;
3789
+ }
3790
+ if (pinned != null) {
3791
+ const unpin = await prompter.askYesNo(`Unpin ${pinned}?`, false);
3792
+ if (unpin == true && isJump == true) {
3793
+ answers.jumpHostKeyFingerprint = void 0;
3794
+ } else if (unpin == true) {
3795
+ answers.hostKeyFingerprint = void 0;
3796
+ }
3797
+ return;
3798
+ }
3799
+ const missing = missingFields(state);
3800
+ if (missing.length > 0) {
3801
+ output.write(` Fill in ${missing.map((field) => FIELD_NAMES[field]).join(", ")} first \u2014 the key is pinned by testing the connection.
3802
+ `);
3803
+ return;
3804
+ }
3805
+ await testConnection(prompter, output, state);
3806
+ }
3807
+ async function runHostForm(prompter, output, options) {
3808
+ const { existing, takenNames, destination } = options;
3809
+ let state;
3810
+ if (existing != null) {
3811
+ let route = "direct";
3812
+ if (existing.jumpHost != null && existing.proxyCommand != null) {
3813
+ route = "jump-proxy";
3814
+ } else if (existing.jumpHost != null) {
3815
+ route = "jump";
3816
+ } else if (existing.proxyCommand != null) {
3817
+ route = "proxy";
3818
+ }
3819
+ state = { answers: { ...existing }, route, nameTouched: true, lastTest: "never", testedSnapshot: null };
3820
+ } else {
3821
+ const foundKeys = findPrivateKeys();
3822
+ state = {
3823
+ answers: {
3824
+ name: "",
3825
+ host: "",
3826
+ port: DEFAULT_SSH_PORT,
3827
+ username: "",
3828
+ auth: "key",
3829
+ keyPath: foundKeys[0]?.path,
3830
+ readonly: false,
3831
+ allowedPaths: [],
3832
+ extraEnv: {}
3833
+ },
3834
+ route: "direct",
3835
+ nameTouched: false,
3836
+ lastTest: "never",
3837
+ testedSnapshot: null
3838
+ };
3839
+ }
3840
+ const initialSnapshot = snapshotOf(state);
3841
+ const originalName = existing?.name;
3842
+ let title = "New host";
3843
+ if (existing != null) {
3844
+ title = `Editing ${existing.name}`;
3845
+ }
3846
+ output.write(prompter.paint.dim(`
3847
+ Pick a setting to change it. In a text field, Enter keeps the value shown and ${CLEAR_ANSWER} clears it.
3848
+
3849
+ `));
3850
+ const previousTransient = prompter.transient;
3851
+ prompter.transient = true;
3852
+ try {
3853
+ let cursor = "host";
3854
+ for (; ; ) {
3855
+ const picked = await prompter.select(title, buildRows(state, destination), { defaultValue: cursor, cancelValue: "cancel" });
3856
+ const before = cloneState(state);
3857
+ try {
3858
+ if (picked === "cancel") {
3859
+ if (snapshotOf(state) !== initialSnapshot) {
3860
+ const discard = await prompter.askYesNo("Discard the changes?", false);
3861
+ if (discard == false) {
3862
+ continue;
3863
+ }
3864
+ }
3865
+ return null;
3866
+ }
3867
+ const missing = missingFields(state);
3868
+ if (picked === "test") {
3869
+ if (missing.length > 0) {
3870
+ output.write(` Fill in ${missing.map((field) => FIELD_NAMES[field]).join(", ")} first.
3871
+ `);
3872
+ cursor = missing[0];
3873
+ continue;
3874
+ }
3875
+ const connected = await testConnection(prompter, output, state);
3876
+ cursor = "test";
3877
+ if (connected == true) {
3878
+ cursor = "save";
3879
+ }
3880
+ continue;
3881
+ }
3882
+ if (picked === "save") {
3883
+ if (missing.length > 0) {
3884
+ output.write(` Fill in ${missing.map((field) => FIELD_NAMES[field]).join(", ")} first.
3885
+ `);
3886
+ cursor = missing[0];
3887
+ continue;
3888
+ }
3889
+ const name = state.answers.name;
3890
+ if (name !== originalName && takenNames.includes(name) == true) {
3891
+ const replace = await prompter.askYesNo(`"${name}" already exists in this file. Replace it?`, false);
3892
+ if (replace == false) {
3893
+ cursor = "name";
3894
+ continue;
3895
+ }
3896
+ }
3897
+ if (snapshotOf(state) !== state.testedSnapshot) {
3898
+ const testFirst = await prompter.askYesNo("Test the connection before saving?", true);
3899
+ if (testFirst == true) {
3900
+ const connected = await testConnection(prompter, output, state);
3901
+ if (connected == false) {
3902
+ const next = await prompter.select(
3903
+ "The check failed. What now?",
3904
+ [
3905
+ { value: "back", key: "back", label: "Back to the settings" },
3906
+ { value: "save", key: "save", label: "Save anyway", hint: "the host may be unreachable from here" },
3907
+ { value: "discard", key: "discard", label: "Discard", hint: "nothing is written" }
3908
+ ],
3909
+ { defaultValue: "back", cancelValue: "back" }
3910
+ );
3911
+ if (next === "discard") {
3912
+ return null;
3913
+ }
3914
+ if (next === "back") {
3915
+ cursor = "test";
3916
+ continue;
3917
+ }
3918
+ }
3919
+ }
3920
+ }
3921
+ return finalizeAnswers(state);
3922
+ }
3923
+ await editField(prompter, output, state, picked);
3924
+ cursor = nextCursor(state, destination, picked);
3925
+ } catch (ex) {
3926
+ if (ex instanceof PromptBackError == false) {
3927
+ throw ex;
3928
+ }
3929
+ restoreState(state, before);
3930
+ cursor = picked;
3931
+ }
3932
+ }
3933
+ } finally {
3934
+ prompter.transient = previousTransient;
3398
3935
  }
3399
- return answers;
3400
3936
  }
3401
3937
  function toMcpServerEntry(value) {
3402
3938
  if (typeof value !== "object" || value == null) {
@@ -4125,125 +4661,54 @@ function reportWrite(prompter, result, action) {
4125
4661
 
4126
4662
  `);
4127
4663
  }
4128
- function profileFromEntry(output, entry) {
4129
- try {
4130
- const profile = loadHostProfile(entry.env);
4131
- if (profile == null) {
4132
- output.write(" The entry has no host.\n");
4133
- }
4134
- return profile;
4135
- } catch (ex) {
4136
- output.write(` The server would refuse to start with this entry: ${errorMessage(ex)}
4137
- `);
4138
- return null;
4139
- }
4140
- }
4141
- async function verifyAnswers(prompter, output, initial) {
4142
- let answers = initial;
4143
- for (; ; ) {
4144
- const testNow = await prompter.askYesNo("Test the connection now?", true);
4145
- if (testNow == false) {
4146
- return answers;
4147
- }
4148
- output.write("\n");
4149
- const profile = profileFromEntry(output, answersToEntry(answers));
4150
- if (profile != null) {
4151
- const result = await runConnectionCheck(profile, output);
4152
- output.write("\n");
4153
- if (result.connected == true) {
4154
- if (result.hostKeyFingerprint != null && answers.hostKeyFingerprint == null) {
4155
- const pin = await prompter.askYesNo("Pin this host key? (a changed key then aborts the connection)", true);
4156
- if (pin == true) {
4157
- answers = { ...answers, hostKeyFingerprint: result.hostKeyFingerprint };
4158
- }
4159
- }
4160
- if (result.jumpHostKeyFingerprint != null && answers.jumpHost != null && answers.jumpHostKeyFingerprint == null) {
4161
- const pin = await prompter.askYesNo("Pin the jump host key too?", true);
4162
- if (pin == true) {
4163
- answers = { ...answers, jumpHostKeyFingerprint: result.jumpHostKeyFingerprint };
4164
- }
4165
- }
4166
- return answers;
4167
- }
4168
- const hostKeyChanged = result.hostKeyFingerprint != null && answers.hostKeyFingerprint != null && result.hostKeyFingerprint !== answers.hostKeyFingerprint;
4169
- if (hostKeyChanged == true) {
4170
- output.write(" The host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
4171
- const repin = await prompter.askYesNo(`Pin ${result.hostKeyFingerprint} instead?`, false);
4172
- if (repin == true) {
4173
- answers = { ...answers, hostKeyFingerprint: result.hostKeyFingerprint ?? void 0 };
4174
- continue;
4175
- }
4176
- }
4177
- const jumpKeyChanged = result.jumpHostKeyFingerprint != null && answers.jumpHostKeyFingerprint != null && result.jumpHostKeyFingerprint !== answers.jumpHostKeyFingerprint;
4178
- if (jumpKeyChanged == true) {
4179
- output.write(" The jump host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
4180
- const repin = await prompter.askYesNo(`Pin ${result.jumpHostKeyFingerprint} instead?`, false);
4181
- if (repin == true) {
4182
- answers = { ...answers, jumpHostKeyFingerprint: result.jumpHostKeyFingerprint ?? void 0 };
4183
- continue;
4184
- }
4185
- }
4186
- }
4187
- const next = await prompter.select(
4188
- "The check failed. What now?",
4189
- [
4190
- { value: "edit", key: "e", label: "Change the answers" },
4191
- { value: "retry", key: "r", label: "Try again", hint: "e.g. after starting a tunnel" },
4192
- { value: "write", key: "w", label: "Write it anyway", hint: "the host may be unreachable from here" },
4193
- { value: "cancel", key: "c", label: "Cancel", hint: "nothing is written" }
4194
- ],
4195
- { defaultValue: "edit", cancelValue: "cancel" }
4196
- );
4197
- if (next === "cancel") {
4198
- return null;
4199
- }
4200
- if (next === "write") {
4201
- return answers;
4202
- }
4203
- if (next === "edit") {
4204
- output.write("\n");
4205
- answers = await askHostQuestions(prompter, answers);
4206
- }
4207
- }
4208
- }
4209
4664
  async function addOrEditHost(prompter, output, target, entries, existingName) {
4210
4665
  let existing;
4211
4666
  if (existingName != null) {
4212
4667
  existing = entryToAnswers(existingName, entries[existingName]);
4213
- output.write(`
4214
- ${prompter.paint.bold(`Editing ${existingName}`)}
4215
- `);
4216
- } else {
4217
- output.write(`
4218
- ${prompter.paint.bold("Adding a host")}
4219
- `);
4220
4668
  }
4221
- const asked = await askHostQuestions(prompter, existing);
4222
- output.write("\n");
4223
- const answers = await verifyAnswers(prompter, output, asked);
4669
+ let answers = await runHostForm(prompter, output, {
4670
+ existing,
4671
+ takenNames: Object.keys(entries),
4672
+ destination: target.label
4673
+ });
4224
4674
  if (answers == null) {
4225
4675
  output.write(" Cancelled \u2014 nothing was written.\n\n");
4226
4676
  return;
4227
4677
  }
4228
- if (answers.name !== existingName && entries[answers.name] != null) {
4229
- const replace = await prompter.askYesNo(`"${answers.name}" already exists in this file. Replace it?`, false);
4230
- if (replace == false) {
4231
- output.write(" Cancelled \u2014 nothing was written.\n\n");
4232
- return;
4233
- }
4234
- }
4235
- const entry = answersToEntry(answers);
4236
- output.write(`
4678
+ let entry = answersToEntry(answers);
4679
+ for (; ; ) {
4680
+ output.write(`
4237
4681
  ${prompter.paint.dim(`This goes into ${target.location}:`)}
4238
4682
 
4239
4683
  `);
4240
- output.write(`${indent(formatForTarget(target, answers.name, maskSecrets(entry)))}
4684
+ output.write(`${indent(formatForTarget(target, answers.name, maskSecrets(entry)))}
4241
4685
 
4242
4686
  `);
4243
- const confirmed = await prompter.askYesNo("Write it?", true);
4244
- if (confirmed == false) {
4245
- output.write(" Not written. Pick the host and choose Print to get the entry ready to paste.\n\n");
4246
- return;
4687
+ let confirmed;
4688
+ try {
4689
+ confirmed = await prompter.askYesNo("Write it?", true);
4690
+ } catch (ex) {
4691
+ if (ex instanceof PromptBackError == false) {
4692
+ throw ex;
4693
+ }
4694
+ const revised = await runHostForm(prompter, output, {
4695
+ existing: answers,
4696
+ takenNames: Object.keys(entries),
4697
+ destination: target.label
4698
+ });
4699
+ if (revised == null) {
4700
+ output.write(" Cancelled \u2014 nothing was written.\n\n");
4701
+ return;
4702
+ }
4703
+ answers = revised;
4704
+ entry = answersToEntry(answers);
4705
+ continue;
4706
+ }
4707
+ if (confirmed == false) {
4708
+ output.write(" Not written. Pick the host and choose Print to get the entry ready to paste.\n\n");
4709
+ return;
4710
+ }
4711
+ break;
4247
4712
  }
4248
4713
  const written = target.writeEntry(answers.name, entry);
4249
4714
  reportWrite(prompter, written, "Written");
@@ -4258,7 +4723,7 @@ ${prompter.paint.bold("Adding a host")}
4258
4723
  }
4259
4724
  async function checkHost(prompter, output, target, name, entry) {
4260
4725
  output.write("\n");
4261
- const profile = profileFromEntry(output, entry);
4726
+ const profile = profileFromEnv(output, entry.env);
4262
4727
  if (profile == null) {
4263
4728
  output.write("\n");
4264
4729
  return;
@@ -4332,7 +4797,7 @@ async function manageHost(prompter, output, target, entries, name) {
4332
4797
  const action = await prompter.select(
4333
4798
  name,
4334
4799
  [
4335
- { value: "edit", key: "e", label: "Edit", hint: "change the answers, re-check, re-write" },
4800
+ { value: "edit", key: "e", label: "Edit", hint: "open its settings" },
4336
4801
  { value: "check", key: "c", label: "Check the connection", hint: "connect now and offer to pin the host key" },
4337
4802
  { value: "print", key: "p", label: "Print the entry", hint: "JSON, Codex TOML and a claude command, ready to paste" },
4338
4803
  { value: "remove", key: "r", label: "Remove", hint: `from ${target.label}` },
@@ -4360,7 +4825,7 @@ async function manageHost(prompter, output, target, entries, name) {
4360
4825
  }
4361
4826
  }
4362
4827
  }
4363
- async function runConfigCommand(options) {
4828
+ async function runSetupCommand(options) {
4364
4829
  const { input, output } = options;
4365
4830
  const targets = options.targets ?? listConfigTargets();
4366
4831
  let target = targets[0];
@@ -4460,6 +4925,9 @@ ${paint.bold(target.label)} ${paint.dim(target.location)}
4460
4925
  if (ex instanceof PromptClosedError) {
4461
4926
  throw ex;
4462
4927
  }
4928
+ if (ex instanceof PromptBackError) {
4929
+ continue;
4930
+ }
4463
4931
  output.write(` Failed: ${errorMessage(ex)}
4464
4932
 
4465
4933
  `);
@@ -5167,11 +5635,12 @@ function createSshMcpServer() {
5167
5635
  }
5168
5636
 
5169
5637
  // src/cli.ts
5638
+ var SETUP_COMMAND_NAMES = ["setup", "config"];
5170
5639
  function parseArguments(argv) {
5171
- const parsed = { showHelp: false, showVersion: false, checkOnly: false, configMode: false };
5640
+ const parsed = { showHelp: false, showVersion: false, checkOnly: false, setupMode: false };
5172
5641
  let rest = argv;
5173
- if (argv[0] === "config") {
5174
- parsed.configMode = true;
5642
+ if (argv[0] != null && SETUP_COMMAND_NAMES.includes(argv[0]) == true) {
5643
+ parsed.setupMode = true;
5175
5644
  rest = argv.slice(1);
5176
5645
  }
5177
5646
  for (let index = 0; index < rest.length; index += 1) {
@@ -5205,17 +5674,17 @@ var HELP_TEXT = [
5205
5674
  "",
5206
5675
  "Usage:",
5207
5676
  " akms-mcp-ssh [--check]",
5208
- " akms-mcp-ssh config [--target <client>]",
5677
+ " akms-mcp-ssh setup [--target <client>]",
5209
5678
  "",
5210
5679
  "Commands:",
5211
- " config Manage the SSH hosts registered in an MCP client's config \u2014",
5680
+ " setup Manage the SSH hosts registered in an MCP client's config \u2014",
5212
5681
  " list, add, edit, remove, check, print. Asks plain questions,",
5213
5682
  " so the SSH_* names below never have to be typed.",
5214
5683
  "",
5215
5684
  "Options:",
5216
5685
  " --check Connect to the configured host, report the result, and exit.",
5217
5686
  " Prints the host key fingerprint so it can be pinned.",
5218
- " --target <id> config: claude-code | codex | claude-desktop | cursor",
5687
+ " --target <id> setup: claude-code | codex | claude-desktop | cursor",
5219
5688
  " (default: the first one installed)",
5220
5689
  " -v, --version Print the version and exit",
5221
5690
  " -h, --help Print this help and exit",
@@ -5249,7 +5718,7 @@ var HELP_TEXT = [
5249
5718
  ].join("\n");
5250
5719
  async function runCheckCommand(profile) {
5251
5720
  if (profile == null) {
5252
- process.stdout.write(`No SSH host is registered \u2014 set ${SINGLE_HOST_ENV.HOST} and ${SINGLE_HOST_ENV.USER}, or run: akms-mcp-ssh config
5721
+ process.stdout.write(`No SSH host is registered \u2014 set ${SINGLE_HOST_ENV.HOST} and ${SINGLE_HOST_ENV.USER}, or run: akms-mcp-ssh setup
5253
5722
  `);
5254
5723
  return 1;
5255
5724
  }
@@ -5272,11 +5741,11 @@ async function main() {
5272
5741
  `);
5273
5742
  return;
5274
5743
  }
5275
- if (cliArguments.configMode == true) {
5744
+ if (cliArguments.setupMode == true) {
5276
5745
  if (process.env[LOG_LEVEL_ENV] == null) {
5277
5746
  process.env[LOG_LEVEL_ENV] = "warn";
5278
5747
  }
5279
- const exitCode = await runConfigCommand({
5748
+ const exitCode = await runSetupCommand({
5280
5749
  input: process.stdin,
5281
5750
  output: process.stdout,
5282
5751
  targetId: cliArguments.targetId