< Summary - Envilder CLI

Information
Class: src/envilder/apps/cli/recovery/SsoLoginRecovery.ts
Assembly: Default
File(s): src/envilder/apps/cli/recovery/SsoLoginRecovery.ts
Tag: 561_41292037308
Line coverage
96%
Covered lines: 55
Uncovered lines: 2
Coverable lines: 57
Total lines: 161
Line coverage: 96.4%
Branch coverage
80%
Covered branches: 21
Total branches: 26
Branch coverage: 80.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

src/envilder/apps/cli/recovery/SsoLoginRecovery.ts

#LineLine coverage
 1import { spawn } from 'node:child_process';
 2import { createInterface } from 'node:readline';
 3import { SsoSessionExpiredError } from '../../../core/domain/errors/DomainErrors.js';
 4import { presentError } from '../errors/CliErrorPresenter.js';
 5import { SilentExitError } from '../errors/SilentExitError.js';
 6
 67const AWS_CLI_NOT_FOUND = -1;
 68const LOGIN_FAILED = 1;
 9
 10export type RecoveryDeps = {
 11  isInteractive(): boolean;
 12  confirm(question: string): Promise<boolean>;
 13  runLogin(profileName?: string): Promise<number>;
 14  write(message: string): void;
 15};
 16
 17export function buildLoginArgs(profileName?: string): string[] {
 618  if (profileName) {
 519    return ['sso', 'login', '--profile', profileName];
 20  }
 121  return ['sso', 'login'];
 22}
 23
 24function defaultIsInteractive(): boolean {
 325  return [process.stdout.isTTY, process.stdin.isTTY].every(Boolean);
 26}
 27
 28/**
 29 * Builds a yes/no prompt that writes its question to `output`. Exec mode
 30 * prompts on stderr so stdout stays reserved for the child process.
 31 */
 32export function createConfirm(
 33  output: NodeJS.WritableStream,
 34): (question: string) => Promise<boolean> {
 3935  return (question) => {
 436    const prompt = createInterface({ input: process.stdin, output });
 437    return new Promise((resolve) => {
 438      prompt.question(`${question} `, (answer) => {
 439        prompt.close();
 440        resolve(answer.trim().toLowerCase() === 'y');
 41      });
 42    });
 43  };
 44}
 45
 46function defaultConfirm(question: string): Promise<boolean> {
 147  return createConfirm(process.stdout)(question);
 48}
 49
 650const STDERR_FD = 2;
 51
 52/**
 53 * Builds the `aws sso login` runner. With `stdout: 'stderr'` the login's
 54 * output goes to stderr, so stdout stays reserved for an exec child; the
 55 * default inherits every stream.
 56 */
 57export function createRunLogin(
 58  options: { stdout?: 'inherit' | 'stderr' } = {},
 59): (profileName?: string) => Promise<number> {
 60  const stdio =
 4461    options.stdout === 'stderr'
 62      ? (['inherit', STDERR_FD, 'inherit'] as const)
 63      : ('inherit' as const);
 4464  return (profileName) =>
 365    new Promise((resolve) => {
 366      const child = spawn('aws', buildLoginArgs(profileName), {
 67        stdio: stdio === 'inherit' ? stdio : [...stdio],
 68      });
 369      child.on('error', (error: NodeJS.ErrnoException) => {
 070        resolve(error.code === 'ENOENT' ? AWS_CLI_NOT_FOUND : LOGIN_FAILED);
 71      });
 372      child.on('close', (code) => {
 373        resolve(code ?? LOGIN_FAILED);
 74      });
 75    });
 76}
 77
 678const defaultRunLogin = createRunLogin();
 79
 80function defaultWrite(message: string): void {
 1481  console.error(message);
 82}
 83
 684const defaultDeps: RecoveryDeps = {
 85  isInteractive: defaultIsInteractive,
 86  confirm: defaultConfirm,
 87  runLogin: defaultRunLogin,
 88  write: defaultWrite,
 89};
 90
 91export async function executeWithSsoRecovery(
 92  run: () => Promise<void>,
 93  deps: Partial<RecoveryDeps> = {},
 94): Promise<void> {
 4395  const resolved: RecoveryDeps = { ...defaultDeps, ...deps };
 4396  try {
 4397    return await run();
 98  } catch (error) {
 1699    if (!(error instanceof SsoSessionExpiredError)) {
 3100      throw error;
 101    }
 13102    if (!resolved.isInteractive()) {
 4103      throw error;
 104    }
 9105    await recoverInteractively(run, error, resolved);
 106  }
 107}
 108
 109async function recoverInteractively(
 110  run: () => Promise<void>,
 111  error: SsoSessionExpiredError,
 112  deps: RecoveryDeps,
 113): Promise<void> {
 9114  deps.write(presentError(error));
 115
 9116  const command = error.profileName
 117    ? `aws sso login --profile ${error.profileName}`
 118    : 'aws sso login';
 9119  const accepted = await deps.confirm(`Run '${command}' now? [y/N]`);
 9120  if (!accepted) {
 2121    deps.write(
 122      'Skipped. Run the command above when ready, then re-run envilder.',
 123    );
 2124    throw new SilentExitError(LOGIN_FAILED);
 125  }
 126
 7127  const exitCode = await deps.runLogin(error.profileName);
 7128  ensureLoginSucceeded(exitCode, deps);
 7129  await retryOnce(run, deps);
 130}
 131
 132function ensureLoginSucceeded(exitCode: number, deps: RecoveryDeps): void {
 7133  if (exitCode === AWS_CLI_NOT_FOUND) {
 1134    deps.write(
 135      'The AWS CLI was not found on your PATH. Install it, then run the command above.',
 136    );
 1137    throw new SilentExitError(LOGIN_FAILED);
 138  }
 6139  if (exitCode !== 0) {
 1140    deps.write('Login did not complete. Try again, then re-run envilder.');
 1141    throw new SilentExitError(LOGIN_FAILED);
 142  }
 143}
 144
 145async function retryOnce(
 146  run: () => Promise<void>,
 147  deps: RecoveryDeps,
 148): Promise<void> {
 5149  deps.write('\uD83C\uDF44 1-UP! SSO session restored.');
 5150  deps.write('\u21bb Warping back for your secrets\u2026');
 5151  try {
 5152    await run();
 4153    deps.write('\u2b50 Level cleared.');
 154  } catch (retryError) {
 1155    if (retryError instanceof SsoSessionExpiredError) {
 1156      deps.write(presentError(retryError));
 1157      throw new SilentExitError(LOGIN_FAILED);
 158    }
 0159    throw retryError;
 160  }
 161}