Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions skills/clever-tools/references/full-documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -3768,10 +3768,11 @@ clever ssh [options]

**Options**
```
-a, --alias <alias> Short name for the application
--app <app-id|app-name> Application to manage by its ID (or name, if unambiguous)
-c, --command <command> Execute a command on the remote instance and exit
-i, --identity-file <identity-file> SSH identity file
-a, --alias <alias> Short name for the application
--app <app-id|app-name> Application to manage by its ID (or name, if unambiguous)
-c, --command <command> Execute a command on the remote instance and exit
-i, --identity-file <identity-file> SSH identity file
--instance <instance-id|number|any> Instance to connect to, by ID or number, or `any` (skips interactive selection)
```

## ssh-keys
Expand Down
90 changes: 81 additions & 9 deletions src/commands/ssh/ssh.command.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,12 +29,18 @@ export const sshCommand = defineCommand({
aliases: ['c'],
placeholder: 'command',
}),
instance: defineOption({
name: 'instance',
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,
},
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);
Expand All @@ -42,16 +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 (instances.length === 1) {
sshTarget = instances[0].id;
if (instance != null) {
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 = 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.');
Expand Down Expand Up @@ -126,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;
}
1 change: 1 addition & 0 deletions src/commands/ssh/ssh.docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ clever ssh [options]
|`--app` `<app-id\|app-name>`|Application to manage by its ID (or name, if unambiguous)|
|`-c`, `--command` `<command>`|Execute a command on the remote instance and exit|
|`-i`, `--identity-file` `<identity-file>`|SSH identity file|
|`--instance` `<instance-id\|number\|any>`|Instance to connect to, by ID or number, or `any` (skips interactive selection)|
Loading