From ec51bded83482113b6bb637889f4c0d006862b93 Mon Sep 17 00:00:00 2001 From: Mehdi <26483210+mehdi653@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:38:51 +0000 Subject: [PATCH 1/2] feat(ssh): add --instance option to target a running instance by ID When several instances are running, clever ssh currently requires an interactive prompt (and fails in non-interactive mode). Accept --instance to select the target directly, as requested in #1067. Instance IDs are used rather than instance numbers, which are not stable across deployments. Fixes CleverCloud/clever-tools#1067 --- .../references/full-documentation.md | 1 + src/commands/ssh/ssh.command.js | 16 ++++++++++++++-- src/commands/ssh/ssh.docs.md | 1 + 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/skills/clever-tools/references/full-documentation.md b/skills/clever-tools/references/full-documentation.md index cf6a7955..53323a6d 100644 --- a/skills/clever-tools/references/full-documentation.md +++ b/skills/clever-tools/references/full-documentation.md @@ -3772,6 +3772,7 @@ clever ssh [options] --app Application to manage by its ID (or name, if unambiguous) -c, --command Execute a command on the remote instance and exit -i, --identity-file SSH identity file + --instance Instance ID to connect to (skips interactive selection) ``` ## ssh-keys diff --git a/src/commands/ssh/ssh.command.js b/src/commands/ssh/ssh.command.js index aab1e8ba..4960cab5 100644 --- a/src/commands/ssh/ssh.command.js +++ b/src/commands/ssh/ssh.command.js @@ -28,12 +28,18 @@ export const sshCommand = defineCommand({ aliases: ['c'], placeholder: 'command', }), + instance: defineOption({ + name: 'instance', + schema: z.string().optional(), + description: 'Instance ID to connect to (skips interactive selection)', + placeholder: 'instance-id', + }), alias: aliasOption, app: appIdOrNameOption, }, args: [], async handler(options) { - const { alias, app: appIdOrName, identityFile, command } = options; + const { alias, app: appIdOrName, identityFile, command, instance } = options; const { appId, ownerId } = await Application.resolveId(appIdOrName, alias); const instances = await getAllInstances({ id: ownerId, appId }).then(sendToApi); @@ -43,7 +49,13 @@ export const sshCommand = defineCommand({ } let sshTarget; - if (instances.length === 1) { + if (instance != null) { + const match = instances.find((inst) => inst.id === instance); + if (match == null) { + throw new Error(`Instance ${instance} is not a running instance of this application`); + } + sshTarget = match.id; + } else if (instances.length === 1) { sshTarget = instances[0].id; } else if (process.stdin.isTTY) { const choices = instances diff --git a/src/commands/ssh/ssh.docs.md b/src/commands/ssh/ssh.docs.md index 1e0fb4ba..a9f8df61 100644 --- a/src/commands/ssh/ssh.docs.md +++ b/src/commands/ssh/ssh.docs.md @@ -16,3 +16,4 @@ clever ssh [options] |`--app` ``|Application to manage by its ID (or name, if unambiguous)| |`-c`, `--command` ``|Execute a command on the remote instance and exit| |`-i`, `--identity-file` ``|SSH identity file| +|`--instance` ``|Instance ID to connect to (skips interactive selection)| From addaf1949d06f06a7d9fb1aa2e6c19e3d4768a82 Mon Sep 17 00:00:00 2001 From: David Legrand Date: Fri, 11 Sep 2026 13:00:04 +0200 Subject: [PATCH 2/2] feat(ssh): select an instance by number or with any --- .../references/full-documentation.md | 10 +-- src/commands/ssh/ssh.command.js | 90 +++++++++++++++---- src/commands/ssh/ssh.docs.md | 2 +- 3 files changed, 81 insertions(+), 21 deletions(-) diff --git a/skills/clever-tools/references/full-documentation.md b/skills/clever-tools/references/full-documentation.md index 53323a6d..3aa3174f 100644 --- a/skills/clever-tools/references/full-documentation.md +++ b/skills/clever-tools/references/full-documentation.md @@ -3768,11 +3768,11 @@ clever ssh [options] **Options** ``` --a, --alias Short name for the application - --app Application to manage by its ID (or name, if unambiguous) --c, --command Execute a command on the remote instance and exit --i, --identity-file SSH identity file - --instance Instance ID to connect to (skips interactive selection) +-a, --alias Short name for the application + --app Application to manage by its ID (or name, if unambiguous) +-c, --command Execute a command on the remote instance and exit +-i, --identity-file SSH identity file + --instance Instance to connect to, by ID or number, or `any` (skips interactive selection) ``` ## ssh-keys diff --git a/src/commands/ssh/ssh.command.js b/src/commands/ssh/ssh.command.js index 4960cab5..1db7626c 100644 --- a/src/commands/ssh/ssh.command.js +++ b/src/commands/ssh/ssh.command.js @@ -6,6 +6,7 @@ import { config } from '../../config/config.js'; import { defineCommand } from '../../lib/define-command.js'; import { defineOption } from '../../lib/define-option.js'; import { selectAnswer } from '../../lib/prompts.js'; +import { styleText } from '../../lib/style-text.js'; import * as Application from '../../models/application.js'; import { sendToApi } from '../../models/send-to-api.js'; import { aliasOption, appIdOrNameOption } from '../global.options.js'; @@ -30,9 +31,9 @@ export const sshCommand = defineCommand({ }), instance: defineOption({ name: 'instance', - schema: z.string().optional(), - description: 'Instance ID to connect to (skips interactive selection)', - placeholder: 'instance-id', + schema: z.string().min(1).optional(), + description: 'Instance to connect to, by ID or number, or `any` (skips interactive selection)', + placeholder: 'instance-id|number|any', }), alias: aliasOption, app: appIdOrNameOption, @@ -48,22 +49,25 @@ export const sshCommand = defineCommand({ throw new Error('No running instances found for this application'); } + const ordered = [...instances].sort((a, b) => compareInstanceNumbers(a.instanceNumber, b.instanceNumber)); + let sshTarget; if (instance != null) { - const match = instances.find((inst) => inst.id === instance); - if (match == null) { - throw new Error(`Instance ${instance} is not a running instance of this application`); + const selected = selectInstance(ordered, instance); + if (selected == null) { + const available = ordered.map((inst) => ` - ${inst.instanceNumber} ${inst.id} (${inst.state})`).join('\n'); + throw new Error( + `No instance ${styleText('red', instance)} on this application, pick one of:\n${styleText('grey', available)}`, + ); } - sshTarget = match.id; - } else if (instances.length === 1) { - sshTarget = instances[0].id; + sshTarget = selected.id; + } else if (ordered.length === 1) { + sshTarget = ordered[0].id; } else if (process.stdin.isTTY) { - const choices = instances - .sort((a, b) => a.instanceNumber - b.instanceNumber) - .map((inst) => ({ - name: `${inst.displayName} - Instance ${inst.instanceNumber} - ${inst.state} (${inst.id})`, - value: inst.id, - })); + const choices = ordered.map((inst) => ({ + name: `${inst.displayName} - Instance ${inst.instanceNumber} - ${inst.state} (${inst.id})`, + value: inst.id, + })); sshTarget = await selectAnswer('Select an instance:', choices); } else { throw new Error('Multiple instances are running. Cannot select in non-interactive mode.'); @@ -138,3 +142,59 @@ export const sshCommand = defineCommand({ process.exit(exitCode); }, }); + +/** + * Pick the instance the caller asked for, by ID, by number, or `any`, or nothing when none match. + * + * Numbers are not unique: while a deployment rolls, the instance going away and the one coming up + * carry the same number. `UP` ones are preferred among them, and `any` prefers an `UP` one over the + * lowest number — preferred, not guaranteed, since an application may have none. + * + * @param {Array<{ id: string, instanceNumber: number, state: string }>} ordered - sorted by number + * @param {string} wanted + * @returns {{ id: string, instanceNumber: number, state: string } | null} + */ +function selectInstance(ordered, wanted) { + if (wanted.toLowerCase() === 'any') { + return readiest(ordered); + } + + const byId = ordered.find((inst) => inst.id === wanted); + if (byId != null) { + return byId; + } + + const number = toInstanceNumber(wanted); + if (number == null) { + return null; + } + + return readiest(ordered.filter((inst) => inst.instanceNumber === number)); +} + +/** + * The first serving instance, or the first one when none is serving. + * @param {Array<{ state: string }>} candidates + */ +function readiest(candidates) { + return candidates.find((inst) => inst.state === 'UP') ?? candidates[0] ?? null; +} + +/** Sorts unknown positions last rather than letting NaN leave the list unsorted. */ +function compareInstanceNumbers(a, b) { + if (!Number.isFinite(a)) { + return Number.isFinite(b) ? 1 : 0; + } + return Number.isFinite(b) ? a - b : -1; +} + +/** + * `Number()` rounds past the safe integer range, which would make a wanted number match a + * neighbouring one, so only exact whole numbers count as one. + * @param {string} wanted + * @returns {number | null} + */ +function toInstanceNumber(wanted) { + const asNumber = /^\d+$/.test(wanted) ? Number(wanted) : Number.NaN; + return Number.isSafeInteger(asNumber) ? asNumber : null; +} diff --git a/src/commands/ssh/ssh.docs.md b/src/commands/ssh/ssh.docs.md index a9f8df61..3176f66f 100644 --- a/src/commands/ssh/ssh.docs.md +++ b/src/commands/ssh/ssh.docs.md @@ -16,4 +16,4 @@ clever ssh [options] |`--app` ``|Application to manage by its ID (or name, if unambiguous)| |`-c`, `--command` ``|Execute a command on the remote instance and exit| |`-i`, `--identity-file` ``|SSH identity file| -|`--instance` ``|Instance ID to connect to (skips interactive selection)| +|`--instance` ``|Instance to connect to, by ID or number, or `any` (skips interactive selection)|