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);