Repository: ZephyrCloudIO/zephyr-packages
Pull request #60: fix: proceed with login without requiring enter keypress
Author: login: arthurfiorette
Labels: name: QA: Approved
description: QA tests are passed
Created: 2025-03-11T18:04:45Z | Merged: 2025-03-13T23:13:04Z

#51 follow up fix 

### What's added in this PR?

After https://github.com/ZephyrCloudIO/zephyr-packages/pull/51 got merged, manually opening the login URL and login-in would get stuck unless <kbd>Enter</kbd> was pressed beforehand. Pressing it after the log-in sometimes would get into a dead promise because the socket message was sent before it started listening.

Video example:

https://drive.google.com/file/d/1hvdqSH2SEvaQ8uoW23t8qCZoshye8mRy/view?usp=drive_link

#### Screenshots

![image](https://github.com/user-attachments/assets/46aa9943-8e80-4876-97d9-9fde633ccac1)

### What are the steps to test this PR?

Just log-in

<!-- ### Who do you wish to review this PR other than required reviewers? -->

<!-- @valorkin @zmzlois @arthurfiorette @zackarychapple -->

### (Required) Pre-PR/Merge checklist

- [ ] I have added/updated/opened a PR to [documentation](https://github.com/ZephyrCloudIO/zephyr-documentation) to cover this new behavior
- [ ] I have added an explanation of my changes
- [ ] I have written new tests (if applicable)
- [ ] I have tested this locally (standing from a first time user point of view, never touch this app before)
- [ ] I have/will run tests, or ask for help to add test



Diff:
diff --git a/libs/zephyr-agent/src/lib/auth/login.ts b/libs/zephyr-agent/src/lib/auth/login.ts
index 34512a72e..cab51d325 100644
--- a/libs/zephyr-agent/src/lib/auth/login.ts
+++ b/libs/zephyr-agent/src/lib/auth/login.ts
@@ -1,21 +1,21 @@
 import * as jose from 'jose';
-import * as readline from 'readline';
+import * as readline from 'node:readline';
 import {
   PromiseWithResolvers,
+  ZEPHYR_API_ENDPOINT,
   ZE_API_ENDPOINT,
   ze_api_gateway,
-  ZEPHYR_API_ENDPOINT,
 } from 'zephyr-edge-contract';
-import { logFn } from '../logging/ze-log-event';
-import { createSocket } from './websocket';
+import { ZeErrors, ZephyrError } from '../errors';
+import { ZeHttpRequest } from '../http/ze-http-request';
+import { ze_log } from '../logging';
+import { blue, bold, gray, green, isTTY, white, yellow } from '../logging/picocolor';
+import { formatLogMsg, logFn } from '../logging/ze-log-event';
 import { getSecretToken } from '../node-persist/secret-token';
+import { StorageKeys } from '../node-persist/storage-keys';
 import { getToken, removeToken, saveToken } from '../node-persist/token';
-import { ze_log } from '../logging';
-import { blue, bold, green, white, yellow } from '../logging/picocolor';
-import { ZeHttpRequest } from '../http/ze-http-request';
-import { ZeErrors, ZephyrError } from '../errors';
 import { DEFAULT_AUTH_COMPLETION_TIMEOUT_MS, TOKEN_EXPIRY } from './auth-flags';
-import { brightBlueBgName } from '../logging/debug';
+import { createSocket } from './websocket';
 
 /**
  * Check if the user is already authenticated. If not, ask if they want to open a browser
@@ -43,6 +43,12 @@ export async function checkAuth(): Promise<string> {
     await removeToken();
   }
 
+  // In non-TTY environments it's expected that a ZE_SECRET_TOKEN is present
+  // since user cannot interact with it.
+  if (!isTTY) {
+    logFn('warn', `Could not load ${StorageKeys.ze_secret_token}.`);
+  }
+
   // No valid token found; initiate authentication.
   logFn('', `${yellow('Authentication required')} - You need to log in to Zephyr Cloud`);
 
@@ -50,23 +56,20 @@ export async function checkAuth(): Promise<string> {
   const sessionKey = generateSessionKey();
   const authUrl = await getAuthenticationURL(sessionKey);
 
-  // Prompt user to continue
-  await promptForAuthAction(authUrl);
+  const browserController = new AbortController();
 
-  // Handle browser opening
-  try {
-    await openUrl(authUrl);
-    logFn('', `${blue('⏳')} Waiting for authentication...\n`);
-  } catch (error) {
-    // If browser failed to open, fall back to manual
-    fallbackManualLogin(authUrl);
-  }
+  // Tries to open the browser to authenticate the user
+  void promptForAuthAction(authUrl, browserController.signal)
+    .then(() => openUrl(authUrl))
+    .catch(() => fallbackManualLogin(authUrl));
+
+  const newToken = await waitForAccessToken(sessionKey).finally(() =>
+    browserController.abort()
+  );
 
-  // Wait for token regardless of method
-  const newToken = await waitForAccessToken(sessionKey);
   await saveToken(newToken);
 
-  logFn('', `${green('✓')} You are now logged in to Zephyr Cloud`);
+  logFn('', `${green('✓')} You are now logged in to Zephyr Cloud\n`);
 
   return newToken;
 }
@@ -96,23 +99,26 @@ export function isTokenStillValid(token: string, gap = 0): boolean {
 }
 
 /** Prompts the user to choose an authentication action */
-async function promptForAuthAction(authUrl: string): Promise<void> {
+async function promptForAuthAction(
+  authUrl: string,
+  signal: AbortSignal
+): Promise<string> {
   const rl = readline.createInterface({
     input: process.stdin,
     output: process.stdout,
+    signal,
   });
 
-  return new Promise<void>((resolve) => {
+  return new Promise<string>((resolve) => {
     rl.question(
-      `${brightBlueBgName}  This is the authentication URL:
-${brightBlueBgName}
-${brightBlueBgName}  ${authUrl}
-${brightBlueBgName}
-${brightBlueBgName}  Please hit ${bold(white('Enter'))} to open it up on your browser: `,
-      () => {
-        rl.close();
-        resolve();
-      }
+      formatLogMsg(`
+${authUrl}
+
+${gray(`You can hit ${bold(white('Enter'))} to open it up on your browser.`)}
+`),
+
+      { signal },
+      resolve
     );
   });
 }
diff --git a/libs/zephyr-agent/src/lib/logging/ze-log-event.ts b/libs/zephyr-agent/src/lib/logging/ze-log-event.ts
index 555da1a12..18eb5f508 100644
--- a/libs/zephyr-agent/src/lib/logging/ze-log-event.ts
+++ b/libs/zephyr-agent/src/lib/logging/ze-log-event.ts
@@ -23,24 +23,20 @@ export const logFn = (level: string, msg: unknown): void => {
     return;
   }
 
-  const str = String(msg);
-  const padded = str
-    .split('\n')
-    .map((m) => `${toLevelPrefix(level)}  ${m.trimEnd()}`)
-    .join('\n');
+  const formatted = formatLogMsg(msg, level);
 
   switch (level) {
     case 'warn':
-      console.warn(padded);
+      console.warn(formatted);
       break;
     case 'debug':
-      console.debug(padded);
+      console.debug(formatted);
       break;
     case 'error':
-      console.error(padded);
+      console.error(formatted);
       break;
     default:
-      console.log(padded);
+      console.log(formatted);
   }
 };
 
@@ -57,6 +53,13 @@ function toLevelPrefix(level: string) {
   }
 }
 
+export function formatLogMsg(msg: unknown, level = 'info') {
+  return String(msg)
+    .split('\n')
+    .map((m) => `${toLevelPrefix(level)}  ${m.trimEnd()}`)
+    .join('\n');
+}
+
 export interface LogEventOptions {
   level: string;
   action: string;


Reviews:
user: login: fiorin
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T18:24:47Z
user: login: valorkin
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T23:54:18Z
user: login: arthurfiorette
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T18:12:41Z
user: login: valorkin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T20:07:33Z
user: login: artem-sht
state: DISMISSED
author_association: NONE
submitted_at: 2025-03-13T12:41:57Z
user: login: valorkin
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T22:48:47Z
user: login: valorkin
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T22:50:06Z
user: login: zackarychapple
body: love these changes, thanks for attaching a video too
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T22:52:26Z

Review comments:
diff_hunk: @@ -43,30 +43,33 @@ export async function checkAuth(): Promise<string> {
     await removeToken();
   }
 
+  // In non-TTY environments it's expected that a ZE_SECRET_TOKEN is present
+  // since user cannot interact with it.
+  if (!isTTY) {
+    logFn('warn', `Could not load ${StorageKeys.ze_secret_token}.`);
+  }
+
   // No valid token found; initiate authentication.
   logFn('', `${yellow('Authentication required')} - You need to log in to Zephyr Cloud`);
 
   // Get authentication URL first
   const sessionKey = generateSessionKey();
   const authUrl = await getAuthenticationURL(sessionKey);
 
-  // Prompt user to continue
-  await promptForAuthAction(authUrl);
+  const browserController = new AbortController();
 
-  // Handle browser opening
-  try {
-    await openUrl(authUrl);
-    logFn('', `${blue('⏳')} Waiting for authentication...\n`);
-  } catch (error) {
-    // If browser failed to open, fall back to manual
-    fallbackManualLogin(authUrl);
-  }
+  // Tries to open the browser to authenticate the user
+  void promptForAuthAction(authUrl, browserController.signal)
path: libs/zephyr-agent/src/lib/auth/login.ts
user: login: valorkin
body: IMHO prompt should lead to a race promise and not use exception in this way (cuz exception is unexpected behaviour, like no internet and socket dropped off )
created_at: 2025-03-11T23:53:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -43,30 +43,33 @@ export async function checkAuth(): Promise<string> {
     await removeToken();
   }
 
+  // In non-TTY environments it's expected that a ZE_SECRET_TOKEN is present
+  // since user cannot interact with it.
+  if (!isTTY) {
+    logFn('warn', `Could not load ${StorageKeys.ze_secret_token}.`);
+  }
+
   // No valid token found; initiate authentication.
   logFn('', `${yellow('Authentication required')} - You need to log in to Zephyr Cloud`);
 
   // Get authentication URL first
   const sessionKey = generateSessionKey();
   const authUrl = await getAuthenticationURL(sessionKey);
 
-  // Prompt user to continue
-  await promptForAuthAction(authUrl);
+  const browserController = new AbortController();
 
-  // Handle browser opening
-  try {
-    await openUrl(authUrl);
-    logFn('', `${blue('⏳')} Waiting for authentication...\n`);
-  } catch (error) {
-    // If browser failed to open, fall back to manual
-    fallbackManualLogin(authUrl);
-  }
+  // Tries to open the browser to authenticate the user
+  void promptForAuthAction(authUrl, browserController.signal)
path: libs/zephyr-agent/src/lib/auth/login.ts
user: login: arthurfiorette
body: @valorkin, at first, you may think that this can be raced between both, but in reality, we just need to wait for the auth confirmation, any other kind of prompt should be done async, and there's no constraint requiring the browser to be opened to have a valid login.

The catch handler is not for the first promise, but for the second one in case our runtime `eval` of `open` module fails.

https://github.com/ZephyrCloudIO/zephyr-packages/blob/469c0751a0689e3901a3c4e73663e5933cedc162/libs/zephyr-agent/src/lib/auth/login.ts#L136-L140

Putting it at last just adds a safety net in case something unexpected happens with `readline.question`
created_at: 2025-03-12T18:12:41Z
author_association: MEMBER
diff_hunk: @@ -43,30 +43,33 @@ export async function checkAuth(): Promise<string> {
     await removeToken();
   }
 
+  // In non-TTY environments it's expected that a ZE_SECRET_TOKEN is present
+  // since user cannot interact with it.
+  if (!isTTY) {
+    logFn('warn', `Could not load ${StorageKeys.ze_secret_token}.`);
+  }
+
   // No valid token found; initiate authentication.
   logFn('', `${yellow('Authentication required')} - You need to log in to Zephyr Cloud`);
 
   // Get authentication URL first
   const sessionKey = generateSessionKey();
   const authUrl = await getAuthenticationURL(sessionKey);
 
-  // Prompt user to continue
-  await promptForAuthAction(authUrl);
+  const browserController = new AbortController();
 
-  // Handle browser opening
-  try {
-    await openUrl(authUrl);
-    logFn('', `${blue('⏳')} Waiting for authentication...\n`);
-  } catch (error) {
-    // If browser failed to open, fall back to manual
-    fallbackManualLogin(authUrl);
-  }
+  // Tries to open the browser to authenticate the user
+  void promptForAuthAction(authUrl, browserController.signal)
path: libs/zephyr-agent/src/lib/auth/login.ts
user: login: valorkin
body: Why manual login is in catch?
Should flow wait for manual login to be resolved?
created_at: 2025-03-12T20:07:32Z
author_association: CONTRIBUTOR

PR comments:
user: login: cla-assistant[bot]
created_at: 2025-03-11T18:05:01Z
body: [![CLA assistant check](https://cla-assistant.io/pull/badge/signed)](https://cla-assistant.io/ZephyrCloudIO/zephyr-packages?pullRequest=60) <br/>All committers have signed the CLA.
author_association: NONE
user: login: nx-cloud[bot]
created_at: 2025-03-11T18:05:43Z
body: 
View your [CI Pipeline Execution ↗](https://cloud.nx.app/cipes/67d3612a2edd0d69db2c4919?utm_source=pull-request&utm_medium=comment) for commit 3dce9aecca1f0e8eb48952376a0fe65ba34412b0.

| Command | Status | Duration | Result |
|---------|--------|----------:|--------|
| `nx affected -t test` | ✅ Succeeded | 1m 5s | [View ↗](https://cloud.nx.app/runs/uSyq7ol26L?utm_source=pull-request&utm_medium=comment) |
| `nx run-many -t build --all --exclude=examples/**` | ✅ Succeeded | 15s | [View ↗](https://cloud.nx.app/runs/JMBB2Tk2Wk?utm_source=pull-request&utm_medium=comment) |
| `nx affected -t lint` | ✅ Succeeded | 5s | [View ↗](https://cloud.nx.app/runs/D0BbrXRQbi?utm_source=pull-request&utm_medium=comment) |

---

☁️ [Nx Cloud](https://cloud.nx.app?utm_source=pull-request&utm_medium=comment) last updated this comment at `2025-03-13 22:52:24` UTC

<!-- NX_CLOUD_APP_COMMENT_END -->
author_association: NONE