Skip to content

Commit 649d8c3

Browse files
committed
feat(cli): support logging in with an API key (#2590)
Co-authored-by: Claude <noreply@anthropic.com> Synced from monorepo@e43ad599757bae98575cf5d34cd0600bdb808712
1 parent 64734af commit 649d8c3

3 files changed

Lines changed: 209 additions & 8 deletions

File tree

.sync-commit

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1e65c38d8a31e7dc7119ad1218e84055aea169eb
1+
e43ad599757bae98575cf5d34cd0600bdb808712

src/commands/auth/login.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { DEFAULT_PROFILE } from '~/lib/profile';
1111
type Flags = {
1212
profile: string;
1313
force: boolean;
14+
'api-key'?: string;
1415
issuer?: string;
1516
'api-url'?: string;
1617
'client-id'?: string;
@@ -28,14 +29,21 @@ export async function implementation(this: LocalContext, { profile = DEFAULT_PRO
2829
await updateConfig({ ...config, profiles: updatedProfiles });
2930
}
3031

31-
try {
32-
const { baseUrl, client } = getAuthConfig({
33-
clientId: customFlags['client-id'],
34-
clientSecret: customFlags['client-secret'],
35-
issuer: customFlags.issuer,
36-
apiBaseUrl: customFlags['api-url']
37-
});
32+
const { baseUrl, client } = getAuthConfig({
33+
clientId: customFlags['client-id'],
34+
clientSecret: customFlags['client-secret'],
35+
issuer: customFlags.issuer,
36+
apiBaseUrl: customFlags['api-url']
37+
});
3838

39+
// Passing --api-key is a non-interactive alternative to the device OAuth flow below.
40+
const apiKey = customFlags['api-key'];
41+
if (apiKey) {
42+
await loginWithApiKey.call(this, { profile, apiKey, baseUrl });
43+
return;
44+
}
45+
46+
try {
3947
for await (const step of XataApi.deviceLogin(client)) {
4048
match(step)
4149
.with({ type: 'prompt' }, (step) => {
@@ -72,6 +80,35 @@ export async function implementation(this: LocalContext, { profile = DEFAULT_PRO
7280
}
7381
}
7482

83+
async function loginWithApiKey(
84+
this: LocalContext,
85+
{ profile, apiKey, baseUrl }: { profile: string; apiKey: string; baseUrl: string }
86+
) {
87+
// Validate the API key before persisting it so we don't store an invalid one.
88+
try {
89+
const xata = new XataApi({ baseUrl, token: apiKey });
90+
await xata.api.organizations.getOrganizationsList({});
91+
} catch {
92+
console.error('The provided API key is invalid or could not be verified. No changes were made.');
93+
return;
94+
}
95+
96+
await updateConfig({
97+
...config,
98+
activeProfile: profile,
99+
profiles: {
100+
...(config?.profiles || {}),
101+
[profile]: {
102+
type: 'apiKey',
103+
apiKey,
104+
customConfig: { apiBaseUrl: baseUrl }
105+
}
106+
}
107+
});
108+
109+
console.log(`Logged in with profile "${profile}" using an API key.`);
110+
}
111+
75112
export const AuthLoginCommand = buildCommand({
76113
docs: {
77114
brief: `Log in to a ${CLI_NAME} account`
@@ -89,6 +126,12 @@ export const AuthLoginCommand = buildCommand({
89126
brief: 'Force login even if already logged in',
90127
default: false
91128
},
129+
'api-key': {
130+
kind: 'parsed',
131+
parse: String,
132+
brief: 'Log in non-interactively with an API key instead of the browser OAuth flow',
133+
optional: true
134+
},
92135
issuer: {
93136
kind: 'parsed',
94137
parse: String,
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { beforeEach, describe, expect, mock, test } from 'bun:test';
2+
import type { LocalContext } from '~/context';
3+
import type { Config } from '~/lib/schemas';
4+
5+
// Module-level state shared with the mocked `~/lib/config` module. The object
6+
// identity is kept stable (we only mutate it) so the live binding in login.ts
7+
// always observes the latest state.
8+
const configState: Config = { activeProfile: 'default', profiles: {} };
9+
const updateConfig = mock(async (newConfig: Config) => {
10+
configState.activeProfile = newConfig.activeProfile;
11+
configState.profiles = newConfig.profiles;
12+
});
13+
14+
mock.module('~/lib/config', () => ({
15+
config: configState,
16+
updateConfig
17+
}));
18+
19+
const getOrganizationsList = mock(async () => ({ organizations: [] }));
20+
21+
class FakeXataApi {
22+
api = { organizations: { getOrganizationsList } };
23+
static async *deviceLogin() {
24+
// Not exercised by the API key flow.
25+
}
26+
}
27+
28+
mock.module('@xata.io/api', () => ({
29+
XataApi: FakeXataApi
30+
}));
31+
32+
const { implementation } = await import('./login');
33+
34+
function buildContext() {
35+
const logs: string[] = [];
36+
const errors: string[] = [];
37+
38+
const originalLog = console.log;
39+
const originalError = console.error;
40+
console.log = (...args: unknown[]) => logs.push(args.join(' '));
41+
console.error = (...args: unknown[]) => errors.push(args.join(' '));
42+
43+
const restore = () => {
44+
console.log = originalLog;
45+
console.error = originalError;
46+
};
47+
48+
const context = {} as unknown as LocalContext;
49+
50+
return { context, logs, errors, restore };
51+
}
52+
53+
describe('auth login --api-key', () => {
54+
beforeEach(() => {
55+
configState.activeProfile = 'default';
56+
configState.profiles = {};
57+
updateConfig.mockClear();
58+
getOrganizationsList.mockClear();
59+
getOrganizationsList.mockImplementation(async () => ({ organizations: [] }));
60+
});
61+
62+
test('stores an apiKey profile after validating the key', async () => {
63+
const { context, logs, restore } = buildContext();
64+
65+
try {
66+
await implementation.call(context, { profile: 'default', force: false, 'api-key': 'xau_test' });
67+
} finally {
68+
restore();
69+
}
70+
71+
expect(getOrganizationsList).toHaveBeenCalledTimes(1);
72+
expect(updateConfig).toHaveBeenCalledTimes(1);
73+
expect(configState.activeProfile).toBe('default');
74+
expect(configState.profiles.default).toEqual({
75+
type: 'apiKey',
76+
apiKey: 'xau_test',
77+
customConfig: { apiBaseUrl: 'https://api.xata.tech' }
78+
});
79+
expect(logs.join('')).toContain('Logged in with profile "default" using an API key.');
80+
});
81+
82+
test('respects the --profile flag', async () => {
83+
const { context, restore } = buildContext();
84+
85+
try {
86+
await implementation.call(context, { profile: 'work', force: false, 'api-key': 'xau_work' });
87+
} finally {
88+
restore();
89+
}
90+
91+
expect(configState.activeProfile).toBe('work');
92+
expect(configState.profiles.work).toMatchObject({ type: 'apiKey', apiKey: 'xau_work' });
93+
});
94+
95+
test('uses a custom api base url when provided', async () => {
96+
const { context, restore } = buildContext();
97+
98+
try {
99+
await implementation.call(context, {
100+
profile: 'default',
101+
force: false,
102+
'api-key': 'xau_custom',
103+
'api-url': 'https://api.staging.xata.tech'
104+
});
105+
} finally {
106+
restore();
107+
}
108+
109+
expect(configState.profiles.default).toMatchObject({
110+
customConfig: { apiBaseUrl: 'https://api.staging.xata.tech' }
111+
});
112+
});
113+
114+
test('does not store the profile when the key is invalid', async () => {
115+
getOrganizationsList.mockImplementationOnce(async () => {
116+
throw new Error('401 Unauthorized');
117+
});
118+
const { context, errors, restore } = buildContext();
119+
120+
try {
121+
await implementation.call(context, { profile: 'default', force: false, 'api-key': 'bad-key' });
122+
} finally {
123+
restore();
124+
}
125+
126+
expect(updateConfig).not.toHaveBeenCalled();
127+
expect(configState.profiles.default).toBeUndefined();
128+
expect(errors.join('')).toContain('The provided API key is invalid');
129+
});
130+
131+
test('does not overwrite an existing profile without --force', async () => {
132+
configState.profiles = { default: { type: 'apiKey', apiKey: 'existing' } };
133+
const { context, logs, restore } = buildContext();
134+
135+
try {
136+
await implementation.call(context, { profile: 'default', force: false, 'api-key': 'xau_new' });
137+
} finally {
138+
restore();
139+
}
140+
141+
expect(getOrganizationsList).not.toHaveBeenCalled();
142+
expect(updateConfig).not.toHaveBeenCalled();
143+
expect(logs.join('')).toContain('already logged in');
144+
});
145+
146+
test('falls through to the device flow when no --api-key is passed', async () => {
147+
const { context, restore } = buildContext();
148+
149+
try {
150+
await implementation.call(context, { profile: 'default', force: false });
151+
} finally {
152+
restore();
153+
}
154+
155+
expect(getOrganizationsList).not.toHaveBeenCalled();
156+
expect(updateConfig).not.toHaveBeenCalled();
157+
});
158+
});

0 commit comments

Comments
 (0)