@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.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.1";
38
+ var SERVER_VERSION = "0.1.3";
39
39
 
40
40
  // src/_defs/command-rules.ts
41
41
  var CRITICAL_PATH_PATTERN = new RegExp(
@@ -2368,6 +2368,23 @@ async function downloadFile(connection, remotePath, localPath) {
2368
2368
  }
2369
2369
 
2370
2370
  // src/modules/manage/connection-check.ts
2371
+ function profileFromEnv(output, env) {
2372
+ try {
2373
+ const profile = loadHostProfile(env);
2374
+ if (profile == null) {
2375
+ output.write(" The entry has no host.\n");
2376
+ }
2377
+ return profile;
2378
+ } catch (ex) {
2379
+ let detail = String(ex);
2380
+ if (ex instanceof Error) {
2381
+ detail = ex.message;
2382
+ }
2383
+ output.write(` The server would refuse to start with this entry: ${detail}
2384
+ `);
2385
+ return null;
2386
+ }
2387
+ }
2371
2388
  function describeAuth(profile) {
2372
2389
  if (profile.privateKeyPath != null) {
2373
2390
  return `key ${profile.privateKeyPath}`;
@@ -2634,15 +2651,25 @@ var PromptClosedError = class extends Error {
2634
2651
  this.name = "PromptClosedError";
2635
2652
  }
2636
2653
  };
2654
+ var PromptBackError = class extends Error {
2655
+ constructor() {
2656
+ super("prompt left with esc");
2657
+ this.name = "PromptBackError";
2658
+ }
2659
+ };
2637
2660
  var ESC = "\x1B";
2638
- var KEY_UP = [`${ESC}[A`, `${ESC}OA`, "k"];
2639
- var KEY_DOWN = [`${ESC}[B`, `${ESC}OB`, "j"];
2640
- var KEY_HOME = [`${ESC}[H`, `${ESC}OH`, `${ESC}[1~`];
2641
- var KEY_END = [`${ESC}[F`, `${ESC}OF`, `${ESC}[4~`];
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~`];
2642
2668
  var KEY_ENTER = ["\r", "\n"];
2643
2669
  var KEY_BACKSPACE = ["\x7F", "\b"];
2644
2670
  var KEY_CTRL_C = "";
2645
2671
  var KEY_CTRL_D = "";
2672
+ var KEY_CLEAR_LINE = "";
2646
2673
  function splitKeys(chunk) {
2647
2674
  const keys = [];
2648
2675
  let index = 0;
@@ -2664,6 +2691,13 @@ function splitKeys(chunk) {
2664
2691
  }
2665
2692
  return keys;
2666
2693
  }
2694
+ function isPrintableKey(key) {
2695
+ if (key.startsWith(ESC) == true) {
2696
+ return false;
2697
+ }
2698
+ const codePoint = key.codePointAt(0) ?? 0;
2699
+ return codePoint >= 32 && codePoint !== 127;
2700
+ }
2667
2701
  function charWidth(codePoint) {
2668
2702
  if (codePoint < 4352) {
2669
2703
  return 1;
@@ -2738,8 +2772,14 @@ var Prompter = class {
2738
2772
  this.pendingLines = [];
2739
2773
  this.lineWaiter = null;
2740
2774
  this.inputEnded = false;
2741
- /** Set while a key-driven prompt (select, secret) owns the input. */
2775
+ /** Set while a key-driven prompt owns the input. */
2742
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;
2743
2783
  const ttyInput = input;
2744
2784
  const ttyOutput = output;
2745
2785
  this.interactive = ttyInput.isTTY == true && ttyInput.setRawMode != null && ttyOutput.isTTY == true;
@@ -2783,6 +2823,7 @@ var Prompter = class {
2783
2823
  /**
2784
2824
  * Free-text prompt. Enter accepts `defaultValue` when one is shown.
2785
2825
  *
2826
+ * @throws {PromptBackError} On Esc.
2786
2827
  * @throws {PromptClosedError} If the input closes before a line arrives.
2787
2828
  */
2788
2829
  async ask(question, defaultValue) {
@@ -2791,16 +2832,16 @@ var Prompter = class {
2791
2832
  suffix = ` ${this.style.dim(`(${defaultValue})`)}`;
2792
2833
  }
2793
2834
  const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)}${suffix} `;
2794
- this.output.write(prompt);
2795
- const line = await this.readLine();
2796
- if (line == null) {
2797
- throw new PromptClosedError();
2798
- }
2799
- let answer = line.trim();
2835
+ const typed = await this.readText(prompt, false);
2836
+ let answer = typed.trim();
2800
2837
  if (answer === "" && defaultValue != null) {
2801
2838
  answer = defaultValue;
2802
2839
  }
2803
- this.collapseAnswered(prompt, line, question, answer);
2840
+ let shown = answer;
2841
+ if (shown === "") {
2842
+ shown = this.style.dim("(empty)");
2843
+ }
2844
+ this.writeAnswered(question, shown);
2804
2845
  return answer;
2805
2846
  }
2806
2847
  /** Like `ask`, re-asking until something non-empty is entered. */
@@ -2814,6 +2855,10 @@ var Prompter = class {
2814
2855
  `);
2815
2856
  }
2816
2857
  }
2858
+ /**
2859
+ * @throws {PromptBackError} On Esc — the caller decides what "neither" means.
2860
+ * @throws {PromptClosedError} If the input closes before a line arrives.
2861
+ */
2817
2862
  async askYesNo(question, defaultValue) {
2818
2863
  let hint = "y/N";
2819
2864
  if (defaultValue == true) {
@@ -2821,12 +2866,8 @@ var Prompter = class {
2821
2866
  }
2822
2867
  for (; ; ) {
2823
2868
  const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)} ${this.style.dim(`(${hint})`)} `;
2824
- this.output.write(prompt);
2825
- const line = await this.readLine();
2826
- if (line == null) {
2827
- throw new PromptClosedError();
2828
- }
2829
- const normalized = line.trim().toLowerCase();
2869
+ const typed = await this.readText(prompt, false);
2870
+ const normalized = typed.trim().toLowerCase();
2830
2871
  let answer = null;
2831
2872
  if (normalized === "") {
2832
2873
  answer = defaultValue;
@@ -2840,7 +2881,7 @@ var Prompter = class {
2840
2881
  if (answer == true) {
2841
2882
  shown = "yes";
2842
2883
  }
2843
- this.collapseAnswered(prompt, line, question, shown);
2884
+ this.writeAnswered(question, shown);
2844
2885
  return answer;
2845
2886
  }
2846
2887
  this.output.write(` ${this.style.dim("Answer y or n.")}
@@ -2848,9 +2889,10 @@ var Prompter = class {
2848
2889
  }
2849
2890
  }
2850
2891
  /**
2851
- * Picks one of `items`. On a TTY: ↑/↓ (or k/j) move, 1–9 jump, Enter picks, Esc returns
2852
- * `cancelValue`. Off a TTY: a numbered list, answered by number, key or label.
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.
2853
2894
  *
2895
+ * @throws {PromptBackError} On Esc without a `cancelValue`.
2854
2896
  * @throws {PromptClosedError} On Ctrl+C or when the input closes.
2855
2897
  */
2856
2898
  async select(question, items, options) {
@@ -2862,63 +2904,62 @@ var Prompter = class {
2862
2904
  if (startIndex < 0) {
2863
2905
  startIndex = items.findIndex((item) => "value" in item);
2864
2906
  }
2907
+ let picked;
2865
2908
  if (this.interactive == true) {
2866
- const picked2 = await this.selectWithKeys(question, items, startIndex, options?.cancelValue);
2867
- return picked2;
2909
+ picked = await this.selectWithKeys(question, items, startIndex);
2910
+ } else {
2911
+ picked = await this.selectWithLines(question, items, startIndex);
2868
2912
  }
2869
- const picked = await this.selectWithLines(question, items, startIndex);
2870
- return picked;
2913
+ if (picked != null) {
2914
+ return picked;
2915
+ }
2916
+ if (options?.cancelValue != null) {
2917
+ return options.cancelValue;
2918
+ }
2919
+ throw new PromptBackError();
2871
2920
  }
2872
2921
  /**
2873
2922
  * Reads a value without echoing it on a TTY. Empty means "none", or "keep" when the
2874
2923
  * caller says a value already exists.
2875
2924
  *
2925
+ * @throws {PromptBackError} On Esc.
2876
2926
  * @throws {PromptClosedError} On Ctrl+C or when the input closes.
2877
2927
  */
2878
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;
2937
+ }
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) {
2879
2946
  if (this.interactive == false) {
2880
- const answer = await this.ask(question);
2881
- return answer;
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;
2882
2956
  }
2883
- const prompt = `${this.style.accent(this.symbols.question)} ${this.style.bold(question)} `;
2884
- this.output.write(prompt);
2885
- const ttyInput = this.input;
2886
- return new Promise((resolve, reject) => {
2887
- let buffer = "";
2888
- const finish = () => {
2889
- this.keyHandler = null;
2890
- ttyInput.setRawMode?.(false);
2891
- this.output.write(`\r${ESC}[2K`);
2892
- };
2893
- this.keyHandler = (key) => {
2894
- if (key === KEY_CTRL_C || key === KEY_CTRL_D) {
2895
- finish();
2896
- reject(new PromptClosedError());
2897
- return;
2898
- }
2899
- if (KEY_ENTER.includes(key) == true) {
2900
- finish();
2901
- let shown = this.style.dim("(empty)");
2902
- if (buffer !== "") {
2903
- shown = "\u2022".repeat(Math.min(buffer.length, 8));
2904
- }
2905
- this.writeAnswered(question, shown);
2906
- resolve(buffer);
2907
- return;
2908
- }
2909
- if (KEY_BACKSPACE.includes(key) == true) {
2910
- buffer = Array.from(buffer).slice(0, -1).join("");
2911
- return;
2912
- }
2913
- if (key.startsWith(ESC) == true) {
2914
- return;
2915
- }
2916
- buffer += key;
2917
- };
2918
- ttyInput.setRawMode?.(true);
2919
- });
2957
+ const result = await this.editLine(prompt, secret);
2958
+ if (result.kind === "back") {
2959
+ throw new PromptBackError();
2960
+ }
2961
+ return result.text;
2920
2962
  }
2921
- // ── line mode ──────────────────────────────────────────────────────────
2922
2963
  readLine() {
2923
2964
  const queued = this.pendingLines.shift();
2924
2965
  if (queued != null) {
@@ -2956,6 +2997,78 @@ var Prompter = class {
2956
2997
  newlineIndex = this.lineBuffer.indexOf("\n");
2957
2998
  }
2958
2999
  }
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
+ });
3071
+ }
2959
3072
  async selectWithLines(question, items, startIndex) {
2960
3073
  const choices = items.filter((item) => "value" in item);
2961
3074
  const labelWidths = groupLabelWidths(items);
@@ -2984,6 +3097,9 @@ var Prompter = class {
2984
3097
  if (line == null) {
2985
3098
  throw new PromptClosedError();
2986
3099
  }
3100
+ if (line === ESC) {
3101
+ return null;
3102
+ }
2987
3103
  const answer = line.trim();
2988
3104
  if (answer === "") {
2989
3105
  return defaultChoice.value;
@@ -3039,28 +3155,14 @@ var Prompter = class {
3039
3155
  }
3040
3156
  /** The one-line record an answered prompt leaves behind on a TTY. */
3041
3157
  writeAnswered(question, answer) {
3042
- this.output.write(`${this.style.success(this.symbols.tick)} ${question} ${this.style.dim("\xB7")} ${this.style.accent(answer)}
3043
- `);
3044
- }
3045
- /**
3046
- * Replaces a cooked-mode prompt and its echoed answer with the one-line record. The
3047
- * rows are worked out from the widths, since a long answer wraps.
3048
- */
3049
- collapseAnswered(prompt, typed, question, answer) {
3050
- if (this.interactive == false) {
3158
+ if (this.interactive == false || this.transient == true) {
3051
3159
  return;
3052
3160
  }
3053
- const plainPrompt = prompt.replace(/\u001b\[[0-9;]*m/g, "");
3054
- const used = displayWidth(plainPrompt) + displayWidth(typed);
3055
- const rows = Math.max(1, Math.ceil(used / this.columns()));
3056
- this.output.write(`${ESC}[${rows}A\r${ESC}[J`);
3057
- let shown = answer;
3058
- if (shown === "") {
3059
- shown = this.style.dim("(empty)");
3060
- }
3061
- this.writeAnswered(question, shown);
3161
+ this.output.write(`${this.style.success(this.symbols.tick)} ${question} ${this.style.dim("\xB7")} ${this.style.accent(answer)}
3162
+ `);
3062
3163
  }
3063
- selectWithKeys(question, items, startIndex, cancelValue) {
3164
+ /** @returns The picked value, or null on Esc. */
3165
+ selectWithKeys(question, items, startIndex) {
3064
3166
  const ttyInput = this.input;
3065
3167
  const selectableIndexes = items.flatMap((item, index) => {
3066
3168
  if ("value" in item) {
@@ -3110,11 +3212,7 @@ var Prompter = class {
3110
3212
  }
3111
3213
  lines.push(this.fitLine(parts, width));
3112
3214
  }
3113
- let footer = "\u2191\u2193 move \xB7 enter select";
3114
- if (cancelValue != null) {
3115
- footer += " \xB7 esc back";
3116
- }
3117
- lines.push(this.fitLine([{ text: footer, paint: this.style.dim }], width));
3215
+ lines.push(this.fitLine([{ text: "\u2191\u2193 move \xB7 enter select \xB7 esc back", paint: this.style.dim }], width));
3118
3216
  return lines;
3119
3217
  };
3120
3218
  const move = (step) => {
@@ -3143,11 +3241,8 @@ var Prompter = class {
3143
3241
  return;
3144
3242
  }
3145
3243
  if (key === ESC) {
3146
- if (cancelValue == null) {
3147
- return;
3148
- }
3149
3244
  finish();
3150
- resolve(cancelValue);
3245
+ resolve(null);
3151
3246
  return;
3152
3247
  }
3153
3248
  if (KEY_UP.includes(key) == true) {
@@ -3171,11 +3266,47 @@ var Prompter = class {
3171
3266
  });
3172
3267
  }
3173
3268
  };
3269
+
3270
+ // src/modules/manage/host-form.ts
3174
3271
  var PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
3175
3272
  var NON_KEY_FILE_NAMES = ["config", "known_hosts", "known_hosts.old", "authorized_keys", "environment"];
3176
3273
  var MAX_KEY_FILE_BYTES = 64 * 1024;
3177
3274
  var CLEAR_ANSWER = "-";
3178
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)";
3179
3310
  function findPrivateKeys() {
3180
3311
  const sshDirectory = path__default.default.join(os5__default.default.homedir(), ".ssh");
3181
3312
  let names = [];
@@ -3291,121 +3422,526 @@ async function askKeyPath(prompter, current) {
3291
3422
  }
3292
3423
  }
3293
3424
  }
3294
- async function askHostQuestions(prompter, existing) {
3295
- if (existing != null) {
3296
- prompter.write(prompter.paint.dim(` Enter keeps the value in brackets; ${CLEAR_ANSWER} clears an optional one.
3297
-
3298
- `));
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;
3299
3441
  }
3300
- const host = await prompter.askRequired("Host (hostname or IP)", existing?.host);
3301
- const port = await askPort(prompter, "Port", existing?.port ?? DEFAULT_SSH_PORT);
3302
- const username = await prompter.askRequired("Login user", existing?.username);
3303
- let name = "";
3304
- const defaultName = existing?.name ?? `ssh-${host}`.replace(/[^A-Za-z0-9_-]+/g, "-").slice(0, 48);
3305
- for (; ; ) {
3306
- name = await prompter.askRequired("Server name the agent sees", defaultName);
3307
- if (SERVER_NAME_PATTERN.test(name) == true) {
3308
- break;
3442
+ if (routeUsesProxy(state.route) == false) {
3443
+ answers.proxyCommand = void 0;
3444
+ }
3445
+ if (answers.auth !== "key") {
3446
+ answers.keyPath = void 0;
3447
+ answers.passphrase = void 0;
3448
+ }
3449
+ if (answers.auth !== "password") {
3450
+ answers.password = void 0;
3451
+ }
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 }
3309
3464
  }
3310
- prompter.write(" Use letters, digits, '-' and '_' only (max 48) \u2014 the name becomes part of every tool name.\n");
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");
3311
3480
  }
3312
- const auth = await prompter.select(
3313
- "Authentication",
3314
- [
3315
- { value: "key", label: "Private key file", hint: "only the path is stored \u2014 recommended" },
3316
- { value: "password", label: "Password", hint: "stored in the config file as plain text" },
3317
- { value: "agent", label: "SSH agent", hint: "keys already loaded in ssh-agent" }
3318
- ],
3319
- { defaultValue: existing?.auth ?? "key" }
3320
- );
3321
- let keyPath;
3322
- let passphrase;
3323
- let password;
3324
- if (auth === "key") {
3325
- keyPath = await askKeyPath(prompter, existing?.keyPath);
3326
- passphrase = await askOptionalSecret(prompter, "Key passphrase", existing?.passphrase);
3327
- }
3328
- if (auth === "password") {
3329
- password = await askOptionalSecret(prompter, "Password", existing?.password);
3330
- if (password == null) {
3331
- prompter.write(" No password given \u2014 falling back to the ssh agent.\n");
3332
- }
3333
- }
3334
- let currentRoute = "direct";
3335
- if (existing?.jumpHost != null && existing.proxyCommand != null) {
3336
- currentRoute = "jump-proxy";
3337
- } else if (existing?.jumpHost != null) {
3338
- currentRoute = "jump";
3339
- } else if (existing?.proxyCommand != null) {
3340
- currentRoute = "proxy";
3341
- }
3342
- const route = await prompter.select(
3343
- "How is it reached?",
3344
- [
3345
- { value: "direct", label: "Directly" },
3346
- { value: "jump", label: "Through a jump host", hint: "ssh -J" },
3347
- { value: "proxy", label: "Through a proxy command", hint: "e.g. cloudflared access ssh" },
3348
- { value: "jump-proxy", label: "Proxy command, then a jump host", hint: "cloudflared to the bastion, then on to the host" }
3349
- ],
3350
- { defaultValue: currentRoute }
3351
- );
3352
- let jumpHost;
3353
- let jumpPort;
3354
- let jumpUser;
3355
- let proxyCommand;
3356
- if (route === "jump" || route === "jump-proxy") {
3357
- jumpHost = await prompter.askRequired("Jump host", existing?.jumpHost);
3358
- jumpPort = await askPort(prompter, "Jump host port", existing?.jumpPort ?? DEFAULT_SSH_PORT);
3359
- jumpUser = await prompter.askRequired("Jump host user", existing?.jumpUser ?? username);
3360
- }
3361
- if (route === "proxy" || route === "jump-proxy") {
3362
- prompter.write(prompter.paint.dim(" %h / %p / %r expand to the first hop's host, port and user.\n"));
3363
- proxyCommand = await prompter.askRequired("Proxy command", existing?.proxyCommand ?? "cloudflared access ssh --hostname %h");
3364
- }
3365
- const description = await askOptional(prompter, "Description shown to the agent", existing?.description);
3366
- const readonly = await prompter.askYesNo("Read-only? (rejects writes, redirections, uploads)", existing?.readonly ?? false);
3367
- let currentAllowedPaths;
3368
- if (existing != null && existing.allowedPaths.length > 0) {
3369
- currentAllowedPaths = existing.allowedPaths.join(",");
3481
+ if (answers.username === "") {
3482
+ missing.push("user");
3370
3483
  }
3371
- let allowedPaths = [];
3372
- const allowedPathsAnswer = await askOptional(
3373
- prompter,
3374
- "Limit file tools to path prefixes (comma-separated, empty = anywhere)",
3375
- currentAllowedPaths
3376
- );
3377
- if (allowedPathsAnswer != null) {
3378
- allowedPaths = allowedPathsAnswer.split(",").map((item) => item.trim()).filter((item) => item !== "");
3484
+ if (answers.auth === "key" && answers.keyPath == null) {
3485
+ missing.push("key");
3379
3486
  }
3380
- let resolvedAuth = auth;
3381
- if (auth === "password" && password == null) {
3382
- resolvedAuth = "agent";
3487
+ if (answers.auth === "password" && answers.password == null) {
3488
+ missing.push("password");
3383
3489
  }
3384
- const answers = {
3385
- name,
3386
- host,
3387
- port,
3388
- username,
3389
- auth: resolvedAuth,
3390
- keyPath,
3391
- passphrase,
3392
- password,
3393
- description,
3394
- jumpHost,
3395
- jumpPort,
3396
- jumpUser,
3397
- proxyCommand,
3398
- readonly,
3399
- allowedPaths,
3400
- extraEnv: existing?.extraEnv ?? {}
3490
+ if (routeUsesJump(state.route) == true && answers.jumpHost == null) {
3491
+ missing.push("jump-host");
3492
+ }
3493
+ if (routeUsesProxy(state.route) == true && answers.proxyCommand == null) {
3494
+ missing.push("proxy");
3495
+ }
3496
+ return missing;
3497
+ }
3498
+ function describePin(fingerprint) {
3499
+ if (fingerprint == null) {
3500
+ return "not pinned";
3501
+ }
3502
+ return `pinned \xB7 ${fingerprint.slice(0, 19)}\u2026`;
3503
+ }
3504
+ function valueOrRequired(value) {
3505
+ if (value == null || value === "") {
3506
+ return REQUIRED_MARK;
3507
+ }
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
3401
3520
  };
3402
- if (existing != null && existing.host === host && existing.port === port) {
3403
- answers.hostKeyFingerprint = existing.hostKeyFingerprint;
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";
3543
+ }
3544
+ rows.push(fieldRow("passphrase", passphrase));
3545
+ }
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"));
3552
+ }
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";
3562
+ }
3563
+ jumpUser = `same as login (${loginUser})`;
3564
+ }
3565
+ rows.push(fieldRow("jump-user", jumpUser));
3404
3566
  }
3405
- if (existing != null && existing.jumpHost === jumpHost && existing.jumpPort === jumpPort) {
3406
- answers.jumpHostKeyFingerprint = existing.jumpHostKeyFingerprint;
3567
+ if (routeUsesProxy(state.route) == true) {
3568
+ rows.push(fieldRow("proxy", valueOrRequired(answers.proxyCommand), "%h %p %r \u2192 first hop"));
3569
+ }
3570
+ rows.push(fieldRow("description", answers.description ?? "none", "shown to the agent"));
3571
+ let readonly = "no";
3572
+ if (answers.readonly == true) {
3573
+ readonly = "yes";
3574
+ }
3575
+ rows.push(fieldRow("readonly", readonly, "enter toggles"));
3576
+ let paths = "anywhere";
3577
+ if (answers.allowedPaths.length > 0) {
3578
+ paths = answers.allowedPaths.join(", ");
3579
+ }
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)));
3584
+ }
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;
3596
+ }
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];
3604
+ }
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;
3611
+ }
3612
+ }
3613
+ const next = values[index];
3614
+ if (next == null || next === "test") {
3615
+ return "save";
3616
+ }
3617
+ return next;
3618
+ }
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;
3635
+ }
3636
+ }
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;
3641
+ }
3642
+ }
3643
+ state.lastTest = "ok";
3644
+ state.testedSnapshot = snapshotOf(state);
3645
+ return true;
3646
+ }
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
+ }
3655
+ }
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");
3663
+ }
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;
3674
+ }
3675
+ answers.host = await prompter.askRequired("Host (hostname or IP)", current);
3676
+ if (state.nameTouched == false) {
3677
+ answers.name = deriveServerName(answers.host);
3678
+ }
3679
+ return;
3680
+ }
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;
3689
+ }
3690
+ answers.username = await prompter.askRequired("Login user", current);
3691
+ return;
3692
+ }
3693
+ if (id === "name") {
3694
+ let current;
3695
+ if (answers.name !== "") {
3696
+ current = answers.name;
3697
+ }
3698
+ for (; ; ) {
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;
3704
+ }
3705
+ prompter.write(" Use letters, digits, '-' and '_' only (max 48) \u2014 the name becomes part of every tool name.\n");
3706
+ }
3707
+ }
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);
3720
+ }
3721
+ if (answers.auth === "password" && answers.password == null) {
3722
+ answers.password = await askOptionalSecret(prompter, "Password", void 0);
3723
+ }
3724
+ return;
3725
+ }
3726
+ if (id === "key") {
3727
+ answers.keyPath = await askKeyPath(prompter, answers.keyPath);
3728
+ return;
3729
+ }
3730
+ if (id === "passphrase") {
3731
+ answers.passphrase = await askOptionalSecret(prompter, "Key passphrase", answers.passphrase);
3732
+ return;
3733
+ }
3734
+ if (id === "password") {
3735
+ answers.password = await askOptionalSecret(prompter, "Password", answers.password);
3736
+ return;
3737
+ }
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");
3751
+ }
3752
+ if (routeUsesProxy(state.route) == true && answers.proxyCommand == null) {
3753
+ answers.proxyCommand = await prompter.askRequired("Proxy command", "cloudflared access ssh --hostname %h");
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;
3781
+ }
3782
+ if (id === "paths") {
3783
+ let current;
3784
+ if (answers.allowedPaths.length > 0) {
3785
+ current = answers.allowedPaths.join(",");
3786
+ }
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 !== "");
3791
+ }
3792
+ return;
3793
+ }
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;
3805
+ }
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.
3811
+ `);
3812
+ return;
3813
+ }
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";
3827
+ }
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
3847
+ };
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;
3875
+ }
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;
3890
+ }
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;
3897
+ }
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);
3931
+ }
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;
3937
+ }
3938
+ restoreState(state, before);
3939
+ cursor = picked;
3940
+ }
3941
+ }
3942
+ } finally {
3943
+ prompter.transient = previousTransient;
3407
3944
  }
3408
- return answers;
3409
3945
  }
3410
3946
  function toMcpServerEntry(value) {
3411
3947
  if (typeof value !== "object" || value == null) {
@@ -4134,125 +4670,54 @@ function reportWrite(prompter, result, action) {
4134
4670
 
4135
4671
  `);
4136
4672
  }
4137
- function profileFromEntry(output, entry) {
4138
- try {
4139
- const profile = loadHostProfile(entry.env);
4140
- if (profile == null) {
4141
- output.write(" The entry has no host.\n");
4142
- }
4143
- return profile;
4144
- } catch (ex) {
4145
- output.write(` The server would refuse to start with this entry: ${errorMessage(ex)}
4146
- `);
4147
- return null;
4148
- }
4149
- }
4150
- async function verifyAnswers(prompter, output, initial) {
4151
- let answers = initial;
4152
- for (; ; ) {
4153
- const testNow = await prompter.askYesNo("Test the connection now?", true);
4154
- if (testNow == false) {
4155
- return answers;
4156
- }
4157
- output.write("\n");
4158
- const profile = profileFromEntry(output, answersToEntry(answers));
4159
- if (profile != null) {
4160
- const result = await runConnectionCheck(profile, output);
4161
- output.write("\n");
4162
- if (result.connected == true) {
4163
- if (result.hostKeyFingerprint != null && answers.hostKeyFingerprint == null) {
4164
- const pin = await prompter.askYesNo("Pin this host key? (a changed key then aborts the connection)", true);
4165
- if (pin == true) {
4166
- answers = { ...answers, hostKeyFingerprint: result.hostKeyFingerprint };
4167
- }
4168
- }
4169
- if (result.jumpHostKeyFingerprint != null && answers.jumpHost != null && answers.jumpHostKeyFingerprint == null) {
4170
- const pin = await prompter.askYesNo("Pin the jump host key too?", true);
4171
- if (pin == true) {
4172
- answers = { ...answers, jumpHostKeyFingerprint: result.jumpHostKeyFingerprint };
4173
- }
4174
- }
4175
- return answers;
4176
- }
4177
- const hostKeyChanged = result.hostKeyFingerprint != null && answers.hostKeyFingerprint != null && result.hostKeyFingerprint !== answers.hostKeyFingerprint;
4178
- if (hostKeyChanged == true) {
4179
- output.write(" The host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
4180
- const repin = await prompter.askYesNo(`Pin ${result.hostKeyFingerprint} instead?`, false);
4181
- if (repin == true) {
4182
- answers = { ...answers, hostKeyFingerprint: result.hostKeyFingerprint ?? void 0 };
4183
- continue;
4184
- }
4185
- }
4186
- const jumpKeyChanged = result.jumpHostKeyFingerprint != null && answers.jumpHostKeyFingerprint != null && result.jumpHostKeyFingerprint !== answers.jumpHostKeyFingerprint;
4187
- if (jumpKeyChanged == true) {
4188
- output.write(" The jump host presented a different key than the pinned one. Re-pin only if you know it was rebuilt.\n");
4189
- const repin = await prompter.askYesNo(`Pin ${result.jumpHostKeyFingerprint} instead?`, false);
4190
- if (repin == true) {
4191
- answers = { ...answers, jumpHostKeyFingerprint: result.jumpHostKeyFingerprint ?? void 0 };
4192
- continue;
4193
- }
4194
- }
4195
- }
4196
- const next = await prompter.select(
4197
- "The check failed. What now?",
4198
- [
4199
- { value: "edit", key: "e", label: "Change the answers" },
4200
- { value: "retry", key: "r", label: "Try again", hint: "e.g. after starting a tunnel" },
4201
- { value: "write", key: "w", label: "Write it anyway", hint: "the host may be unreachable from here" },
4202
- { value: "cancel", key: "c", label: "Cancel", hint: "nothing is written" }
4203
- ],
4204
- { defaultValue: "edit", cancelValue: "cancel" }
4205
- );
4206
- if (next === "cancel") {
4207
- return null;
4208
- }
4209
- if (next === "write") {
4210
- return answers;
4211
- }
4212
- if (next === "edit") {
4213
- output.write("\n");
4214
- answers = await askHostQuestions(prompter, answers);
4215
- }
4216
- }
4217
- }
4218
4673
  async function addOrEditHost(prompter, output, target, entries, existingName) {
4219
4674
  let existing;
4220
4675
  if (existingName != null) {
4221
4676
  existing = entryToAnswers(existingName, entries[existingName]);
4222
- output.write(`
4223
- ${prompter.paint.bold(`Editing ${existingName}`)}
4224
- `);
4225
- } else {
4226
- output.write(`
4227
- ${prompter.paint.bold("Adding a host")}
4228
- `);
4229
4677
  }
4230
- const asked = await askHostQuestions(prompter, existing);
4231
- output.write("\n");
4232
- const answers = await verifyAnswers(prompter, output, asked);
4678
+ let answers = await runHostForm(prompter, output, {
4679
+ existing,
4680
+ takenNames: Object.keys(entries),
4681
+ destination: target.label
4682
+ });
4233
4683
  if (answers == null) {
4234
4684
  output.write(" Cancelled \u2014 nothing was written.\n\n");
4235
4685
  return;
4236
4686
  }
4237
- if (answers.name !== existingName && entries[answers.name] != null) {
4238
- const replace = await prompter.askYesNo(`"${answers.name}" already exists in this file. Replace it?`, false);
4239
- if (replace == false) {
4240
- output.write(" Cancelled \u2014 nothing was written.\n\n");
4241
- return;
4242
- }
4243
- }
4244
- const entry = answersToEntry(answers);
4245
- output.write(`
4687
+ let entry = answersToEntry(answers);
4688
+ for (; ; ) {
4689
+ output.write(`
4246
4690
  ${prompter.paint.dim(`This goes into ${target.location}:`)}
4247
4691
 
4248
4692
  `);
4249
- output.write(`${indent(formatForTarget(target, answers.name, maskSecrets(entry)))}
4693
+ output.write(`${indent(formatForTarget(target, answers.name, maskSecrets(entry)))}
4250
4694
 
4251
4695
  `);
4252
- const confirmed = await prompter.askYesNo("Write it?", true);
4253
- if (confirmed == false) {
4254
- output.write(" Not written. Pick the host and choose Print to get the entry ready to paste.\n\n");
4255
- return;
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;
4256
4721
  }
4257
4722
  const written = target.writeEntry(answers.name, entry);
4258
4723
  reportWrite(prompter, written, "Written");
@@ -4267,7 +4732,7 @@ ${prompter.paint.bold("Adding a host")}
4267
4732
  }
4268
4733
  async function checkHost(prompter, output, target, name, entry) {
4269
4734
  output.write("\n");
4270
- const profile = profileFromEntry(output, entry);
4735
+ const profile = profileFromEnv(output, entry.env);
4271
4736
  if (profile == null) {
4272
4737
  output.write("\n");
4273
4738
  return;
@@ -4341,7 +4806,7 @@ async function manageHost(prompter, output, target, entries, name) {
4341
4806
  const action = await prompter.select(
4342
4807
  name,
4343
4808
  [
4344
- { value: "edit", key: "e", label: "Edit", hint: "change the answers, re-check, re-write" },
4809
+ { value: "edit", key: "e", label: "Edit", hint: "open its settings" },
4345
4810
  { value: "check", key: "c", label: "Check the connection", hint: "connect now and offer to pin the host key" },
4346
4811
  { value: "print", key: "p", label: "Print the entry", hint: "JSON, Codex TOML and a claude command, ready to paste" },
4347
4812
  { value: "remove", key: "r", label: "Remove", hint: `from ${target.label}` },
@@ -4369,7 +4834,7 @@ async function manageHost(prompter, output, target, entries, name) {
4369
4834
  }
4370
4835
  }
4371
4836
  }
4372
- async function runConfigCommand(options) {
4837
+ async function runSetupCommand(options) {
4373
4838
  const { input, output } = options;
4374
4839
  const targets = options.targets ?? listConfigTargets();
4375
4840
  let target = targets[0];
@@ -4469,6 +4934,9 @@ ${paint.bold(target.label)} ${paint.dim(target.location)}
4469
4934
  if (ex instanceof PromptClosedError) {
4470
4935
  throw ex;
4471
4936
  }
4937
+ if (ex instanceof PromptBackError) {
4938
+ continue;
4939
+ }
4472
4940
  output.write(` Failed: ${errorMessage(ex)}
4473
4941
 
4474
4942
  `);
@@ -5176,11 +5644,12 @@ function createSshMcpServer() {
5176
5644
  }
5177
5645
 
5178
5646
  // src/cli.ts
5647
+ var SETUP_COMMAND_NAMES = ["setup", "config"];
5179
5648
  function parseArguments(argv) {
5180
- const parsed = { showHelp: false, showVersion: false, checkOnly: false, configMode: false };
5649
+ const parsed = { showHelp: false, showVersion: false, checkOnly: false, setupMode: false };
5181
5650
  let rest = argv;
5182
- if (argv[0] === "config") {
5183
- parsed.configMode = true;
5651
+ if (argv[0] != null && SETUP_COMMAND_NAMES.includes(argv[0]) == true) {
5652
+ parsed.setupMode = true;
5184
5653
  rest = argv.slice(1);
5185
5654
  }
5186
5655
  for (let index = 0; index < rest.length; index += 1) {
@@ -5214,17 +5683,17 @@ var HELP_TEXT = [
5214
5683
  "",
5215
5684
  "Usage:",
5216
5685
  " akms-mcp-ssh [--check]",
5217
- " akms-mcp-ssh config [--target <client>]",
5686
+ " akms-mcp-ssh setup [--target <client>]",
5218
5687
  "",
5219
5688
  "Commands:",
5220
- " 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",
5221
5690
  " list, add, edit, remove, check, print. Asks plain questions,",
5222
5691
  " so the SSH_* names below never have to be typed.",
5223
5692
  "",
5224
5693
  "Options:",
5225
5694
  " --check Connect to the configured host, report the result, and exit.",
5226
5695
  " Prints the host key fingerprint so it can be pinned.",
5227
- " --target <id> config: claude-code | codex | claude-desktop | cursor",
5696
+ " --target <id> setup: claude-code | codex | claude-desktop | cursor",
5228
5697
  " (default: the first one installed)",
5229
5698
  " -v, --version Print the version and exit",
5230
5699
  " -h, --help Print this help and exit",
@@ -5258,7 +5727,7 @@ var HELP_TEXT = [
5258
5727
  ].join("\n");
5259
5728
  async function runCheckCommand(profile) {
5260
5729
  if (profile == null) {
5261
- process.stdout.write(`No SSH host is registered \u2014 set ${SINGLE_HOST_ENV.HOST} and ${SINGLE_HOST_ENV.USER}, or run: akms-mcp-ssh config
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
5262
5731
  `);
5263
5732
  return 1;
5264
5733
  }
@@ -5281,11 +5750,11 @@ async function main() {
5281
5750
  `);
5282
5751
  return;
5283
5752
  }
5284
- if (cliArguments.configMode == true) {
5753
+ if (cliArguments.setupMode == true) {
5285
5754
  if (process.env[LOG_LEVEL_ENV] == null) {
5286
5755
  process.env[LOG_LEVEL_ENV] = "warn";
5287
5756
  }
5288
- const exitCode = await runConfigCommand({
5757
+ const exitCode = await runSetupCommand({
5289
5758
  input: process.stdin,
5290
5759
  output: process.stdout,
5291
5760
  targetId: cliArguments.targetId