Skip to content

Commit 5cb3676

Browse files
committed
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.
1 parent 5ea47ca commit 5cb3676

File tree

12 files changed

+156
-54
lines changed

12 files changed

+156
-54
lines changed

features/test-implementations/1.setup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ Given<TestWorld>(/logged in apify console user/i, async function () {
193193
}
194194

195195
// Try to make the client with the token
196-
const client = new ApifyClient(getApifyClientOptions(process.env.TEST_USER_TOKEN));
196+
const client = new ApifyClient(await getApifyClientOptions(process.env.TEST_USER_TOKEN));
197197

198198
try {
199199
await client.user('me').get();

src/commands/login.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,18 @@ import { updateUserId } from '../lib/hooks/telemetry/useTelemetryState.js';
1616
import { useMaskedInput } from '../lib/hooks/user-confirmations/useMaskedInput.js';
1717
import { useSelectFromList } from '../lib/hooks/user-confirmations/useSelectFromList.js';
1818
import { error, info, success } from '../lib/outputs.js';
19-
import { getLocalUserInfo, getLoggedClient, tildify } from '../lib/utils.js';
19+
import { getApifyAPIBaseUrl, getLocalUserInfo, getLoggedClient, tildify } from '../lib/utils.js';
2020

21-
const CONSOLE_BASE_URL = 'https://console.apify.com/settings/integrations';
22-
// const CONSOLE_BASE_URL = 'http://localhost:3000/settings/integrations';
21+
const CONSOLE_BASE_URL = getApifyAPIBaseUrl()?.includes('localhost')
22+
? 'http://localhost:3000/settings/integrations'
23+
: 'https://console.apify.com/settings/integrations';
2324
const CONSOLE_URL_ORIGIN = new URL(CONSOLE_BASE_URL).origin;
2425

25-
const API_BASE_URL = CONSOLE_BASE_URL.includes('localhost') ? 'http://localhost:3333' : undefined;
26-
2726
// Not really checked right now, but it might come useful if we ever need to do some breaking changes
2827
const API_VERSION = 'v1';
2928

3029
const tryToLogin = async (token: string) => {
31-
const isUserLogged = await getLoggedClient(token, API_BASE_URL);
30+
const isUserLogged = await getLoggedClient(token);
3231
const userInfo = await getLocalUserInfo();
3332

3433
if (isUserLogged) {

src/commands/logout.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { ApifyCommand } from '../lib/command-framework/apify-command.js';
22
import { AUTH_FILE_PATH } from '../lib/consts.js';
3-
import { rimrafPromised } from '../lib/files.js';
43
import { updateUserId } from '../lib/hooks/telemetry/useTelemetryState.js';
5-
import { success } from '../lib/outputs.js';
6-
import { tildify } from '../lib/utils.js';
4+
import { error, success } from '../lib/outputs.js';
5+
import { clearLocalUserInfo, listLocalUserInfos, tildify } from '../lib/utils.js';
76

87
export class LogoutCommand extends ApifyCommand<typeof LogoutCommand> {
98
static override name = 'logout' as const;
@@ -13,10 +12,24 @@ export class LogoutCommand extends ApifyCommand<typeof LogoutCommand> {
1312
`Run 'apify login' to authenticate again.`;
1413

1514
async run() {
16-
await rimrafPromised(AUTH_FILE_PATH());
15+
const wasLoggedOut = await clearLocalUserInfo();
16+
const remainingLogins = await listLocalUserInfos();
1717

18-
await updateUserId(null);
18+
let remainingBackendsInfo = '';
19+
if (remainingLogins.length > 0) {
20+
// this message is probably never seen by public users, just Apify devs
21+
const stringInfos = remainingLogins
22+
.map(({ baseUrl, username }) => `- ${baseUrl} (user: ${username})`)
23+
.join('\n');
24+
remainingBackendsInfo = ` You are still logged in to the following Apify authentication backends:\n${stringInfos}`;
25+
}
26+
27+
if (!wasLoggedOut) {
28+
error({ message: `You were not logged in.${remainingBackendsInfo}` });
29+
return;
30+
}
1931

20-
success({ message: 'You are logged out from your Apify account.' });
32+
await updateUserId(null);
33+
success({ message: `You are logged out from the current Apify account.${remainingBackendsInfo}` });
2134
}
2235
}

src/lib/actor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export const getApifyStorageClient = async (
5757
const apifyToken = await getApifyTokenFromEnvOrAuthFile();
5858

5959
return new ApifyClient({
60-
...getApifyClientOptions(apifyToken),
60+
...(await getApifyClientOptions(apifyToken)),
6161
...options,
6262
});
6363
};

src/lib/consts.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,5 @@ export enum CommandExitCodes {
7676
NotFound = 250,
7777
NotImplemented = 255,
7878
}
79+
80+
export const DEFAULT_APIFY_API_BASE_URL = 'https://api.apify.com';

src/lib/hooks/telemetry/useTelemetryEnabled.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { cliDebugPrint } from '../../utils/cliDebugPrint.js';
2-
import { useTelemetryState } from './useTelemetryState.js';
2+
import { isTelemetryDisabledInThisEnv, useTelemetryState } from './useTelemetryState.js';
33

44
export async function useTelemetryEnabled() {
55
// Env variable present and not false/0
@@ -13,5 +13,5 @@ export async function useTelemetryEnabled() {
1313

1414
cliDebugPrint('telemetry state', { telemetryState });
1515

16-
return telemetryState.enabled;
16+
return telemetryState.enabled && !isTelemetryDisabledInThisEnv();
1717
}

src/lib/hooks/telemetry/useTelemetryState.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { cryptoRandomObjectId } from '@apify/utilities';
66
import { TELEMETRY_FILE_PATH } from '../../consts.js';
77
import { info } from '../../outputs.js';
88
import type { AuthJSON } from '../../types.js';
9-
import { getLocalUserInfo } from '../../utils.js';
9+
import { getApifyAPIBaseUrl, getLocalUserInfo } from '../../utils.js';
1010

1111
type TelemetryState = TelemetryStateV0 | TelemetryStateV1;
1212

@@ -34,6 +34,10 @@ function createAnonymousId() {
3434
return `CLI:${cryptoRandomObjectId()}`;
3535
}
3636

37+
export function isTelemetryDisabledInThisEnv() {
38+
return getApifyAPIBaseUrl() !== 'https://api.apify.com';
39+
}
40+
3741
async function migrateStateV0ToV1(state: TelemetryState) {
3842
if (state.version && state.version >= 1) {
3943
return false;
@@ -89,7 +93,7 @@ export async function useTelemetryState(): Promise<LatestTelemetryState> {
8993

9094
export type StateUpdater = (state: LatestTelemetryState) => void;
9195

92-
export function updateTelemetryState(state: LatestTelemetryState, updater?: StateUpdater) {
96+
function updateTelemetryState(state: LatestTelemetryState, updater?: StateUpdater) {
9397
// Update the state in memory
9498
const stateClone = { ...state };
9599
updater?.(stateClone);
@@ -103,6 +107,8 @@ export function updateTelemetryState(state: LatestTelemetryState, updater?: Stat
103107
}
104108

105109
export async function updateUserId(userId: string | null) {
110+
if (isTelemetryDisabledInThisEnv()) return;
111+
106112
const state = await useTelemetryState();
107113

108114
updateTelemetryState(state, (stateToUpdate) => {

src/lib/utils.ts

Lines changed: 102 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,15 @@ import {
3636
import {
3737
APIFY_CLIENT_DEFAULT_HEADERS,
3838
AUTH_FILE_PATH,
39+
DEFAULT_APIFY_API_BASE_URL,
3940
DEFAULT_LOCAL_STORAGE_DIR,
40-
GLOBAL_CONFIGS_FOLDER,
4141
INPUT_FILE_REG_EXP,
4242
LOCAL_CONFIG_PATH,
4343
MINIMUM_SUPPORTED_PYTHON_VERSION,
4444
SUPPORTED_NODEJS_VERSION,
4545
} from './consts.js';
4646
import { deleteFile, ensureFolderExistsSync, rimrafPromised } from './files.js';
47+
import { warning } from './outputs.js';
4748
import type { AuthJSON } from './types.js';
4849

4950
// Export AJV properly: https://github.com/ajv-validator/ajv/issues/2132
@@ -92,15 +93,72 @@ export const getLocalRequestQueuePath = (storeId?: string) => {
9293
return join(getLocalStorageDir(), LOCAL_STORAGE_SUBDIRS.requestQueues, storeDir);
9394
};
9495

96+
let hasLoggedAPIBaseUrlDeprecation = false;
97+
export const getApifyAPIBaseUrl = () => {
98+
const envVar = APIFY_ENV_VARS.API_BASE_URL;
99+
100+
const legacyVar = 'APIFY_CLIENT_BASE_URL';
101+
if (process.env[legacyVar]) {
102+
if (!hasLoggedAPIBaseUrlDeprecation) {
103+
warning({ message: `Environment variable '${legacyVar}' is deprecated. Please use '${envVar}' instead.` });
104+
hasLoggedAPIBaseUrlDeprecation = true;
105+
}
106+
return process.env[legacyVar];
107+
}
108+
109+
// here we _could_ fallback to `undefined` and let ApifyClient to fill the default value, but this function is also
110+
// used for identifying the stored token in the global auth file
111+
// (to allow keeping a separate login for api.apify.com and localhost)
112+
// it is probably safe to assume that the default is https://api.apify.com
113+
return process.env[envVar] || DEFAULT_APIFY_API_BASE_URL;
114+
};
115+
116+
interface MultiBackendAuthJSON {
117+
_authFileVersion: 2;
118+
/** Mapping of ApifyAPIBaseUrl to the AuthJSON for that backend */
119+
backends: Record<string, AuthJSON>;
120+
}
121+
95122
/**
96-
* Returns object from auth file or empty object.
123+
* Returns info about logins stored for all available backends.
97124
*/
98-
export const getLocalUserInfo = async (): Promise<AuthJSON> => {
99-
let result: AuthJSON = {};
125+
const getAllLocalUserInfos = async (): Promise<MultiBackendAuthJSON> => {
126+
let result: AuthJSON | MultiBackendAuthJSON = {};
100127
try {
101128
const raw = await readFile(AUTH_FILE_PATH(), 'utf-8');
102-
result = JSON.parse(raw) as AuthJSON;
129+
result = JSON.parse(raw) as AuthJSON | MultiBackendAuthJSON;
103130
} catch {
131+
return { _authFileVersion: 2, backends: {} };
132+
}
133+
134+
if ('_authFileVersion' in result) return result;
135+
136+
// migrate to multi-backend format, assume the stored data is for the current backend
137+
const backendUrl = getApifyAPIBaseUrl();
138+
const multiBackendResult: MultiBackendAuthJSON = { _authFileVersion: 2, backends: {} };
139+
multiBackendResult.backends[backendUrl] = result;
140+
return multiBackendResult;
141+
};
142+
143+
/**
144+
* Lists stored user infos for all backends.
145+
*/
146+
export const listLocalUserInfos = async (): Promise<({ baseUrl: string } & Pick<AuthJSON, 'username' | 'id'>)[]> => {
147+
const allInfos = await getAllLocalUserInfos();
148+
return Object.entries(allInfos.backends).map(([baseUrl, info]) => ({
149+
baseUrl,
150+
username: info.username,
151+
id: info.id,
152+
}));
153+
};
154+
155+
/**
156+
* Returns object from auth file or empty object.
157+
*/
158+
export const getLocalUserInfo = async (): Promise<AuthJSON> => {
159+
const allInfos = await getAllLocalUserInfos();
160+
const result = allInfos.backends[getApifyAPIBaseUrl()];
161+
if (!result) {
104162
return {};
105163
}
106164

@@ -111,6 +169,34 @@ export const getLocalUserInfo = async (): Promise<AuthJSON> => {
111169
return result;
112170
};
113171

172+
/**
173+
* Persists auth info for the current backend
174+
*/
175+
export async function storeLocalUserInfo(userInfo: AuthJSON) {
176+
ensureApifyDirectory(AUTH_FILE_PATH());
177+
178+
const allInfos = await getAllLocalUserInfos();
179+
allInfos.backends[getApifyAPIBaseUrl()] = userInfo;
180+
181+
writeFileSync(AUTH_FILE_PATH(), JSON.stringify(allInfos, null, '\t'));
182+
}
183+
184+
/**
185+
* Removes auth info for the current backend - effectively logs out the user.
186+
*
187+
* Returns true if info was removed, false if there was no info for this backend.
188+
*/
189+
export async function clearLocalUserInfo() {
190+
const allInfos = await getAllLocalUserInfos();
191+
const backendUrl = getApifyAPIBaseUrl();
192+
193+
if (!allInfos.backends[backendUrl]) return false;
194+
195+
delete allInfos.backends[backendUrl];
196+
writeFileSync(AUTH_FILE_PATH(), JSON.stringify(allInfos, null, '\t'));
197+
return true;
198+
}
199+
114200
/**
115201
* Gets instance of ApifyClient for user otherwise throws error
116202
*/
@@ -124,13 +210,11 @@ export async function getLoggedClientOrThrow() {
124210
return loggedClient;
125211
}
126212

127-
const getTokenWithAuthFileFallback = (existingToken?: string) => {
128-
if (!existingToken && existsSync(GLOBAL_CONFIGS_FOLDER()) && existsSync(AUTH_FILE_PATH())) {
129-
const raw = readFileSync(AUTH_FILE_PATH(), 'utf-8');
130-
return JSON.parse(raw).token;
131-
}
213+
const getTokenWithAuthFileFallback = async (existingToken?: string) => {
214+
if (existingToken) return existingToken;
132215

133-
return existingToken;
216+
const userInfo = await getLocalUserInfo();
217+
return userInfo.token;
134218
};
135219

136220
// biome-ignore format: off
@@ -139,12 +223,12 @@ type CJSAxiosHeaders = import('axios', { with: { 'resolution-mode': 'require' }
139223
/**
140224
* Returns options for ApifyClient
141225
*/
142-
export const getApifyClientOptions = (token?: string, apiBaseUrl?: string): ApifyClientOptions => {
143-
token = getTokenWithAuthFileFallback(token);
226+
export const getApifyClientOptions = async (token?: string, apiBaseUrl?: string): Promise<ApifyClientOptions> => {
227+
token = await getTokenWithAuthFileFallback(token);
144228

145229
return {
146230
token,
147-
baseUrl: apiBaseUrl || process.env.APIFY_CLIENT_BASE_URL,
231+
baseUrl: apiBaseUrl || getApifyAPIBaseUrl(),
148232
requestInterceptors: [
149233
(config) => {
150234
config.headers ??= new AxiosHeaders() as CJSAxiosHeaders;
@@ -165,9 +249,9 @@ export const getApifyClientOptions = (token?: string, apiBaseUrl?: string): Apif
165249
* @param [token]
166250
*/
167251
export async function getLoggedClient(token?: string, apiBaseUrl?: string) {
168-
token = getTokenWithAuthFileFallback(token);
252+
token = await getTokenWithAuthFileFallback(token);
169253

170-
const apifyClient = new ApifyClient(getApifyClientOptions(token, apiBaseUrl));
254+
const apifyClient = new ApifyClient(await getApifyClientOptions(token, apiBaseUrl));
171255

172256
let userInfo;
173257
try {
@@ -177,9 +261,7 @@ export async function getLoggedClient(token?: string, apiBaseUrl?: string) {
177261
}
178262

179263
// Always refresh Auth file
180-
ensureApifyDirectory(AUTH_FILE_PATH());
181-
182-
writeFileSync(AUTH_FILE_PATH(), JSON.stringify({ token: apifyClient.token, ...userInfo }, null, '\t'));
264+
await storeLocalUserInfo({ token: apifyClient.token, ...userInfo });
183265

184266
return apifyClient;
185267
}
@@ -376,7 +458,7 @@ export const outputJobLog = async ({
376458
apifyClient?: ApifyClient;
377459
}) => {
378460
const { id: logId, status } = job;
379-
const client = apifyClient || new ApifyClient({ baseUrl: process.env.APIFY_CLIENT_BASE_URL });
461+
const client = apifyClient || new ApifyClient({ baseUrl: getApifyAPIBaseUrl() });
380462

381463
// In case job was already done just output log
382464
if (ACTOR_JOB_TERMINAL_STATUSES.includes(status as never)) {

test/__setup__/config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ if (!ENV_TEST_USER_TOKEN) {
1313
throw Error('You must configure "TEST_USER_TOKEN" environment variable to run tests!');
1414
}
1515

16-
export const testUserClient = new ApifyClient(getApifyClientOptions(ENV_TEST_USER_TOKEN));
16+
export const testUserClient = new ApifyClient(await getApifyClientOptions(ENV_TEST_USER_TOKEN));
1717

18-
export const badUserClient = new ApifyClient(getApifyClientOptions(TEST_USER_BAD_TOKEN));
18+
export const badUserClient = new ApifyClient(await getApifyClientOptions(TEST_USER_BAD_TOKEN));
1919

2020
export const TEST_USER_TOKEN = ENV_TEST_USER_TOKEN;
2121

test/api/commands/info.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import { readFileSync } from 'node:fs';
2-
31
import { InfoCommand } from '../../../src/commands/info.js';
42
import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js';
5-
import { AUTH_FILE_PATH } from '../../../src/lib/consts.js';
3+
import { getLocalUserInfo } from '../../../src/lib/utils.js';
64
import { safeLogin, useAuthSetup } from '../../__setup__/hooks/useAuthSetup.js';
75
import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js';
86

@@ -21,7 +19,7 @@ describe('[api] apify info', () => {
2119
await safeLogin();
2220
await testRunCommand(InfoCommand, {});
2321

24-
const userInfoFromConfig = JSON.parse(readFileSync(AUTH_FILE_PATH(), 'utf8'));
22+
const userInfoFromConfig = await getLocalUserInfo();
2523

2624
const spy = logSpy();
2725

0 commit comments

Comments
 (0)