All files / src/commands/ssh ssh.command.js

90.47% Statements 76/84
76.92% Branches 10/13
100% Functions 2/2
90.47% Lines 76/84

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 16x 16x 8x 8x 6x 6x 4x 4x 2x 2x 2x   2x 1x 1x 1x 3x 3x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x     1x 1x 1x 1x 1x 1x 1x 1x 1x 1x           193x 193x  
import { getAllInstances } from '@clevercloud/client/esm/api/v2/application.js';
import { spawn } from 'node:child_process';
import { z } from 'zod';
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 * as Application from '../../models/application.js';
import { sendToApi } from '../../models/send-to-api.js';
import { runMarkerProtocol } from '../../models/ssh-marker-protocol.js';
import { aliasOption, appIdOrNameOption } from '../global.options.js';
 
export const sshCommand = defineCommand({
  description: 'Connect to running instances through SSH',
  since: '0.7.0',
  options: {
    identityFile: defineOption({
      name: 'identity-file',
      schema: z.string().optional(),
      description: 'SSH identity file',
      aliases: ['i'],
      placeholder: 'identity-file',
    }),
    command: defineOption({
      name: 'command',
      schema: z.string().optional(),
      description: 'Execute a command on the remote instance and exit',
      aliases: ['c'],
      placeholder: 'command',
    }),
    alias: aliasOption,
    app: appIdOrNameOption,
  },
  args: [],
  async handler(options) {
    const { alias, app: appIdOrName, identityFile, command } = options;
    const { appId, ownerId } = await Application.resolveId(appIdOrName, alias);
 
    const instances = await getAllInstances({ id: ownerId, appId }).then(sendToApi);
 
    if (instances.length === 0) {
      throw new Error('No running instances found for this application');
    }
 
    let sshTarget;
    if (instances.length === 1) {
      sshTarget = instances[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,
        }));
      sshTarget = await selectAnswer('Select an instance:', choices);
    } else {
      throw new Error('Multiple instances are running. Cannot select in non-interactive mode.');
    }
 
    const sshParams = [];
    // -t: force PTY allocation (SSH skips it by default because appId is passed as a command for gateway routing)
    if (command == null) {
      sshParams.push('-t');
    }
    if (identityFile != null) {
      sshParams.push('-i', identityFile);
    }
    sshParams.push(config.SSH_GATEWAY, sshTarget);
 
    // Interactive session mode (spawn SSH with inherited stdio)
    if (command == null) {
      return new Promise((resolve, reject) => {
        const sshProcess = spawn('ssh', sshParams, { stdio: 'inherit' });
        sshProcess.on('exit', resolve);
        sshProcess.on('error', reject);
      });
    }

    // Single command mode (pipe stdio to filter gateway noise via a marker)
    const sshProcess = spawn('ssh', sshParams, { stdio: 'pipe' });
    const exitCode = await runMarkerProtocol({ child: sshProcess, command });
    process.exit(exitCode);
  },
});