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

95.97% Statements 191/199
86.66% Branches 26/30
100% Functions 6/6
95.97% Lines 191/199

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 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 193x 4x 4x 4x 193x 193x 193x 193x 193x 4x 4x     4x     4x 4x 4x 4x 4x       4x 4x 4x   4x 4x 193x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 193x 193x 193x 193x 193x 193x 10x 10x 10x 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 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 10x 10x 10x 10x 10x 10x 2x 2x 8x 10x 10x 10x 10x 2x 2x 2x 2x 2x 2x 2x 2x 2x 8x 10x 4x 4x 4x 4x 4x 4x 10x 10x 10x 10x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 10x 10x 10x 10x 7x 7x 193x 193x  
import { get as getUser } from '@clevercloud/client/esm/api/v2/organisation.js';
import dedent from 'dedent';
import crypto from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
import { z } from 'zod';
import pkg from '../../../package.json' with { type: 'json' };
import { baseConfig, config, saveProfile } from '../../config/config.js';
import { defineCommand } from '../../lib/define-command.js';
import { defineOption } from '../../lib/define-option.js';
import { formatProfile } from '../../lib/profile.js';
import { styleText } from '../../lib/style-text.js';
import { Logger } from '../../logger.js';
import { sendToApiWithConfig } from '../../models/send-to-api.js';
import { openBrowser } from '../../models/utils.js';
 
function randomToken() {
  return crypto.randomBytes(20).toString('base64').replace(/\//g, '-').replace(/\+/g, '_').replace(/=/g, '');
}
 
const POLLING_INTERVAL = 2000;
 
const POLLING_MAX_TRY_COUNT = 60;
 
function pollOauthData(url, tryCount = 0) {
  if (tryCount >= POLLING_MAX_TRY_COUNT) {
    throw new Error('Something went wrong while trying to log you in.');
  }
  if (tryCount > 1 && tryCount % 10 === 0) {
    Logger.println("We're still waiting for the login process (in your browser) to be completed…");
  }
 
  return globalThis
    .fetch(url)
    .then(async (r) => {
      if (r.status === 404) {
        await delay(POLLING_INTERVAL);
        return pollOauthData(url, tryCount + 1);
      }
      return r.json();
    })
    .catch(async () => {
      throw new Error('Something went wrong while trying to log you in.');
    });
}
 
async function loginViaConsole(apiHost, consoleTokenUrl) {
  const cliToken = randomToken();
 
  const consoleUrl = new URL(consoleTokenUrl);
  consoleUrl.searchParams.set('cli_version', pkg.version);
  consoleUrl.searchParams.set('cli_token', cliToken);
 
  const cliPollUrl = new URL(apiHost);
  cliPollUrl.pathname = '/v2/self/cli_tokens';
  cliPollUrl.searchParams.set('cli_token', cliToken);
 
  Logger.debug('Try to login to Clever Cloud…');
  await openBrowser(
    consoleUrl.toString(),
    `Opening ${styleText('blue', consoleUrl.toString())} in your browser to log you in…`,
  );
 
  return pollOauthData(cliPollUrl.toString());
}
 
/**
 * Find an existing profile matching the target alias.
 * @param {string} alias
 * @returns {import('../../config/config.js').Profile | undefined}
 */
function getExistingTargetProfile(alias) {
  return config.profiles.find((profile) => profile.alias === alias);
}
 
export const loginCommand = defineCommand({
  description: 'Login to Clever Cloud',
  since: '0.2.0',
  options: {
    token: defineOption({
      name: 'token',
      schema: z.string().optional(),
      description: 'Provide an existing token',
      placeholder: 'token',
    }),
    secret: defineOption({
      name: 'secret',
      schema: z.string().optional(),
      description: 'Provide an existing secret',
      placeholder: 'secret',
    }),
    alias: defineOption({
      name: 'alias',
      aliases: ['a'],
      schema: z
        .string()
        .min(1, { message: 'Profile alias cannot be empty' })
        .regex(/^[a-zA-Z0-9_-]+$/, { message: 'Alias must only contain letters, numbers, hyphens and underscores' })
        .refine((a) => a !== '$env', { message: '"$env" is reserved auth via environment variables' })
        .default('default'),
      description: 'Profile alias',
      placeholder: 'alias',
    }),
    apiHost: defineOption({
      name: 'api-host',
      schema: z.string().url().optional(),
      description: 'API host URL override',
      placeholder: 'url',
    }),
    consoleUrl: defineOption({
      name: 'console-url',
      schema: z.string().url().optional(),
      description: 'Console URL override',
      placeholder: 'url',
    }),
    authBridgeHost: defineOption({
      name: 'auth-bridge-host',
      schema: z.string().url().optional(),
      description: 'Auth bridge URL override',
      placeholder: 'url',
    }),
    sshGateway: defineOption({
      name: 'ssh-gateway',
      schema: z.string().optional(),
      description: 'SSH gateway override',
      placeholder: 'address',
    }),
    consumerKey: defineOption({
      name: 'oauth-consumer-key',
      schema: z.string().optional(),
      description: 'OAuth consumer key override',
      placeholder: 'key',
    }),
    consumerSecret: defineOption({
      name: 'oauth-consumer-secret',
      schema: z.string().optional(),
      description: 'OAuth consumer secret override',
      placeholder: 'secret',
    }),
  },
  async handler(options) {
    const { token, secret } = options;
    const hasToken = token != null;
    const hasSecret = secret != null;
    const existingTargetProfile = getExistingTargetProfile(options.alias);
 
    if (hasToken !== hasSecret) {
      throw new Error('Both `--token` and `--secret` must be defined');
    }
 
    const apiHost = options.apiHost ?? baseConfig.API_HOST;
    const consoleUrl = options.consoleUrl ? `${options.consoleUrl}/cli-oauth` : baseConfig.CONSOLE_TOKEN_URL;
 
    if (!hasToken && existingTargetProfile != null) {
      // KO fallback wording (3 lines):
      // Press Ctrl+C to cancel this login if you do not want to overwrite this profile.
      // Then run clever login --alias <another-alias> to login with a different alias.
      Logger.println(dedent`
        You are already logged in with profile ${styleText('gray', formatProfile(existingTargetProfile))}; this login may overwrite it.
        Press ${styleText('gray', 'Ctrl+C')} to cancel, then run ${styleText('gray', 'clever login --alias <another-alias>')}.
      `);
      Logger.println();
    }
 
    const oauthData = hasToken ? { token, secret } : await loginViaConsole(apiHost, consoleUrl);
 
    const user = await getUser({}).then(
      sendToApiWithConfig({
        token: oauthData.token,
        secret: oauthData.secret,
        apiHost,
        consumerKey: options.consumerKey ?? baseConfig.OAUTH_CONSUMER_KEY,
        consumerSecret: options.consumerSecret ?? baseConfig.OAUTH_CONSUMER_SECRET,
      }),
    );
 
    const overrideEntries = Object.entries({
      API_HOST: options.apiHost,
      CONSOLE_URL: options.consoleUrl,
      AUTH_BRIDGE_HOST: options.authBridgeHost,
      SSH_GATEWAY: options.sshGateway,
      OAUTH_CONSUMER_KEY: options.consumerKey,
      OAUTH_CONSUMER_SECRET: options.consumerSecret,
    }).filter(([, v]) => v != null);
 
    const profile = {
      alias: options.alias,
      token: oauthData.token,
      secret: oauthData.secret,
      expirationDate: oauthData.expirationDate,
      userId: user.id,
      email: user.email,
      overrides: overrideEntries.length > 0 ? Object.fromEntries(overrideEntries) : undefined,
    };
 
    await saveProfile(profile);
 
    Logger.printSuccess(`Login successful as ${styleText('green', formatProfile(profile))}`);
  },
});