From 827a40c7e1c0156c59ee60429ec96c9bee0d2967 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Wed, 7 Jan 2026 17:43:44 +0200 Subject: [PATCH 1/9] fix issue with error throwing, add refresh mechanism --- package-lock.json | 13 ++ package.json | 1 + src/cli/commands/auth/login.ts | 15 ++- src/cli/utils/runCommand.ts | 7 +- src/core/auth/api.ts | 178 ++++++++++++++++------------ src/core/auth/authClient.ts | 16 +++ src/core/auth/config.ts | 25 ++++ src/core/auth/schema.ts | 69 +++++++++-- src/core/consts.ts | 8 ++ src/core/errors.ts | 5 +- src/core/resources/entity/config.ts | 1 - src/core/utils/httpClient.ts | 50 ++++++++ src/core/utils/index.ts | 1 + 13 files changed, 284 insertions(+), 105 deletions(-) create mode 100644 src/core/auth/authClient.ts create mode 100644 src/core/utils/httpClient.ts diff --git a/package-lock.json b/package-lock.json index a1ae977b..307f58c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "commander": "^12.1.0", "globby": "^16.1.0", "jsonc-parser": "^3.3.1", + "ky": "^1.14.2", "p-wait-for": "^6.0.0", "zod": "^4.3.5" }, @@ -4453,6 +4454,18 @@ "json-buffer": "3.0.1" } }, + "node_modules/ky": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.2.tgz", + "integrity": "sha512-q3RBbsO5A5zrPhB6CaCS8ZUv+NWCXv6JJT4Em0i264G9W0fdPB8YRfnnEi7Dm7X7omAkBIPojzYJ2D1oHTHqug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/package.json b/package.json index 10f1ecc8..e8c261ad 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "commander": "^12.1.0", "globby": "^16.1.0", "jsonc-parser": "^3.3.1", + "ky": "^1.14.2", "p-wait-for": "^6.0.0", "zod": "^4.3.5" }, diff --git a/src/cli/commands/auth/login.ts b/src/cli/commands/auth/login.ts index 6367977b..649c3bac 100644 --- a/src/cli/commands/auth/login.ts +++ b/src/cli/commands/auth/login.ts @@ -22,7 +22,7 @@ async function generateAndDisplayDeviceCode(): Promise { ); log.info( - `Please visit: ${deviceCodeResponse.verificationUrl}\n` + + `Please visit: ${deviceCodeResponse.verificationUri}\n` + `Enter your device code: ${deviceCodeResponse.userCode}` ); @@ -73,11 +73,14 @@ async function waitForAuthentication( return tokenResponse; } -async function saveAuthData(token: TokenResponse): Promise { +async function saveAuthData(response: TokenResponse): Promise { + // TODO: Fetch user info (email, name) from the server after authentication + // For now, we store placeholder values until a /userinfo endpoint is available await writeAuth({ - token: token.token, - email: token.email, - name: token.name, + accessToken: response.accessToken, + refreshToken: response.refreshToken, + email: "user@base44.com", + name: "Base44 User", }); } @@ -91,7 +94,7 @@ async function login(): Promise { await saveAuthData(token); - log.success(`Logged in as ${token.name}`); + log.success("Successfully logged in!"); } export const loginCommand = new Command("login") diff --git a/src/cli/utils/runCommand.ts b/src/cli/utils/runCommand.ts index 55ad065d..04c8ea8f 100644 --- a/src/cli/utils/runCommand.ts +++ b/src/cli/utils/runCommand.ts @@ -18,11 +18,8 @@ export async function runCommand( try { await commandFn(); } catch (e) { - if (e instanceof AuthValidationError) { - const issues = e.issues.map((i) => i.message).join(", "); - log.error(`Invalid response from server: ${issues}`); - } else if (e instanceof AuthApiError || e instanceof Error) { - log.error(e.message); + if (e instanceof Error) { + log.error(e.stack ?? e.message); } else { log.error(String(e)); } diff --git a/src/core/auth/api.ts b/src/core/auth/api.ts index ab585814..41602ab4 100644 --- a/src/core/auth/api.ts +++ b/src/core/auth/api.ts @@ -1,101 +1,125 @@ import { AuthApiError, AuthValidationError } from "../errors.js"; -import { DeviceCodeResponseSchema, TokenResponseSchema } from "./schema.js"; +import { + DeviceCodeResponseSchema, + TokenResponseSchema, + OAuthErrorSchema, +} from "./schema.js"; import type { DeviceCodeResponse, TokenResponse } from "./schema.js"; - -async function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -const deviceCodeToTokenMap = new Map< - string, - { startTime: number; readyAfter: number } ->(); +import { AUTH_CLIENT_ID } from "../consts.js"; +import authClient from "./authClient.js"; export async function generateDeviceCode(): Promise { - try { - await delay(1000); - - const deviceCode = `device-code-${Date.now()}`; - - deviceCodeToTokenMap.set(deviceCode, { - startTime: Date.now(), - readyAfter: 5000, - }); - - const mockResponse: DeviceCodeResponse = { - deviceCode, - userCode: "ABCD-1234", - verificationUrl: "https://app.base44.com/verify", - expiresIn: 600, - }; - - const result = DeviceCodeResponseSchema.safeParse(mockResponse); - if (!result.success) { - throw new AuthValidationError( - "Invalid device code response from server", - result.error.issues.map((issue) => ({ - message: issue.message, - path: issue.path.map(String), - })) - ); - } - - return result.data; - } catch (error) { - if (error instanceof AuthValidationError) { - throw error; - } + const response = await authClient.post("oauth/device/code", { + json: { + client_id: AUTH_CLIENT_ID, + scope: "apps:read apps:write", + }, + throwHttpErrors: false, + }); + + if (!response.ok) { throw new AuthApiError( - "Failed to generate device code", - error instanceof Error ? error : new Error(String(error)) + `Failed to generate device code: ${response.status} ${response.statusText}` ); } + + const result = DeviceCodeResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new AuthValidationError( + `Invalid device code response from server: ${result.error.message}` + ); + } + + return result.data; } export async function getTokenFromDeviceCode( deviceCode: string ): Promise { - try { - await delay(1000); + const searchParams = new URLSearchParams(); + searchParams.set( + "grant_type", + "urn:ietf:params:oauth:grant-type:device_code" + ); + searchParams.set("device_code", deviceCode); + searchParams.set("client_id", AUTH_CLIENT_ID); + + const response = await authClient.post("oauth/token", { + body: searchParams.toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + throwHttpErrors: false, + }); + + const json = await response.json(); + + if (!response.ok) { + const errorResult = OAuthErrorSchema.safeParse(json); + + if (!errorResult.success) { + throw new AuthApiError(`Token request failed: ${response.statusText}`); + } - const deviceInfo = deviceCodeToTokenMap.get(deviceCode); + const { error, error_description } = errorResult.data; - if (!deviceInfo) { + // Polling states - user hasn't completed auth yet + if (error === "authorization_pending" || error === "slow_down") { return null; } - const elapsed = Date.now() - deviceInfo.startTime; + // Actual errors + throw new AuthApiError(error_description ?? `OAuth error: ${error}`); + } + + const result = TokenResponseSchema.safeParse(json); - if (elapsed < deviceInfo.readyAfter) { - return null; - } + if (!result.success) { + throw new AuthValidationError( + `Invalid token response from server: ${result.error.message}` + ); + } - const mockResponse: TokenResponse = { - token: `mock-token-${Date.now()}`, - email: "shahart@base44.com", - name: "Shahar Talmi", - }; - - const result = TokenResponseSchema.safeParse(mockResponse); - if (!result.success) { - throw new AuthValidationError( - "Invalid token response from server", - result.error.issues.map((issue) => ({ - message: issue.message, - path: issue.path.map(String), - })) - ); - } + return result.data; +} - deviceCodeToTokenMap.delete(deviceCode); - return result.data; - } catch (error) { - if (error instanceof AuthValidationError || error instanceof AuthApiError) { - throw error; +export async function renewAccessToken( + refreshToken: string +): Promise { + const searchParams = new URLSearchParams(); + searchParams.set("grant_type", "refresh_token"); + searchParams.set("refresh_token", refreshToken); + searchParams.set("client_id", AUTH_CLIENT_ID); + + const response = await authClient.post("oauth/token", { + body: searchParams.toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + throwHttpErrors: false, + }); + + const json = await response.json(); + + if (!response.ok) { + const errorResult = OAuthErrorSchema.safeParse(json); + + if (!errorResult.success) { + throw new AuthApiError(`Token refresh failed: ${response.statusText}`); } - throw new AuthApiError( - "Failed to retrieve token from device code", - error instanceof Error ? error : new Error(String(error)) + + const { error, error_description } = errorResult.data; + throw new AuthApiError(error_description ?? `OAuth error: ${error}`); + } + + const result = TokenResponseSchema.safeParse(json); + + if (!result.success) { + throw new AuthValidationError( + `Invalid token response from server: ${result.error.message}` ); } + + return result.data; } diff --git a/src/core/auth/authClient.ts b/src/core/auth/authClient.ts new file mode 100644 index 00000000..18a158be --- /dev/null +++ b/src/core/auth/authClient.ts @@ -0,0 +1,16 @@ +import ky from "ky"; +import { getBase44ApiUrl } from "../consts.js"; + +/** + * Separate ky instance for OAuth endpoints. + * These don't need Authorization headers (they use client_id + tokens in body). + */ +const authClient = ky.create({ + prefixUrl: getBase44ApiUrl(), + headers: { + "User-Agent": "Base44 CLI", + }, +}); + +export default authClient; + diff --git a/src/core/auth/config.ts b/src/core/auth/config.ts index 594fa0b4..4c07daee 100644 --- a/src/core/auth/config.ts +++ b/src/core/auth/config.ts @@ -1,5 +1,6 @@ import { getAuthFilePath } from "../consts.js"; import { readJsonFile, writeJsonFile, deleteFile } from "../utils/fs.js"; +import { renewAccessToken } from "./api.js"; import { AuthDataSchema } from "./schema.js"; import type { AuthData } from "./schema.js"; @@ -65,3 +66,27 @@ export async function deleteAuth(): Promise { ); } } + +/** + * Refreshes the access token and saves the new tokens. + * Returns the new access token, or null if refresh failed. + * Used by httpClient to handle 401 responses. + */ +export async function refreshAndSaveTokens(): Promise { + try { + const auth = await readAuth(); + const tokenResponse = await renewAccessToken(auth.refreshToken); + + await writeAuth({ + ...auth, + accessToken: tokenResponse.accessToken, + refreshToken: tokenResponse.refreshToken, + }); + + return tokenResponse.accessToken; + } catch { + // Refresh failed - delete auth, user needs to login again + await deleteAuth(); + return null; + } +} diff --git a/src/core/auth/schema.ts b/src/core/auth/schema.ts index e689569d..f619fe2d 100644 --- a/src/core/auth/schema.ts +++ b/src/core/auth/schema.ts @@ -2,7 +2,8 @@ import { z } from "zod"; // Auth data schema (stored locally) export const AuthDataSchema = z.object({ - token: z.string().min(1, "Token cannot be empty"), + accessToken: z.string().min(1, "Token cannot be empty"), + refreshToken: z.string().min(1, "Refresh token cannot be empty"), email: z.email(), name: z.string().min(1, "Name cannot be empty"), }); @@ -10,19 +11,63 @@ export const AuthDataSchema = z.object({ export type AuthData = z.infer; // API response schemas -export const DeviceCodeResponseSchema = z.object({ - deviceCode: z.string().min(1, "Device code cannot be empty"), - userCode: z.string().min(1, "User code cannot be empty"), - verificationUrl: z.url("Invalid verification URL"), - expiresIn: z.number().int().positive("Expires in must be a positive integer"), -}); +export const DeviceCodeResponseSchema = z + .object({ + device_code: z.string().min(1, "Device code cannot be empty"), + user_code: z.string().min(1, "User code cannot be empty"), + verification_uri: z.url("Invalid verification URL"), + verification_uri_complete: z.url("Invalid complete verification URL"), + expires_in: z + .number() + .int() + .positive("Expires in must be a positive integer"), + interval: z + .number() + .int() + .positive("Interval in must be a positive integer"), + }) + .transform((data) => ({ + deviceCode: data.device_code, + userCode: data.user_code, + verificationUri: data.verification_uri, + verificationUriComplete: data.verification_uri_complete, + expiresIn: data.expires_in, + interval: data.interval, + })); export type DeviceCodeResponse = z.infer; -export const TokenResponseSchema = z.object({ - token: z.string().min(1, "Token cannot be empty"), - email: z.email("Invalid email address"), - name: z.string().min(1, "Name cannot be empty"), -}); +export const TokenResponseSchema = z + .object({ + access_token: z.string().min(1, "Token cannot be empty"), + token_type: z.string().min(1, "Token type cannot be empty"), + expires_in: z + .number() + .int() + .positive("Expires in must be a positive integer"), + refresh_token: z.string().min(1, "Refresh token cannot be empty"), + scope: z.string().optional(), + }) + .transform((data) => ({ + accessToken: data.access_token, + tokenType: data.token_type, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + scope: data.scope, + })); export type TokenResponse = z.infer; + +// OAuth error response schema +export const OAuthErrorSchema = z.object({ + error: z.enum([ + "authorization_pending", + "slow_down", + "expired_token", + "access_denied", + "invalid_grant", + ]), + error_description: z.string().optional(), +}); + +export type OAuthError = z.infer; diff --git a/src/core/consts.ts b/src/core/consts.ts index 7fbb9b4b..a7909326 100644 --- a/src/core/consts.ts +++ b/src/core/consts.ts @@ -20,3 +20,11 @@ export function getProjectConfigPatterns() { "config.json", ]; } + +export const AUTH_CLIENT_ID = "base44_cli"; + +const DEFAULT_API_URL = "https://app.base44.com"; + +export function getBase44ApiUrl(): string { + return process.env.BASE44_API_URL || DEFAULT_API_URL; +} diff --git a/src/core/errors.ts b/src/core/errors.ts index 15812e38..c9a64640 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -6,10 +6,7 @@ export class AuthApiError extends Error { } export class AuthValidationError extends Error { - constructor( - message: string, - public readonly issues: Array<{ message: string; path: string[] }> - ) { + constructor(message: string) { super(message); this.name = "AuthValidationError"; } diff --git a/src/core/resources/entity/config.ts b/src/core/resources/entity/config.ts index 37a8fff5..5b603b37 100644 --- a/src/core/resources/entity/config.ts +++ b/src/core/resources/entity/config.ts @@ -34,4 +34,3 @@ export async function readAllEntities(entitiesDir: string): Promise { return entities; } - diff --git a/src/core/utils/httpClient.ts b/src/core/utils/httpClient.ts new file mode 100644 index 00000000..d1f86d94 --- /dev/null +++ b/src/core/utils/httpClient.ts @@ -0,0 +1,50 @@ +import ky from "ky"; +import type { KyRequest, KyResponse, NormalizedOptions } from "ky"; +import { getBase44ApiUrl } from "../consts.js"; +import { readAuth, refreshAndSaveTokens } from "../auth/config.js"; + +/** + * Handles 401 responses by refreshing the token and retrying the request. + */ +async function handleUnauthorized( + request: KyRequest, + _options: NormalizedOptions, + response: KyResponse +): Promise { + if (response.status !== 401) { + return; + } + + const newAccessToken = await refreshAndSaveTokens(); + + if (!newAccessToken) { + // Refresh failed, let the 401 propagate + return; + } + + // Retry the request with new token + request.headers.set("Authorization", `Bearer ${newAccessToken}`); + return ky(request); +} + +const httpClient = ky.create({ + prefixUrl: getBase44ApiUrl(), + headers: { + "User-Agent": "Base44 CLI", + }, + hooks: { + beforeRequest: [ + async (request) => { + try { + const auth = await readAuth(); + request.headers.set("Authorization", `Bearer ${auth.accessToken}`); + } catch { + // No auth available, continue without header + } + }, + ], + afterResponse: [handleUnauthorized], + }, +}); + +export default httpClient; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 844adcf0..b6995bfe 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -1 +1,2 @@ export * from "./fs.js"; +export * from "./httpClient.js"; From e6373dabd58d0ca9c317891e89d6dc96969e29a9 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Thu, 8 Jan 2026 11:27:16 +0200 Subject: [PATCH 2/9] added refresh logic fixes --- src/cli/commands/auth/login.ts | 10 +++++-- src/cli/utils/runCommand.ts | 1 - src/core/auth/api.ts | 4 ++- src/core/auth/config.ts | 55 ++++++++++++++++++++++++---------- src/core/auth/schema.ts | 9 ++---- src/core/utils/httpClient.ts | 28 +++++++++++++++-- 6 files changed, 78 insertions(+), 29 deletions(-) diff --git a/src/cli/commands/auth/login.ts b/src/cli/commands/auth/login.ts index 649c3bac..5d332f44 100644 --- a/src/cli/commands/auth/login.ts +++ b/src/cli/commands/auth/login.ts @@ -31,7 +31,8 @@ async function generateAndDisplayDeviceCode(): Promise { async function waitForAuthentication( deviceCode: string, - expiresIn: number + expiresIn: number, + interval: number ): Promise { let tokenResponse: TokenResponse | undefined; @@ -49,7 +50,7 @@ async function waitForAuthentication( return false; }, { - interval: 2000, + interval: interval * 1000, timeout: expiresIn * 1000, } ); @@ -76,9 +77,11 @@ async function waitForAuthentication( async function saveAuthData(response: TokenResponse): Promise { // TODO: Fetch user info (email, name) from the server after authentication // For now, we store placeholder values until a /userinfo endpoint is available + const expiresAt = Date.now() + response.expiresIn * 1000; await writeAuth({ accessToken: response.accessToken, refreshToken: response.refreshToken, + expiresAt, email: "user@base44.com", name: "Base44 User", }); @@ -89,7 +92,8 @@ async function login(): Promise { const token = await waitForAuthentication( deviceCodeResponse.deviceCode, - deviceCodeResponse.expiresIn + deviceCodeResponse.expiresIn, + deviceCodeResponse.interval ); await saveAuthData(token); diff --git a/src/cli/utils/runCommand.ts b/src/cli/utils/runCommand.ts index 04c8ea8f..431a0e20 100644 --- a/src/cli/utils/runCommand.ts +++ b/src/cli/utils/runCommand.ts @@ -1,6 +1,5 @@ import { intro, log } from "@clack/prompts"; import chalk from "chalk"; -import { AuthApiError, AuthValidationError } from "@core/errors.js"; const base44Color = chalk.bgHex("#E86B3C"); diff --git a/src/core/auth/api.ts b/src/core/auth/api.ts index 41602ab4..c93f0853 100644 --- a/src/core/auth/api.ts +++ b/src/core/auth/api.ts @@ -59,7 +59,9 @@ export async function getTokenFromDeviceCode( const errorResult = OAuthErrorSchema.safeParse(json); if (!errorResult.success) { - throw new AuthApiError(`Token request failed: ${response.statusText}`); + throw new AuthValidationError( + `Token request failed: ${errorResult.error.message}` + ); } const { error, error_description } = errorResult.data; diff --git a/src/core/auth/config.ts b/src/core/auth/config.ts index 4c07daee..393fb2ca 100644 --- a/src/core/auth/config.ts +++ b/src/core/auth/config.ts @@ -4,6 +4,12 @@ import { renewAccessToken } from "./api.js"; import { AuthDataSchema } from "./schema.js"; import type { AuthData } from "./schema.js"; +// Buffer time before expiration to trigger proactive refresh (60 seconds) +const TOKEN_REFRESH_BUFFER_MS = 60 * 1000; + +// Lock to prevent concurrent token refreshes +let refreshPromise: Promise | null = null; + export async function readAuth(): Promise { try { const parsed = await readJsonFile(getAuthFilePath()); @@ -67,26 +73,45 @@ export async function deleteAuth(): Promise { } } +/** + * Checks if the access token is expired or about to expire. + */ +export function isTokenExpired(auth: AuthData): boolean { + return Date.now() >= auth.expiresAt - TOKEN_REFRESH_BUFFER_MS; +} + /** * Refreshes the access token and saves the new tokens. * Returns the new access token, or null if refresh failed. - * Used by httpClient to handle 401 responses. + * Uses a lock to prevent concurrent refresh requests. */ export async function refreshAndSaveTokens(): Promise { - try { - const auth = await readAuth(); - const tokenResponse = await renewAccessToken(auth.refreshToken); + // If a refresh is already in progress, wait for it + if (refreshPromise) { + return refreshPromise; + } - await writeAuth({ - ...auth, - accessToken: tokenResponse.accessToken, - refreshToken: tokenResponse.refreshToken, - }); + refreshPromise = (async () => { + try { + const auth = await readAuth(); + const tokenResponse = await renewAccessToken(auth.refreshToken); - return tokenResponse.accessToken; - } catch { - // Refresh failed - delete auth, user needs to login again - await deleteAuth(); - return null; - } + await writeAuth({ + ...auth, + accessToken: tokenResponse.accessToken, + refreshToken: tokenResponse.refreshToken, + expiresAt: Date.now() + tokenResponse.expiresIn * 1000, + }); + + return tokenResponse.accessToken; + } catch { + // Refresh failed - delete auth, user needs to login again + await deleteAuth(); + return null; + } finally { + refreshPromise = null; + } + })(); + + return refreshPromise; } diff --git a/src/core/auth/schema.ts b/src/core/auth/schema.ts index f619fe2d..23214323 100644 --- a/src/core/auth/schema.ts +++ b/src/core/auth/schema.ts @@ -4,6 +4,7 @@ import { z } from "zod"; export const AuthDataSchema = z.object({ accessToken: z.string().min(1, "Token cannot be empty"), refreshToken: z.string().min(1, "Refresh token cannot be empty"), + expiresAt: z.number().int().positive("Expires at must be a positive integer"), email: z.email(), name: z.string().min(1, "Name cannot be empty"), }); @@ -60,13 +61,7 @@ export type TokenResponse = z.infer; // OAuth error response schema export const OAuthErrorSchema = z.object({ - error: z.enum([ - "authorization_pending", - "slow_down", - "expired_token", - "access_denied", - "invalid_grant", - ]), + error: z.string(), error_description: z.string().optional(), }); diff --git a/src/core/utils/httpClient.ts b/src/core/utils/httpClient.ts index d1f86d94..57cf5681 100644 --- a/src/core/utils/httpClient.ts +++ b/src/core/utils/httpClient.ts @@ -1,10 +1,18 @@ import ky from "ky"; import type { KyRequest, KyResponse, NormalizedOptions } from "ky"; import { getBase44ApiUrl } from "../consts.js"; -import { readAuth, refreshAndSaveTokens } from "../auth/config.js"; +import { + readAuth, + refreshAndSaveTokens, + isTokenExpired, +} from "../auth/config.js"; + +// Track requests that have already been retried to prevent infinite loops +const retriedRequests = new WeakSet(); /** * Handles 401 responses by refreshing the token and retrying the request. + * Only retries once per request to prevent infinite loops. */ async function handleUnauthorized( request: KyRequest, @@ -15,6 +23,11 @@ async function handleUnauthorized( return; } + // Prevent infinite retry loop - only retry once per request + if (retriedRequests.has(request)) { + return; + } + const newAccessToken = await refreshAndSaveTokens(); if (!newAccessToken) { @@ -22,7 +35,8 @@ async function handleUnauthorized( return; } - // Retry the request with new token + // Mark this request as retried and retry with new token + retriedRequests.add(request); request.headers.set("Authorization", `Bearer ${newAccessToken}`); return ky(request); } @@ -37,6 +51,16 @@ const httpClient = ky.create({ async (request) => { try { const auth = await readAuth(); + + // Proactively refresh if token is expired or about to expire + if (isTokenExpired(auth)) { + const newAccessToken = await refreshAndSaveTokens(); + if (newAccessToken) { + request.headers.set("Authorization", `Bearer ${newAccessToken}`); + return; + } + } + request.headers.set("Authorization", `Bearer ${auth.accessToken}`); } catch { // No auth available, continue without header From f04b6f1c62e03f79bda3aa60a8ae7e34afe112fe Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Thu, 8 Jan 2026 14:45:59 +0200 Subject: [PATCH 3/9] fix package version issue and add a userInfo method --- src/cli/commands/auth/login.ts | 5 +---- src/cli/index.ts | 4 ++-- src/cli/utils/index.ts | 2 -- src/cli/utils/packageVersion.ts | 14 -------------- src/core/auth/api.ts | 21 ++++++++++++++++++++- src/core/auth/schema.ts | 9 +++++++-- 6 files changed, 30 insertions(+), 25 deletions(-) delete mode 100644 src/cli/utils/packageVersion.ts diff --git a/src/cli/commands/auth/login.ts b/src/cli/commands/auth/login.ts index 5d332f44..039ee54a 100644 --- a/src/cli/commands/auth/login.ts +++ b/src/cli/commands/auth/login.ts @@ -21,10 +21,7 @@ async function generateAndDisplayDeviceCode(): Promise { } ); - log.info( - `Please visit: ${deviceCodeResponse.verificationUri}\n` + - `Enter your device code: ${deviceCodeResponse.userCode}` - ); + log.info(`Please visit: ${deviceCodeResponse.verificationUriComplete}`); return deviceCodeResponse; } diff --git a/src/cli/index.ts b/src/cli/index.ts index f0021b87..b7ee129d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,11 +1,11 @@ #!/usr/bin/env node import { Command } from "commander"; -import { getPackageVersion } from "./utils/index.js"; import { loginCommand } from "./commands/auth/login.js"; import { whoamiCommand } from "./commands/auth/whoami.js"; import { logoutCommand } from "./commands/auth/logout.js"; import { showProjectCommand } from "./commands/project/show-project.js"; +import packageJson from "../../package.json"; const program = new Command(); @@ -14,7 +14,7 @@ program .description( "Base44 CLI - Unified interface for managing Base44 applications" ) - .version(getPackageVersion()); + .version(packageJson.version); // Register authentication commands program.addCommand(loginCommand); diff --git a/src/cli/utils/index.ts b/src/cli/utils/index.ts index 8e7b5ed6..f28dc0d6 100644 --- a/src/cli/utils/index.ts +++ b/src/cli/utils/index.ts @@ -1,4 +1,2 @@ -export * from "./packageVersion.js"; export * from "./runCommand.js"; export * from "./runTask.js"; - diff --git a/src/cli/utils/packageVersion.ts b/src/cli/utils/packageVersion.ts deleted file mode 100644 index 26ce352e..00000000 --- a/src/cli/utils/packageVersion.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; - -export function getPackageVersion(): string { - const __filename = fileURLToPath(import.meta.url); - const __dirname = dirname(__filename); - - // From dist/cli/index.js -> package.json at root (bundled output) - const packageJsonPath = join(__dirname, "..", "..", "package.json"); - const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); - return packageJson.version; -} - diff --git a/src/core/auth/api.ts b/src/core/auth/api.ts index c93f0853..74abae3d 100644 --- a/src/core/auth/api.ts +++ b/src/core/auth/api.ts @@ -3,10 +3,16 @@ import { DeviceCodeResponseSchema, TokenResponseSchema, OAuthErrorSchema, + UserInfoSchema, +} from "./schema.js"; +import type { + DeviceCodeResponse, + TokenResponse, + UserInfoResponse, } from "./schema.js"; -import type { DeviceCodeResponse, TokenResponse } from "./schema.js"; import { AUTH_CLIENT_ID } from "../consts.js"; import authClient from "./authClient.js"; +import httpClient from "../utils/httpClient.js"; export async function generateDeviceCode(): Promise { const response = await authClient.post("oauth/device/code", { @@ -125,3 +131,16 @@ export async function renewAccessToken( return result.data; } + +export async function getUserInfo(): Promise { + const response = await httpClient.get("oauth/userinfo"); + const result = UserInfoSchema.safeParse(await response.json()); + + if (!result.success) { + throw new AuthValidationError( + `Invalid UserInfo response from server: ${result.error.message}` + ); + } + + return result.data; +} diff --git a/src/core/auth/schema.ts b/src/core/auth/schema.ts index 23214323..abe39a2d 100644 --- a/src/core/auth/schema.ts +++ b/src/core/auth/schema.ts @@ -11,7 +11,6 @@ export const AuthDataSchema = z.object({ export type AuthData = z.infer; -// API response schemas export const DeviceCodeResponseSchema = z .object({ device_code: z.string().min(1, "Device code cannot be empty"), @@ -59,10 +58,16 @@ export const TokenResponseSchema = z export type TokenResponse = z.infer; -// OAuth error response schema export const OAuthErrorSchema = z.object({ error: z.string(), error_description: z.string().optional(), }); export type OAuthError = z.infer; + +export const UserInfoSchema = z.object({ + email: z.email(), + name: z.string(), +}); + +export type UserInfoResponse = z.infer; From 85f1e63eb1245a2df04ab6fad709deee44bbe275 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Thu, 8 Jan 2026 15:10:28 +0200 Subject: [PATCH 4/9] actual call the getUserInfo service --- src/cli/commands/auth/login.ts | 8 +++++--- src/core/auth/api.ts | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/auth/login.ts b/src/cli/commands/auth/login.ts index 039ee54a..2283d9f6 100644 --- a/src/cli/commands/auth/login.ts +++ b/src/cli/commands/auth/login.ts @@ -5,6 +5,7 @@ import { writeAuth, generateDeviceCode, getTokenFromDeviceCode, + getUserInfo, } from "@core/auth/index.js"; import type { DeviceCodeResponse, TokenResponse } from "@core/auth/index.js"; import { runCommand, runTask } from "../../utils/index.js"; @@ -72,15 +73,16 @@ async function waitForAuthentication( } async function saveAuthData(response: TokenResponse): Promise { - // TODO: Fetch user info (email, name) from the server after authentication + const userInfo = await getUserInfo(); + // For now, we store placeholder values until a /userinfo endpoint is available const expiresAt = Date.now() + response.expiresIn * 1000; await writeAuth({ accessToken: response.accessToken, refreshToken: response.refreshToken, expiresAt, - email: "user@base44.com", - name: "Base44 User", + email: userInfo.email, + name: userInfo.name, }); } diff --git a/src/core/auth/api.ts b/src/core/auth/api.ts index 74abae3d..076ee878 100644 --- a/src/core/auth/api.ts +++ b/src/core/auth/api.ts @@ -134,6 +134,11 @@ export async function renewAccessToken( export async function getUserInfo(): Promise { const response = await httpClient.get("oauth/userinfo"); + + if (!response.ok) { + throw new AuthApiError(`Failed to fetch user info: ${response.status}`); + } + const result = UserInfoSchema.safeParse(await response.json()); if (!result.success) { From 248994d8ffa8f8d3a88d28091164d69258551dbc Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Sun, 11 Jan 2026 09:48:41 +0200 Subject: [PATCH 5/9] added userInfo call and log --- src/cli/commands/auth/login.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/cli/commands/auth/login.ts b/src/cli/commands/auth/login.ts index 2283d9f6..245577cb 100644 --- a/src/cli/commands/auth/login.ts +++ b/src/cli/commands/auth/login.ts @@ -7,8 +7,13 @@ import { getTokenFromDeviceCode, getUserInfo, } from "@core/auth/index.js"; -import type { DeviceCodeResponse, TokenResponse } from "@core/auth/index.js"; +import type { + DeviceCodeResponse, + TokenResponse, + UserInfoResponse, +} from "@core/auth/index.js"; import { runCommand, runTask } from "../../utils/index.js"; +import { UserInfo } from "node:os"; async function generateAndDisplayDeviceCode(): Promise { const deviceCodeResponse = await runTask( @@ -72,9 +77,10 @@ async function waitForAuthentication( return tokenResponse; } -async function saveAuthData(response: TokenResponse): Promise { - const userInfo = await getUserInfo(); - +async function saveAuthData( + response: TokenResponse, + userInfo: UserInfoResponse +): Promise { // For now, we store placeholder values until a /userinfo endpoint is available const expiresAt = Date.now() + response.expiresIn * 1000; await writeAuth({ @@ -95,9 +101,11 @@ async function login(): Promise { deviceCodeResponse.interval ); - await saveAuthData(token); + const userInfo = await getUserInfo(); + + await saveAuthData(token, userInfo); - log.success("Successfully logged in!"); + log.success(`Successfully logged as ${userInfo.name} (${userInfo.email})`); } export const loginCommand = new Command("login") From 0687ed4cf881272594328170365bf776370989f3 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Sun, 11 Jan 2026 10:39:40 +0200 Subject: [PATCH 6/9] small ui changes --- src/cli/commands/auth/login.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/auth/login.ts b/src/cli/commands/auth/login.ts index 245577cb..0afe756e 100644 --- a/src/cli/commands/auth/login.ts +++ b/src/cli/commands/auth/login.ts @@ -1,4 +1,5 @@ import { Command } from "commander"; +import chalk from "chalk"; import { log } from "@clack/prompts"; import pWaitFor from "p-wait-for"; import { @@ -13,7 +14,6 @@ import type { UserInfoResponse, } from "@core/auth/index.js"; import { runCommand, runTask } from "../../utils/index.js"; -import { UserInfo } from "node:os"; async function generateAndDisplayDeviceCode(): Promise { const deviceCodeResponse = await runTask( @@ -27,7 +27,10 @@ async function generateAndDisplayDeviceCode(): Promise { } ); - log.info(`Please visit: ${deviceCodeResponse.verificationUriComplete}`); + log.info( + `Your code is: ${chalk.bold(deviceCodeResponse.userCode)}` + + `\nPlease visit: ${deviceCodeResponse.verificationUriComplete}` + ); return deviceCodeResponse; } @@ -105,7 +108,7 @@ async function login(): Promise { await saveAuthData(token, userInfo); - log.success(`Successfully logged as ${userInfo.name} (${userInfo.email})`); + log.success(`Successfully logged as ${chalk.bold(userInfo.email)}`); } export const loginCommand = new Command("login") From b9edeb003cf08e4d1c521189e5db3fd2a0c8a365 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Sun, 11 Jan 2026 11:46:48 +0200 Subject: [PATCH 7/9] get user info with access token --- src/cli/commands/auth/login.ts | 2 +- src/core/auth/api.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/auth/login.ts b/src/cli/commands/auth/login.ts index 0afe756e..6b67bc7b 100644 --- a/src/cli/commands/auth/login.ts +++ b/src/cli/commands/auth/login.ts @@ -104,7 +104,7 @@ async function login(): Promise { deviceCodeResponse.interval ); - const userInfo = await getUserInfo(); + const userInfo = await getUserInfo(token.accessToken); await saveAuthData(token, userInfo); diff --git a/src/core/auth/api.ts b/src/core/auth/api.ts index 076ee878..8d85130a 100644 --- a/src/core/auth/api.ts +++ b/src/core/auth/api.ts @@ -132,8 +132,12 @@ export async function renewAccessToken( return result.data; } -export async function getUserInfo(): Promise { - const response = await httpClient.get("oauth/userinfo"); +export async function getUserInfo( + accessToken: string +): Promise { + const response = await authClient.get("oauth/userinfo", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); if (!response.ok) { throw new AuthApiError(`Failed to fetch user info: ${response.status}`); From 598e8aada06d06d5ff93e956b68be1b3a154077b Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Sun, 11 Jan 2026 11:52:16 +0200 Subject: [PATCH 8/9] tiny changes --- src/cli/commands/auth/login.ts | 4 ++-- src/core/auth/api.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/auth/login.ts b/src/cli/commands/auth/login.ts index 6b67bc7b..29710c76 100644 --- a/src/cli/commands/auth/login.ts +++ b/src/cli/commands/auth/login.ts @@ -84,8 +84,8 @@ async function saveAuthData( response: TokenResponse, userInfo: UserInfoResponse ): Promise { - // For now, we store placeholder values until a /userinfo endpoint is available const expiresAt = Date.now() + response.expiresIn * 1000; + await writeAuth({ accessToken: response.accessToken, refreshToken: response.refreshToken, @@ -108,7 +108,7 @@ async function login(): Promise { await saveAuthData(token, userInfo); - log.success(`Successfully logged as ${chalk.bold(userInfo.email)}`); + log.success(`Successfully logged in as ${chalk.bold(userInfo.email)}`); } export const loginCommand = new Command("login") diff --git a/src/core/auth/api.ts b/src/core/auth/api.ts index 8d85130a..7255a754 100644 --- a/src/core/auth/api.ts +++ b/src/core/auth/api.ts @@ -12,7 +12,6 @@ import type { } from "./schema.js"; import { AUTH_CLIENT_ID } from "../consts.js"; import authClient from "./authClient.js"; -import httpClient from "../utils/httpClient.js"; export async function generateDeviceCode(): Promise { const response = await authClient.post("oauth/device/code", { From dd205dc51eb9eb5e9acb4a94560156287cb74c87 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Sun, 11 Jan 2026 11:58:13 +0200 Subject: [PATCH 9/9] fix issue with correct way to use ky --- src/core/utils/httpClient.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/utils/httpClient.ts b/src/core/utils/httpClient.ts index 57cf5681..f16bdb4a 100644 --- a/src/core/utils/httpClient.ts +++ b/src/core/utils/httpClient.ts @@ -37,8 +37,9 @@ async function handleUnauthorized( // Mark this request as retried and retry with new token retriedRequests.add(request); - request.headers.set("Authorization", `Bearer ${newAccessToken}`); - return ky(request); + return ky(request, { + headers: { Authorization: `Bearer ${newAccessToken}` }, + }); } const httpClient = ky.create({