Skip to content

Commit e3bf675

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 I decided to implement it. 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 10e4bfb commit e3bf675

File tree

11 files changed

+133
-47
lines changed

11 files changed

+133
-47
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/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: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ 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,
@@ -106,18 +106,59 @@ export const getApifyAPIBaseUrl = () => {
106106
return process.env[legacyVar];
107107
}
108108

109-
return process.env[envVar];
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;
110114
};
111115

116+
interface MultiBackendAuthJSON {
117+
_authFileVersion: 2;
118+
/** Mapping of ApifyAPIBaseUrl to the AuthJSON for that backend */
119+
backends: Record<string, AuthJSON>;
120+
}
121+
112122
/**
113-
* Returns object from auth file or empty object.
123+
* Returns info about logins stored for all available backends.
114124
*/
115-
export const getLocalUserInfo = async (): Promise<AuthJSON> => {
116-
let result: AuthJSON = {};
125+
const getAllLocalUserInfos = async (): Promise<MultiBackendAuthJSON> => {
126+
let result: AuthJSON | MultiBackendAuthJSON = {};
117127
try {
118128
const raw = await readFile(AUTH_FILE_PATH(), 'utf-8');
119-
result = JSON.parse(raw) as AuthJSON;
129+
result = JSON.parse(raw) as AuthJSON | MultiBackendAuthJSON;
120130
} 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) {
121162
return {};
122163
}
123164

@@ -128,6 +169,34 @@ export const getLocalUserInfo = async (): Promise<AuthJSON> => {
128169
return result;
129170
};
130171

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+
131200
/**
132201
* Gets instance of ApifyClient for user otherwise throws error
133202
*/
@@ -141,13 +210,11 @@ export async function getLoggedClientOrThrow() {
141210
return loggedClient;
142211
}
143212

144-
const getTokenWithAuthFileFallback = (existingToken?: string) => {
145-
if (!existingToken && existsSync(GLOBAL_CONFIGS_FOLDER()) && existsSync(AUTH_FILE_PATH())) {
146-
const raw = readFileSync(AUTH_FILE_PATH(), 'utf-8');
147-
return JSON.parse(raw).token;
148-
}
213+
const getTokenWithAuthFileFallback = async (existingToken?: string) => {
214+
if (existingToken) return existingToken;
149215

150-
return existingToken;
216+
const userInfo = await getLocalUserInfo();
217+
return userInfo.token;
151218
};
152219

153220
// biome-ignore format: off
@@ -156,8 +223,8 @@ type CJSAxiosHeaders = import('axios', { with: { 'resolution-mode': 'require' }
156223
/**
157224
* Returns options for ApifyClient
158225
*/
159-
export const getApifyClientOptions = (token?: string, apiBaseUrl?: string): ApifyClientOptions => {
160-
token = getTokenWithAuthFileFallback(token);
226+
export const getApifyClientOptions = async (token?: string, apiBaseUrl?: string): Promise<ApifyClientOptions> => {
227+
token = await getTokenWithAuthFileFallback(token);
161228

162229
return {
163230
token,
@@ -182,9 +249,9 @@ export const getApifyClientOptions = (token?: string, apiBaseUrl?: string): Apif
182249
* @param [token]
183250
*/
184251
export async function getLoggedClient(token?: string, apiBaseUrl?: string) {
185-
token = getTokenWithAuthFileFallback(token);
252+
token = await getTokenWithAuthFileFallback(token);
186253

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

189256
let userInfo;
190257
try {
@@ -194,9 +261,7 @@ export async function getLoggedClient(token?: string, apiBaseUrl?: string) {
194261
}
195262

196263
// Always refresh Auth file
197-
ensureApifyDirectory(AUTH_FILE_PATH());
198-
199-
writeFileSync(AUTH_FILE_PATH(), JSON.stringify({ token: apifyClient.token, ...userInfo }, null, '\t'));
264+
await storeLocalUserInfo({ token: apifyClient.token, ...userInfo });
200265

201266
return apifyClient;
202267
}

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

test/api/commands/log_in_out.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { existsSync, readFileSync } from 'node:fs';
1+
import { existsSync } from 'node:fs';
22

33
import axios from 'axios';
44

55
import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js';
66
import { AUTH_FILE_PATH } from '../../../src/lib/consts.js';
7+
import { getLocalUserInfo } from '../../../src/lib/utils.js';
78
import { TEST_USER_BAD_TOKEN, TEST_USER_TOKEN, testUserClient } from '../../__setup__/config.js';
89
import { safeLogin, useAuthSetup } from '../../__setup__/hooks/useAuthSetup.js';
910
import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js';
@@ -34,7 +35,7 @@ describe('[api] apify login and logout', () => {
3435
const expectedUserInfo = Object.assign(await testUserClient.user('me').get(), {
3536
token: TEST_USER_TOKEN,
3637
}) as unknown as Record<string, string>;
37-
const userInfoFromConfig = JSON.parse(readFileSync(AUTH_FILE_PATH(), 'utf8'));
38+
const userInfoFromConfig = (await getLocalUserInfo()) as unknown as Record<string, string>;
3839

3940
expect(lastErrorMessage()).to.include('Success:');
4041

@@ -85,7 +86,7 @@ describe('[api] apify login and logout', () => {
8586
const expectedUserInfo = Object.assign(await testUserClient.user('me').get(), {
8687
token: TEST_USER_TOKEN,
8788
}) as unknown as Record<string, string>;
88-
const userInfoFromConfig = JSON.parse(readFileSync(AUTH_FILE_PATH(), 'utf8'));
89+
const userInfoFromConfig = (await getLocalUserInfo()) as unknown as Record<string, string>;
8990

9091
expect(lastErrorMessage()).to.include('Success:');
9192

0 commit comments

Comments
 (0)