From 5cb36761f3be6e0a560ea3092048678b20065c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Volf?= Date: Fri, 28 Nov 2025 14:18:56 +0100 Subject: [PATCH] feat: properly implement the logic to use CLI with multiple backends There were some commented stubs and leftover conditions in the code, but they didn't really work properly. This would be really helpful for me for work on core, so when I found the stubs, I decided to actually implement the support properly. This required adding a new version of the auth.json file, I implemented the logic that it transparently upgrades the old format to the new one. --- features/test-implementations/1.setup.ts | 2 +- src/commands/login.ts | 11 +- src/commands/logout.ts | 25 +++- src/lib/actor.ts | 2 +- src/lib/consts.ts | 2 + .../hooks/telemetry/useTelemetryEnabled.ts | 4 +- src/lib/hooks/telemetry/useTelemetryState.ts | 10 +- src/lib/utils.ts | 122 +++++++++++++++--- test/__setup__/config.ts | 4 +- test/api/commands/info.test.ts | 6 +- test/api/commands/log_in_out.test.ts | 7 +- test/local/commands/run.test.ts | 15 ++- 12 files changed, 156 insertions(+), 54 deletions(-) diff --git a/features/test-implementations/1.setup.ts b/features/test-implementations/1.setup.ts index 83b714845..ba547097c 100644 --- a/features/test-implementations/1.setup.ts +++ b/features/test-implementations/1.setup.ts @@ -193,7 +193,7 @@ Given(/logged in apify console user/i, async function () { } // Try to make the client with the token - const client = new ApifyClient(getApifyClientOptions(process.env.TEST_USER_TOKEN)); + const client = new ApifyClient(await getApifyClientOptions(process.env.TEST_USER_TOKEN)); try { await client.user('me').get(); diff --git a/src/commands/login.ts b/src/commands/login.ts index b5d2552ca..7aaf024ef 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -16,19 +16,18 @@ import { updateUserId } from '../lib/hooks/telemetry/useTelemetryState.js'; import { useMaskedInput } from '../lib/hooks/user-confirmations/useMaskedInput.js'; import { useSelectFromList } from '../lib/hooks/user-confirmations/useSelectFromList.js'; import { error, info, success } from '../lib/outputs.js'; -import { getLocalUserInfo, getLoggedClient, tildify } from '../lib/utils.js'; +import { getApifyAPIBaseUrl, getLocalUserInfo, getLoggedClient, tildify } from '../lib/utils.js'; -const CONSOLE_BASE_URL = 'https://console.apify.com/settings/integrations'; -// const CONSOLE_BASE_URL = 'http://localhost:3000/settings/integrations'; +const CONSOLE_BASE_URL = getApifyAPIBaseUrl()?.includes('localhost') + ? 'http://localhost:3000/settings/integrations' + : 'https://console.apify.com/settings/integrations'; const CONSOLE_URL_ORIGIN = new URL(CONSOLE_BASE_URL).origin; -const API_BASE_URL = CONSOLE_BASE_URL.includes('localhost') ? 'http://localhost:3333' : undefined; - // Not really checked right now, but it might come useful if we ever need to do some breaking changes const API_VERSION = 'v1'; const tryToLogin = async (token: string) => { - const isUserLogged = await getLoggedClient(token, API_BASE_URL); + const isUserLogged = await getLoggedClient(token); const userInfo = await getLocalUserInfo(); if (isUserLogged) { diff --git a/src/commands/logout.ts b/src/commands/logout.ts index 0cf076f61..710f99e4f 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -1,9 +1,8 @@ import { ApifyCommand } from '../lib/command-framework/apify-command.js'; import { AUTH_FILE_PATH } from '../lib/consts.js'; -import { rimrafPromised } from '../lib/files.js'; import { updateUserId } from '../lib/hooks/telemetry/useTelemetryState.js'; -import { success } from '../lib/outputs.js'; -import { tildify } from '../lib/utils.js'; +import { error, success } from '../lib/outputs.js'; +import { clearLocalUserInfo, listLocalUserInfos, tildify } from '../lib/utils.js'; export class LogoutCommand extends ApifyCommand { static override name = 'logout' as const; @@ -13,10 +12,24 @@ export class LogoutCommand extends ApifyCommand { `Run 'apify login' to authenticate again.`; async run() { - await rimrafPromised(AUTH_FILE_PATH()); + const wasLoggedOut = await clearLocalUserInfo(); + const remainingLogins = await listLocalUserInfos(); - await updateUserId(null); + let remainingBackendsInfo = ''; + if (remainingLogins.length > 0) { + // this message is probably never seen by public users, just Apify devs + const stringInfos = remainingLogins + .map(({ baseUrl, username }) => `- ${baseUrl} (user: ${username})`) + .join('\n'); + remainingBackendsInfo = ` You are still logged in to the following Apify authentication backends:\n${stringInfos}`; + } + + if (!wasLoggedOut) { + error({ message: `You were not logged in.${remainingBackendsInfo}` }); + return; + } - success({ message: 'You are logged out from your Apify account.' }); + await updateUserId(null); + success({ message: `You are logged out from the current Apify account.${remainingBackendsInfo}` }); } } diff --git a/src/lib/actor.ts b/src/lib/actor.ts index c896fd638..f0823c2c7 100644 --- a/src/lib/actor.ts +++ b/src/lib/actor.ts @@ -57,7 +57,7 @@ export const getApifyStorageClient = async ( const apifyToken = await getApifyTokenFromEnvOrAuthFile(); return new ApifyClient({ - ...getApifyClientOptions(apifyToken), + ...(await getApifyClientOptions(apifyToken)), ...options, }); }; diff --git a/src/lib/consts.ts b/src/lib/consts.ts index 5847aea6b..3a7ad106e 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -76,3 +76,5 @@ export enum CommandExitCodes { NotFound = 250, NotImplemented = 255, } + +export const DEFAULT_APIFY_API_BASE_URL = 'https://api.apify.com'; diff --git a/src/lib/hooks/telemetry/useTelemetryEnabled.ts b/src/lib/hooks/telemetry/useTelemetryEnabled.ts index 583651c1b..30be2e065 100644 --- a/src/lib/hooks/telemetry/useTelemetryEnabled.ts +++ b/src/lib/hooks/telemetry/useTelemetryEnabled.ts @@ -1,5 +1,5 @@ import { cliDebugPrint } from '../../utils/cliDebugPrint.js'; -import { useTelemetryState } from './useTelemetryState.js'; +import { isTelemetryDisabledInThisEnv, useTelemetryState } from './useTelemetryState.js'; export async function useTelemetryEnabled() { // Env variable present and not false/0 @@ -13,5 +13,5 @@ export async function useTelemetryEnabled() { cliDebugPrint('telemetry state', { telemetryState }); - return telemetryState.enabled; + return telemetryState.enabled && !isTelemetryDisabledInThisEnv(); } diff --git a/src/lib/hooks/telemetry/useTelemetryState.ts b/src/lib/hooks/telemetry/useTelemetryState.ts index 83a9e3d33..0e3e8d070 100644 --- a/src/lib/hooks/telemetry/useTelemetryState.ts +++ b/src/lib/hooks/telemetry/useTelemetryState.ts @@ -6,7 +6,7 @@ import { cryptoRandomObjectId } from '@apify/utilities'; import { TELEMETRY_FILE_PATH } from '../../consts.js'; import { info } from '../../outputs.js'; import type { AuthJSON } from '../../types.js'; -import { getLocalUserInfo } from '../../utils.js'; +import { getApifyAPIBaseUrl, getLocalUserInfo } from '../../utils.js'; type TelemetryState = TelemetryStateV0 | TelemetryStateV1; @@ -34,6 +34,10 @@ function createAnonymousId() { return `CLI:${cryptoRandomObjectId()}`; } +export function isTelemetryDisabledInThisEnv() { + return getApifyAPIBaseUrl() !== 'https://api.apify.com'; +} + async function migrateStateV0ToV1(state: TelemetryState) { if (state.version && state.version >= 1) { return false; @@ -89,7 +93,7 @@ export async function useTelemetryState(): Promise { export type StateUpdater = (state: LatestTelemetryState) => void; -export function updateTelemetryState(state: LatestTelemetryState, updater?: StateUpdater) { +function updateTelemetryState(state: LatestTelemetryState, updater?: StateUpdater) { // Update the state in memory const stateClone = { ...state }; updater?.(stateClone); @@ -103,6 +107,8 @@ export function updateTelemetryState(state: LatestTelemetryState, updater?: Stat } export async function updateUserId(userId: string | null) { + if (isTelemetryDisabledInThisEnv()) return; + const state = await useTelemetryState(); updateTelemetryState(state, (stateToUpdate) => { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 0303701bf..6132ded80 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -36,14 +36,15 @@ import { import { APIFY_CLIENT_DEFAULT_HEADERS, AUTH_FILE_PATH, + DEFAULT_APIFY_API_BASE_URL, DEFAULT_LOCAL_STORAGE_DIR, - GLOBAL_CONFIGS_FOLDER, INPUT_FILE_REG_EXP, LOCAL_CONFIG_PATH, MINIMUM_SUPPORTED_PYTHON_VERSION, SUPPORTED_NODEJS_VERSION, } from './consts.js'; import { deleteFile, ensureFolderExistsSync, rimrafPromised } from './files.js'; +import { warning } from './outputs.js'; import type { AuthJSON } from './types.js'; // Export AJV properly: https://github.com/ajv-validator/ajv/issues/2132 @@ -92,15 +93,72 @@ export const getLocalRequestQueuePath = (storeId?: string) => { return join(getLocalStorageDir(), LOCAL_STORAGE_SUBDIRS.requestQueues, storeDir); }; +let hasLoggedAPIBaseUrlDeprecation = false; +export const getApifyAPIBaseUrl = () => { + const envVar = APIFY_ENV_VARS.API_BASE_URL; + + const legacyVar = 'APIFY_CLIENT_BASE_URL'; + if (process.env[legacyVar]) { + if (!hasLoggedAPIBaseUrlDeprecation) { + warning({ message: `Environment variable '${legacyVar}' is deprecated. Please use '${envVar}' instead.` }); + hasLoggedAPIBaseUrlDeprecation = true; + } + return process.env[legacyVar]; + } + + // here we _could_ fallback to `undefined` and let ApifyClient to fill the default value, but this function is also + // used for identifying the stored token in the global auth file + // (to allow keeping a separate login for api.apify.com and localhost) + // it is probably safe to assume that the default is https://api.apify.com + return process.env[envVar] || DEFAULT_APIFY_API_BASE_URL; +}; + +interface MultiBackendAuthJSON { + _authFileVersion: 2; + /** Mapping of ApifyAPIBaseUrl to the AuthJSON for that backend */ + backends: Record; +} + /** - * Returns object from auth file or empty object. + * Returns info about logins stored for all available backends. */ -export const getLocalUserInfo = async (): Promise => { - let result: AuthJSON = {}; +const getAllLocalUserInfos = async (): Promise => { + let result: AuthJSON | MultiBackendAuthJSON = {}; try { const raw = await readFile(AUTH_FILE_PATH(), 'utf-8'); - result = JSON.parse(raw) as AuthJSON; + result = JSON.parse(raw) as AuthJSON | MultiBackendAuthJSON; } catch { + return { _authFileVersion: 2, backends: {} }; + } + + if ('_authFileVersion' in result) return result; + + // migrate to multi-backend format, assume the stored data is for the current backend + const backendUrl = getApifyAPIBaseUrl(); + const multiBackendResult: MultiBackendAuthJSON = { _authFileVersion: 2, backends: {} }; + multiBackendResult.backends[backendUrl] = result; + return multiBackendResult; +}; + +/** + * Lists stored user infos for all backends. + */ +export const listLocalUserInfos = async (): Promise<({ baseUrl: string } & Pick)[]> => { + const allInfos = await getAllLocalUserInfos(); + return Object.entries(allInfos.backends).map(([baseUrl, info]) => ({ + baseUrl, + username: info.username, + id: info.id, + })); +}; + +/** + * Returns object from auth file or empty object. + */ +export const getLocalUserInfo = async (): Promise => { + const allInfos = await getAllLocalUserInfos(); + const result = allInfos.backends[getApifyAPIBaseUrl()]; + if (!result) { return {}; } @@ -111,6 +169,34 @@ export const getLocalUserInfo = async (): Promise => { return result; }; +/** + * Persists auth info for the current backend + */ +export async function storeLocalUserInfo(userInfo: AuthJSON) { + ensureApifyDirectory(AUTH_FILE_PATH()); + + const allInfos = await getAllLocalUserInfos(); + allInfos.backends[getApifyAPIBaseUrl()] = userInfo; + + writeFileSync(AUTH_FILE_PATH(), JSON.stringify(allInfos, null, '\t')); +} + +/** + * Removes auth info for the current backend - effectively logs out the user. + * + * Returns true if info was removed, false if there was no info for this backend. + */ +export async function clearLocalUserInfo() { + const allInfos = await getAllLocalUserInfos(); + const backendUrl = getApifyAPIBaseUrl(); + + if (!allInfos.backends[backendUrl]) return false; + + delete allInfos.backends[backendUrl]; + writeFileSync(AUTH_FILE_PATH(), JSON.stringify(allInfos, null, '\t')); + return true; +} + /** * Gets instance of ApifyClient for user otherwise throws error */ @@ -124,13 +210,11 @@ export async function getLoggedClientOrThrow() { return loggedClient; } -const getTokenWithAuthFileFallback = (existingToken?: string) => { - if (!existingToken && existsSync(GLOBAL_CONFIGS_FOLDER()) && existsSync(AUTH_FILE_PATH())) { - const raw = readFileSync(AUTH_FILE_PATH(), 'utf-8'); - return JSON.parse(raw).token; - } +const getTokenWithAuthFileFallback = async (existingToken?: string) => { + if (existingToken) return existingToken; - return existingToken; + const userInfo = await getLocalUserInfo(); + return userInfo.token; }; // biome-ignore format: off @@ -139,12 +223,12 @@ type CJSAxiosHeaders = import('axios', { with: { 'resolution-mode': 'require' } /** * Returns options for ApifyClient */ -export const getApifyClientOptions = (token?: string, apiBaseUrl?: string): ApifyClientOptions => { - token = getTokenWithAuthFileFallback(token); +export const getApifyClientOptions = async (token?: string, apiBaseUrl?: string): Promise => { + token = await getTokenWithAuthFileFallback(token); return { token, - baseUrl: apiBaseUrl || process.env.APIFY_CLIENT_BASE_URL, + baseUrl: apiBaseUrl || getApifyAPIBaseUrl(), requestInterceptors: [ (config) => { config.headers ??= new AxiosHeaders() as CJSAxiosHeaders; @@ -165,9 +249,9 @@ export const getApifyClientOptions = (token?: string, apiBaseUrl?: string): Apif * @param [token] */ export async function getLoggedClient(token?: string, apiBaseUrl?: string) { - token = getTokenWithAuthFileFallback(token); + token = await getTokenWithAuthFileFallback(token); - const apifyClient = new ApifyClient(getApifyClientOptions(token, apiBaseUrl)); + const apifyClient = new ApifyClient(await getApifyClientOptions(token, apiBaseUrl)); let userInfo; try { @@ -177,9 +261,7 @@ export async function getLoggedClient(token?: string, apiBaseUrl?: string) { } // Always refresh Auth file - ensureApifyDirectory(AUTH_FILE_PATH()); - - writeFileSync(AUTH_FILE_PATH(), JSON.stringify({ token: apifyClient.token, ...userInfo }, null, '\t')); + await storeLocalUserInfo({ token: apifyClient.token, ...userInfo }); return apifyClient; } @@ -376,7 +458,7 @@ export const outputJobLog = async ({ apifyClient?: ApifyClient; }) => { const { id: logId, status } = job; - const client = apifyClient || new ApifyClient({ baseUrl: process.env.APIFY_CLIENT_BASE_URL }); + const client = apifyClient || new ApifyClient({ baseUrl: getApifyAPIBaseUrl() }); // In case job was already done just output log if (ACTOR_JOB_TERMINAL_STATUSES.includes(status as never)) { diff --git a/test/__setup__/config.ts b/test/__setup__/config.ts index b2435f994..0c14fdf3a 100644 --- a/test/__setup__/config.ts +++ b/test/__setup__/config.ts @@ -13,9 +13,9 @@ if (!ENV_TEST_USER_TOKEN) { throw Error('You must configure "TEST_USER_TOKEN" environment variable to run tests!'); } -export const testUserClient = new ApifyClient(getApifyClientOptions(ENV_TEST_USER_TOKEN)); +export const testUserClient = new ApifyClient(await getApifyClientOptions(ENV_TEST_USER_TOKEN)); -export const badUserClient = new ApifyClient(getApifyClientOptions(TEST_USER_BAD_TOKEN)); +export const badUserClient = new ApifyClient(await getApifyClientOptions(TEST_USER_BAD_TOKEN)); export const TEST_USER_TOKEN = ENV_TEST_USER_TOKEN; diff --git a/test/api/commands/info.test.ts b/test/api/commands/info.test.ts index 91aae86c2..1742b9521 100644 --- a/test/api/commands/info.test.ts +++ b/test/api/commands/info.test.ts @@ -1,8 +1,6 @@ -import { readFileSync } from 'node:fs'; - import { InfoCommand } from '../../../src/commands/info.js'; import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; -import { AUTH_FILE_PATH } from '../../../src/lib/consts.js'; +import { getLocalUserInfo } from '../../../src/lib/utils.js'; import { safeLogin, useAuthSetup } from '../../__setup__/hooks/useAuthSetup.js'; import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js'; @@ -21,7 +19,7 @@ describe('[api] apify info', () => { await safeLogin(); await testRunCommand(InfoCommand, {}); - const userInfoFromConfig = JSON.parse(readFileSync(AUTH_FILE_PATH(), 'utf8')); + const userInfoFromConfig = await getLocalUserInfo(); const spy = logSpy(); diff --git a/test/api/commands/log_in_out.test.ts b/test/api/commands/log_in_out.test.ts index 6987d4cfc..89011f98f 100644 --- a/test/api/commands/log_in_out.test.ts +++ b/test/api/commands/log_in_out.test.ts @@ -1,9 +1,10 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import axios from 'axios'; import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; import { AUTH_FILE_PATH } from '../../../src/lib/consts.js'; +import { getLocalUserInfo } from '../../../src/lib/utils.js'; import { TEST_USER_BAD_TOKEN, TEST_USER_TOKEN, testUserClient } from '../../__setup__/config.js'; import { safeLogin, useAuthSetup } from '../../__setup__/hooks/useAuthSetup.js'; import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js'; @@ -34,7 +35,7 @@ describe('[api] apify login and logout', () => { const expectedUserInfo = Object.assign(await testUserClient.user('me').get(), { token: TEST_USER_TOKEN, }) as unknown as Record; - const userInfoFromConfig = JSON.parse(readFileSync(AUTH_FILE_PATH(), 'utf8')); + const userInfoFromConfig = (await getLocalUserInfo()) as unknown as Record; expect(lastErrorMessage()).to.include('Success:'); @@ -85,7 +86,7 @@ describe('[api] apify login and logout', () => { const expectedUserInfo = Object.assign(await testUserClient.user('me').get(), { token: TEST_USER_TOKEN, }) as unknown as Record; - const userInfoFromConfig = JSON.parse(readFileSync(AUTH_FILE_PATH(), 'utf8')); + const userInfoFromConfig = (await getLocalUserInfo()) as unknown as Record; expect(lastErrorMessage()).to.include('Success:'); diff --git a/test/local/commands/run.test.ts b/test/local/commands/run.test.ts index dbbf6fc9b..771375906 100644 --- a/test/local/commands/run.test.ts +++ b/test/local/commands/run.test.ts @@ -4,13 +4,14 @@ import { dirname } from 'node:path/win32'; import { APIFY_ENV_VARS } from '@apify/consts'; import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; -import { AUTH_FILE_PATH, EMPTY_LOCAL_CONFIG, LOCAL_CONFIG_PATH } from '../../../src/lib/consts.js'; +import { EMPTY_LOCAL_CONFIG, LOCAL_CONFIG_PATH } from '../../../src/lib/consts.js'; import { rimrafPromised } from '../../../src/lib/files.js'; import { getLocalDatasetPath, getLocalKeyValueStorePath, getLocalRequestQueuePath, getLocalStorageDir, + getLocalUserInfo, } from '../../../src/lib/utils.js'; import { TEST_TIMEOUT } from '../../__setup__/consts.js'; import { safeLogin, useAuthSetup } from '../../__setup__/hooks/useAuthSetup.js'; @@ -123,9 +124,9 @@ describe('apify run', () => { const actOutputPath = joinPath(getLocalKeyValueStorePath(), 'OUTPUT.json'); const localEnvVars = JSON.parse(readFileSync(actOutputPath, 'utf8')); - const auth = JSON.parse(readFileSync(AUTH_FILE_PATH(), 'utf8')); + const auth = await getLocalUserInfo(); - expect(localEnvVars[APIFY_ENV_VARS.PROXY_PASSWORD]).toStrictEqual(auth.proxy.password); + expect(localEnvVars[APIFY_ENV_VARS.PROXY_PASSWORD]).toStrictEqual(auth.proxy!.password); expect(localEnvVars[APIFY_ENV_VARS.USER_ID]).toStrictEqual(auth.id); expect(localEnvVars[APIFY_ENV_VARS.TOKEN]).toStrictEqual(auth.token); expect(localEnvVars.TEST_LOCAL).toStrictEqual(testEnvVars.TEST_LOCAL); @@ -164,9 +165,9 @@ describe('apify run', () => { const actOutputPath = joinPath(getLocalKeyValueStorePath(), 'OUTPUT.json'); const localEnvVars = JSON.parse(readFileSync(actOutputPath, 'utf8')); - const auth = JSON.parse(readFileSync(AUTH_FILE_PATH(), 'utf8')); + const auth = await getLocalUserInfo(); - expect(localEnvVars[APIFY_ENV_VARS.PROXY_PASSWORD]).toStrictEqual(auth.proxy.password); + expect(localEnvVars[APIFY_ENV_VARS.PROXY_PASSWORD]).toStrictEqual(auth.proxy!.password); expect(localEnvVars[APIFY_ENV_VARS.USER_ID]).toStrictEqual(auth.id); expect(localEnvVars[APIFY_ENV_VARS.TOKEN]).toStrictEqual(auth.token); expect(localEnvVars.TEST_LOCAL).toStrictEqual(testEnvVars.TEST_LOCAL); @@ -204,9 +205,9 @@ describe('apify run', () => { const actOutputPath = joinPath(getLocalKeyValueStorePath(), 'OUTPUT.json'); const localEnvVars = JSON.parse(readFileSync(actOutputPath, 'utf8')); - const auth = JSON.parse(readFileSync(AUTH_FILE_PATH(), 'utf8')); + const auth = await getLocalUserInfo(); - expect(localEnvVars[APIFY_ENV_VARS.PROXY_PASSWORD]).toStrictEqual(auth.proxy.password); + expect(localEnvVars[APIFY_ENV_VARS.PROXY_PASSWORD]).toStrictEqual(auth.proxy!.password); expect(localEnvVars[APIFY_ENV_VARS.USER_ID]).toStrictEqual(auth.id); expect(localEnvVars[APIFY_ENV_VARS.TOKEN]).toStrictEqual(auth.token); expect(localEnvVars.TEST_LOCAL).toStrictEqual(testEnvVars.TEST_LOCAL);