diff --git a/.changeset/sour-eggs-tan.md b/.changeset/sour-eggs-tan.md new file mode 100644 index 000000000..d560cf4d8 --- /dev/null +++ b/.changeset/sour-eggs-tan.md @@ -0,0 +1,5 @@ +--- +"@xata.io/cli": patch +--- + +Add support for OAuth login diff --git a/cli/package.json b/cli/package.json index 1ad550eae..02404aacb 100644 --- a/cli/package.json +++ b/cli/package.json @@ -42,6 +42,7 @@ "lodash.compact": "^3.0.1", "lodash.get": "^4.4.2", "lodash.set": "^4.3.2", + "nanoid": "^4.0.2", "node-fetch": "^3.3.2", "open": "^9.1.0", "prompts": "^2.4.2", @@ -59,6 +60,7 @@ "@types/lodash.compact": "^3.0.9", "@types/lodash.get": "^4.4.9", "@types/lodash.set": "^4.3.9", + "@types/nanoid": "^3.0.0", "@types/relaxed-json": "^1.0.4", "@types/text-table": "^0.2.5", "@types/tmp": "^0.2.6", diff --git a/cli/src/auth-server.test.ts b/cli/src/auth-server.test.ts index 1edb95289..16ad3dfe3 100644 --- a/cli/src/auth-server.test.ts +++ b/cli/src/auth-server.test.ts @@ -4,14 +4,15 @@ import url from 'url'; import { describe, expect, test, vi } from 'vitest'; import { generateKeys, generateURL, handler } from './auth-server.js'; +const domain = 'https://app.xata.io'; const port = 1234; const { publicKey, privateKey, passphrase } = generateKeys(); describe('generateURL', () => { test('generates a URL', async () => { - const uiURL = generateURL(port, publicKey); + const uiURL = generateURL({ port, publicKey, domain }); - expect(uiURL.startsWith('https://app.xata.io/new-api-key?')).toBe(true); + expect(uiURL.startsWith(`${domain}/new-api-key?`)).toBe(true); const parsed = url.parse(uiURL, true); const { pub, name, redirect } = parsed.query; @@ -25,7 +26,7 @@ describe('generateURL', () => { describe('handler', () => { test('405s if the method is not GET', async () => { const callback = vi.fn(); - const httpHandler = handler(publicKey, privateKey, passphrase, callback); + const httpHandler = handler({ publicKey, privateKey, passphrase, callback, domain }); const req = { method: 'POST', url: '/' } as unknown as IncomingMessage; const res = { @@ -42,7 +43,7 @@ describe('handler', () => { test('redirects if the path is /new', async () => { const callback = vi.fn(); - const httpHandler = handler(publicKey, privateKey, passphrase, callback); + const httpHandler = handler({ publicKey, privateKey, passphrase, callback, domain }); const writeHead = vi.fn(); const req = { method: 'GET', url: '/new', socket: { localPort: 9999 } } as unknown as IncomingMessage; @@ -55,7 +56,7 @@ describe('handler', () => { const [status, headers] = writeHead.mock.calls[0]; expect(status).toEqual(302); - expect(String(headers.location).startsWith('https://app.xata.io/new-api-key?pub=')).toBeTruthy(); + expect(String(headers.location).startsWith(`${domain}/new-api-key?pub=`)).toBeTruthy(); expect(String(headers.location).includes('9999')).toBeTruthy(); expect(res.end).toHaveBeenCalledWith(); expect(callback).not.toHaveBeenCalled(); @@ -63,7 +64,7 @@ describe('handler', () => { test('404s if the path is not the root path', async () => { const callback = vi.fn(); - const httpHandler = handler(publicKey, privateKey, passphrase, callback); + const httpHandler = handler({ publicKey, privateKey, passphrase, callback, domain }); const req = { method: 'GET', url: '/foo' } as unknown as IncomingMessage; const res = { @@ -80,7 +81,7 @@ describe('handler', () => { test('returns 400 if resource is called with the wrong parameters', async () => { const callback = vi.fn(); - const httpHandler = handler(publicKey, privateKey, passphrase, callback); + const httpHandler = handler({ publicKey, privateKey, passphrase, callback, domain }); const req = { method: 'GET', url: '/' } as unknown as IncomingMessage; const res = { @@ -97,7 +98,7 @@ describe('handler', () => { test('hadles errors correctly', async () => { const callback = vi.fn(); - const httpHandler = handler(publicKey, privateKey, passphrase, callback); + const httpHandler = handler({ publicKey, privateKey, passphrase, callback, domain }); const req = { method: 'GET', url: '/?key=malformed-key' } as unknown as IncomingMessage; const res = { @@ -115,7 +116,7 @@ describe('handler', () => { test('receives the API key if everything is fine', async () => { const callback = vi.fn(); - const httpHandler = handler(publicKey, privateKey, passphrase, callback); + const httpHandler = handler({ publicKey, privateKey, passphrase, callback, domain }); const apiKey = 'abcdef1234'; const encryptedKey = crypto.publicEncrypt(publicKey, Buffer.from(apiKey)); diff --git a/cli/src/auth-server.ts b/cli/src/auth-server.ts index 9a154960a..608fdac24 100644 --- a/cli/src/auth-server.ts +++ b/cli/src/auth-server.ts @@ -6,11 +6,32 @@ import { AddressInfo } from 'net'; import open from 'open'; import path, { dirname } from 'path'; import url, { fileURLToPath } from 'url'; +import { z } from 'zod'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -export function handler(publicKey: string, privateKey: string, passphrase: string, callback: (apiKey: string) => void) { +const ResponseSchema = z.object({ + accessToken: z.string(), + refreshToken: z.string(), + expires: z.string() +}); + +type OAuthResponse = z.infer; + +export function handler({ + domain, + publicKey, + privateKey, + passphrase, + callback +}: { + domain: string; + publicKey: string; + privateKey: string; + passphrase: string; + callback: (response: OAuthResponse) => void; +}) { return (req: http.IncomingMessage, res: http.ServerResponse) => { try { if (req.method !== 'GET') { @@ -22,7 +43,7 @@ export function handler(publicKey: string, privateKey: string, passphrase: strin if (parsedURL.pathname === '/new') { const port = req.socket.localPort ?? 80; res.writeHead(302, { - location: generateURL(port, publicKey) + location: generateURL({ port, publicKey, domain }) }); res.end(); return; @@ -37,12 +58,12 @@ export function handler(publicKey: string, privateKey: string, passphrase: strin return res.end('Missing key parameter'); } const privKey = crypto.createPrivateKey({ key: privateKey, passphrase }); - const apiKey = crypto + const response = crypto .privateDecrypt(privKey, Buffer.from(String(parsedURL.query.key).replace(/ /g, '+'), 'base64')) .toString('utf8'); renderSuccessPage(req, res, String(parsedURL.query['color-mode'])); req.destroy(); - callback(apiKey); + callback(ResponseSchema.parse(JSON.parse(response))); } catch (err) { res.writeHead(500); res.end(`Something went wrong: ${err instanceof Error ? err.message : String(err)}`); @@ -50,7 +71,7 @@ export function handler(publicKey: string, privateKey: string, passphrase: strin }; } -function renderSuccessPage(req: http.IncomingMessage, res: http.ServerResponse, colorMode: string) { +function renderSuccessPage(_req: http.IncomingMessage, res: http.ServerResponse, colorMode: string) { res.writeHead(200, { 'Content-Type': 'text/html' }); @@ -58,17 +79,24 @@ function renderSuccessPage(req: http.IncomingMessage, res: http.ServerResponse, res.end(html.replace('data-color-mode=""', `data-color-mode="${colorMode}"`)); } -export function generateURL(port: number, publicKey: string) { +export function generateURL({ port, publicKey, domain }: { port: number; publicKey: string; domain: string }) { + const name = 'Xata CLI'; + const serverRedirect = `${domain}/api/integrations/cli/callback`; + const cliRedirect = `http://localhost:${port}`; const pub = publicKey .replace(/\n/g, '') .replace('-----BEGIN PUBLIC KEY-----', '') .replace('-----END PUBLIC KEY-----', ''); - const name = 'Xata CLI'; - const redirect = `http://localhost:${port}`; - const url = new URL('https://app.xata.io/new-api-key'); - url.searchParams.append('pub', pub); - url.searchParams.append('name', name); - url.searchParams.append('redirect', redirect); + + const url = new URL(`${domain}/integrations/oauth/authorize`); + url.searchParams.set('client_id', 'b7msdmpun91q33vpihpk3vs39g'); + url.searchParams.set('redirect_uri', serverRedirect); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', 'admin:all'); + url.searchParams.set( + 'state', + Buffer.from(JSON.stringify({ name, pub, cliRedirect, serverRedirect })).toString('base64') + ); return url.toString(); } @@ -90,19 +118,25 @@ export function generateKeys() { return { publicKey, privateKey, passphrase }; } -export async function createAPIKeyThroughWebUI() { +export async function loginWithWebUI(domain: string) { const { publicKey, privateKey, passphrase } = generateKeys(); - return new Promise((resolve) => { + return new Promise((resolve) => { const server = http.createServer( - handler(publicKey, privateKey, passphrase, (apiKey) => { - resolve(apiKey); - server.close(); + handler({ + domain, + publicKey, + privateKey, + passphrase, + callback: (credentials) => { + resolve(credentials); + server.close(); + } }) ); server.listen(() => { const { port } = server.address() as AddressInfo; - const openURL = generateURL(port, publicKey); + const openURL = generateURL({ port, publicKey, domain }); console.log( `We are opening your default browser. If your browser doesn't open automatically, please copy and paste the following URL into your browser: ${chalk.bold( `http://localhost:${port}/new` @@ -112,3 +146,17 @@ export async function createAPIKeyThroughWebUI() { }); }); } + +export async function refreshAccessToken(domain: string, refreshToken: string) { + const response = await fetch(`${domain}/api/integrations/cli/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }) + }); + + if (!response.ok) { + throw new Error(`Failed to refresh access token: ${response.status} ${response.statusText}`); + } + + return ResponseSchema.parse(await response.json()); +} diff --git a/cli/src/base.ts b/cli/src/base.ts index a2921fc3e..889e40193 100644 --- a/cli/src/base.ts +++ b/cli/src/base.ts @@ -1,12 +1,13 @@ import { Command, Flags, Interfaces } from '@oclif/core'; import { + Schemas, + XataApiPlugin, buildClient, getAPIKey, getBranch, getHostUrl, - parseWorkspacesUrlParts, - Schemas, - XataApiPlugin + parseProviderString, + parseWorkspacesUrlParts } from '@xata.io/client'; import { XataImportPlugin } from '@xata.io/importer'; import ansiRegex from 'ansi-regex'; @@ -22,14 +23,14 @@ import prompts from 'prompts'; import table from 'text-table'; import which from 'which'; import { ZodError } from 'zod'; -import { createAPIKeyThroughWebUI } from './auth-server.js'; -import { partialProjectConfig, ProjectConfig } from './config.js'; +import { loginWithWebUI, refreshAccessToken } from './auth-server.js'; +import { ProjectConfig, partialProjectConfig } from './config.js'; import { - buildProfile, + Profile, credentialsFilePath, getEnvProfileName, - Profile, - readCredentialsDictionary + readCredentialsDictionary, + saveCredentials } from './credentials.js'; import { reportBugURL } from './utils.js'; @@ -40,6 +41,8 @@ export class XataClient extends buildClient({ export type APIKeyLocation = 'shell' | 'dotenv' | 'profile' | 'new'; +const ACCESS_TOKEN_EXPIRATION_THRESHOLD = 15 * 24 * 60 * 60 * 1000; // 15 days + const moduleName = 'xata'; const commonFlagsHelpGroup = 'Common'; @@ -188,33 +191,57 @@ export abstract class BaseCommand extends Command { } } - async getProfile({ ignoreEnv = false }: { ignoreEnv?: boolean } = {}): Promise { + async getProfile({ + ignoreEnv = false, + profileName + }: { ignoreEnv?: boolean; profileName?: string } = {}): Promise { const { flags } = await this.parseCommand(); - const profileName = flags.profile || getEnvProfileName(); + const name = profileName || flags.profile || getEnvProfileName(); + const defaultWeb = process.env.XATA_WEB_URL ?? 'https://app.xata.io'; + const defaultHost = parseProviderString(process.env.XATA_API_PROVIDER) ?? 'production'; const apiKey = getAPIKey(); - const useEnv = !ignoreEnv || profileName === 'default'; - if (useEnv && apiKey) return buildProfile({ name: 'default', apiKey }); + const useEnv = !ignoreEnv || name === 'default'; + if (useEnv && apiKey) { + return { name, web: defaultWeb, host: defaultHost, token: apiKey }; + } const credentials = await readCredentialsDictionary(); - const credential = credentials[profileName]; + const credential = credentials[name]; if (credential?.apiKey) this.apiKeyLocation = 'profile'; - return buildProfile({ ...credential, name: profileName }); + + // Check if the token is valid, if not, refresh it + let token: string | undefined; + if ( + credential?.expiresAt && + credential?.refreshToken && + new Date(credential.expiresAt).getTime() - Date.now() < ACCESS_TOKEN_EXPIRATION_THRESHOLD + ) { + const refresh = await refreshAccessToken(credential.web ?? defaultWeb, credential.refreshToken); + await saveCredentials(name, { + ...credential, + accessToken: refresh.accessToken, + refreshToken: refresh.refreshToken, + expiresAt: refresh.expires + }); + + token = refresh.accessToken; + } else if (credential?.accessToken) { + token = credential.accessToken; + } + + return { + name, + web: credential?.web ?? defaultWeb, + host: parseProviderString(credential?.api) ?? defaultHost, + token: token ?? credential?.apiKey ?? '' + }; } async getXataClient({ profile }: { profile?: Profile } = {}) { if (this.#xataClient) return this.#xataClient; - const { apiKey, host } = profile ?? (await this.getProfile()); - - if (!apiKey) { - this.error('Could not instantiate Xata client. No API key found.', { - suggestions: [ - 'Run `xata auth login`', - 'Configure a project with `xata init --db=https://{workspace}.{region}.xata.sh/db/{database}`' - ] - }); - } + const { token, host } = profile ?? (await this.getProfile()); const { flags } = await this.parseCommand(); const databaseURL = flags.db ?? 'https://{workspace}.{region}.xata.sh/db/{database}'; @@ -223,7 +250,7 @@ export abstract class BaseCommand extends Command { this.#xataClient = new XataClient({ databaseURL, branch, - apiKey, + apiKey: token, fetch, host, clientName: 'cli', @@ -256,7 +283,7 @@ export abstract class BaseCommand extends Command { this.log(`${chalk.greenBright('✔')} ${message}`); } - async verifyAPIKey(profile: Profile) { + async verifyProfile(profile: Profile) { this.info('Checking access to the API...'); const xata = await this.getXataClient({ profile }); try { @@ -538,29 +565,11 @@ export abstract class BaseCommand extends Command { } } - async obtainKey() { - const { decision } = await this.prompt({ - type: 'select', - name: 'decision', - message: 'Do you want to use an existing API key or create a new API key?', - choices: [ - { title: 'Create a new API key in browser', value: 'create' }, - { title: 'Use an existing API key', value: 'existing' } - ] - }); - if (!decision) this.exit(2); - - if (decision === 'create') { - return createAPIKeyThroughWebUI(); - } else if (decision === 'existing') { - const { key } = await this.prompt({ - type: 'password', - name: 'key', - message: 'Existing API key:' - }); - if (!key) this.exit(2); - return key; - } + async obtainKey(web?: string) { + const currentProfile = await this.getProfile(); + const domain = web || currentProfile?.web || 'https://app.xata.io'; + + return await loginWithWebUI(domain); } async deploySchema(workspace: string, region: string, database: string, branch: string, schema: Schemas.Schema) { diff --git a/cli/src/commands/auth/login.ts b/cli/src/commands/auth/login.ts index 804471540..e1e4c6667 100644 --- a/cli/src/commands/auth/login.ts +++ b/cli/src/commands/auth/login.ts @@ -1,7 +1,7 @@ import { Flags } from '@oclif/core'; import { parseProviderString } from '@xata.io/client'; import { BaseCommand } from '../../base.js'; -import { hasProfile, setProfile } from '../../credentials.js'; +import { Credential, hasProfile, saveCredentials } from '../../credentials.js'; export default class Login extends BaseCommand { static description = 'Authenticate with Xata'; @@ -10,8 +10,11 @@ export default class Login extends BaseCommand { static flags = { ...BaseCommand.forceFlag('Overwrite existing credentials if they exist'), - host: Flags.string({ + api: Flags.string({ description: 'Xata API host provider' + }), + web: Flags.string({ + description: 'Xata web host provider' }) }; @@ -34,16 +37,24 @@ export default class Login extends BaseCommand { if (!overwrite) this.exit(2); } - const host = parseProviderString(flags.host); + const host = parseProviderString(flags.api); if (!host) { this.error('Invalid host provider, expected either "production", "staging" or "{apiUrl},{workspacesUrl}"'); } - const key = await this.obtainKey(); + const { accessToken, refreshToken, expires } = await this.obtainKey(flags.web); + const credential: Credential = { + api: flags.api, + web: flags.web || profile.web, + accessToken, + refreshToken, + expiresAt: expires + }; - await this.verifyAPIKey({ ...profile, apiKey: key, host }); + await saveCredentials(profile.name, credential); - await setProfile(profile.name, { apiKey: key, api: flags.host }); + const newProfile = await this.getProfile({ profileName: profile.name }); + await this.verifyProfile(newProfile); this.success('All set! you can now start using xata'); } diff --git a/cli/src/commands/auth/logout.ts b/cli/src/commands/auth/logout.ts index 2a3c8facc..0a26eeea4 100644 --- a/cli/src/commands/auth/logout.ts +++ b/cli/src/commands/auth/logout.ts @@ -1,5 +1,5 @@ import { BaseCommand } from '../../base.js'; -import { hasProfile, removeProfile } from '../../credentials.js'; +import { hasProfile, removeCredential } from '../../credentials.js'; export default class Logout extends BaseCommand { static description = 'Logout from Xata'; @@ -32,7 +32,7 @@ export default class Logout extends BaseCommand { ); if (!confirm) this.exit(2); - await removeProfile(profile.name); + await removeCredential(profile.name); this.success('Logged out correctly'); } diff --git a/cli/src/commands/auth/refresh.ts b/cli/src/commands/auth/refresh.ts new file mode 100644 index 000000000..e341fd8fa --- /dev/null +++ b/cli/src/commands/auth/refresh.ts @@ -0,0 +1,45 @@ +import { refreshAccessToken } from '../../auth-server.js'; +import { BaseCommand } from '../../base.js'; +import { readCredentialsDictionary, saveCredentials } from '../../credentials.js'; + +export default class Refresh extends BaseCommand { + static description = 'Refresh authentication with Xata'; + + static examples = []; + + static flags = { + ...BaseCommand.forceFlag('Force refresh of the auth token') + }; + + static args = {}; + + // This is an support and debugging command, so we hide it from the help menu + static hidden = true; + + async run(): Promise { + const { flags } = await this.parseCommand(); + + const profile = await this.getProfile({ ignoreEnv: true }); + const credentials = await readCredentialsDictionary(); + const credential = credentials[profile.name]; + + if (!credential?.accessToken || !credential?.refreshToken || !credential?.expiresAt) { + this.error('Invalid credentials, please login again'); + } + + if (flags.force) { + const refresh = await refreshAccessToken(credential.web ?? 'https://app.xata.io', credential.refreshToken); + await saveCredentials(profile.name, { + ...credential, + accessToken: refresh.accessToken, + refreshToken: refresh.refreshToken, + expiresAt: refresh.expires + }); + + this.success('Successfully refreshed your session'); + return; + } + + this.info(`Your current session expires at ${new Date(credential.expiresAt).toLocaleString()}, no need to refresh`); + } +} diff --git a/cli/src/commands/auth/status.ts b/cli/src/commands/auth/status.ts index fff72a4a5..84c79fe02 100644 --- a/cli/src/commands/auth/status.ts +++ b/cli/src/commands/auth/status.ts @@ -19,7 +19,7 @@ export default class Status extends BaseCommand { this.info('Client is logged in'); - await this.verifyAPIKey(profile); + await this.verifyProfile(profile); this.success('API key is valid'); } diff --git a/cli/src/commands/init/index.ts b/cli/src/commands/init/index.ts index 5733624ba..2a53c7b6f 100644 --- a/cli/src/commands/init/index.ts +++ b/cli/src/commands/init/index.ts @@ -1,13 +1,13 @@ import { Flags } from '@oclif/core'; -import { buildProviderString, Schemas } from '@xata.io/client'; +import { buildProviderString, Schemas, XataApiClient } from '@xata.io/client'; import { ModuleType, parseSchemaFile } from '@xata.io/codegen'; import chalk from 'chalk'; import dotenv from 'dotenv'; import { access, readFile, writeFile } from 'fs/promises'; import compact from 'lodash.compact'; +import { nanoid } from 'nanoid'; import path, { extname } from 'path'; import which from 'which'; -import { createAPIKeyThroughWebUI } from '../../auth-server.js'; import { BaseCommand, ENV_FILES } from '../../base.js'; import { isIgnored } from '../../git.js'; import { getDbTableExpression } from '../../utils/codeSnippet.js'; @@ -15,10 +15,10 @@ import { delay } from '../../utils/delay.js'; import { enumFlag } from '../../utils/oclif.js'; import Browse from '../browse/index.js'; import Codegen, { languages, unsupportedExtensionError } from '../codegen/index.js'; +import Pull from '../pull/index.js'; import RandomData from '../random-data/index.js'; import EditSchema from '../schema/edit.js'; import Shell from '../shell/index.js'; -import Pull from '../pull/index.js'; const moduleTypeOptions = ['cjs', 'esm']; @@ -159,10 +159,7 @@ export default class Init extends BaseCommand { this.log(); await this.writeEnvFile(workspace, region, database, branch); - - if (ignoreEnvFile) { - await this.ignoreEnvFile(); - } + if (ignoreEnvFile) await this.ignoreEnvFile(); this.log(); if (packageManager) { @@ -386,22 +383,21 @@ export default class Init extends BaseCommand { return envFile; } + // In the future, create it scoped to the workspace and database + async generateApiKey(workspace: string, region: string, database: string) { + const xata = await this.getXataClient(); + + const name = `sdk-${workspace}-${database}-${nanoid(11)}`; + const response = await xata.api.authentication.createUserAPIKey({ name }); + await this.waitUntilAPIKeyIsValid({ apiKey: response.key, workspace, region, database }); + + return response; + } + async writeEnvFile(workspace: string, region: string, database: string, branch: string) { const envFile = await this.findEnvFile(); const doesEnvFileExist = await this.access(envFile); - const profile = await this.getProfile(); - // TODO: generate a database-scoped API key - let apiKey = profile.apiKey; - - if (!apiKey) { - apiKey = await createAPIKeyThroughWebUI(); - this.apiKeyLocation = 'new'; - // Any following API call must use this API key - process.env.XATA_API_KEY = apiKey; - - await this.waitUntilAPIKeyIsValid(workspace, region, database); - } // eslint-disable-next-line prefer-const let { content, containsXataApiKey } = await readEnvFile(envFile); @@ -409,12 +405,14 @@ export default class Init extends BaseCommand { if (containsXataApiKey) { this.warn(`Your ${envFile} file already contains XATA_API_KEY key. skipping...`); } else { + const { key, name: keyName } = await this.generateApiKey(workspace, region, database); const setBranch = `XATA_BRANCH=${branch}`; if (content) content += '\n\n'; content += '# [Xata] Configuration used by the CLI and the SDK\n'; content += '# Make sure your framework/tooling loads this file on startup to have it available for the SDK\n'; content += `${setBranch}\n`; - content += `XATA_API_KEY=${apiKey}\n`; + content += `# API Key: ${keyName}\n`; + content += `XATA_API_KEY=${key}\n`; if (profile.host !== 'production') content += `XATA_API_PROVIDER=${buildProviderString(profile.host)}\n`; this.log(`${doesEnvFileExist ? 'Updating' : 'Creating'} ${envFile} file`); @@ -428,13 +426,23 @@ export default class Init extends BaseCommand { } // New API keys need to be replicated until can be used in a particular region/database - async waitUntilAPIKeyIsValid(workspace: string, region: string, database: string) { - const xata = await this.getXataClient(); + async waitUntilAPIKeyIsValid({ + apiKey, + workspace, + region, + database + }: { + apiKey: string; + workspace: string; + region: string; + database: string; + }) { + const api = new XataApiClient({ apiKey }); const maxRetries = 10; let retries = 0; while (retries++ < maxRetries) { try { - await xata.api.branches.getBranchList({ workspace, region, database }); + await api.branches.getBranchList({ workspace, region, database }); return; } catch (err) { if (err instanceof Error && err.message.includes('Invalid API key')) { diff --git a/cli/src/commands/shell/index.ts b/cli/src/commands/shell/index.ts index 7cf219c3f..35bfa1273 100644 --- a/cli/src/commands/shell/index.ts +++ b/cli/src/commands/shell/index.ts @@ -33,7 +33,7 @@ export default class Shell extends BaseCommand { async run(): Promise { const { flags } = await this.parseCommand(); const profile = await this.getProfile(); - const apiKey = profile?.apiKey; + const apiKey = profile?.token; if (!apiKey) { this.error('No API key found. Either use the XATA_API_KEY environment variable or run `xata auth login`'); } diff --git a/cli/src/credentials.ts b/cli/src/credentials.ts index 643dfbf1a..0343bd41f 100644 --- a/cli/src/credentials.ts +++ b/cli/src/credentials.ts @@ -1,4 +1,4 @@ -import { HostProvider, parseProviderString } from '@xata.io/client'; +import { HostProvider } from '@xata.io/client'; import { mkdir, readFile, writeFile } from 'fs/promises'; import ini from 'ini'; import { homedir } from 'os'; @@ -8,8 +8,12 @@ import z from 'zod'; const credentialSchema = z.object({ api: z.string().optional(), web: z.string().optional(), - apiKey: z.string() + apiKey: z.string().optional(), + accessToken: z.string().optional(), + refreshToken: z.string().optional(), + expiresAt: z.string().optional() }); + const credentialsDictionarySchema = z.record(credentialSchema); export type Credential = z.infer; @@ -19,9 +23,9 @@ export const credentialsFilePath = path.join(homedir(), '.config', 'xata', 'cred export type Profile = { name: string; - apiKey: string; web: string; host: HostProvider; + token: string; }; export async function readCredentialsDictionary(): Promise { @@ -67,16 +71,22 @@ async function writeCredentials(credentials: CredentialsDictionary) { await writeFile(credentialsFilePath, ini.stringify(credentials), { mode: 0o600 }); } -export async function setProfile(name: string, profile: Credential) { +export async function saveCredentials(name: string, credential: Credential) { const credentials = await readCredentialsDictionary(); - credentials[name] = { - apiKey: profile.apiKey, - ...Object.fromEntries(Object.entries(profile).filter(([, value]) => value)) - }; + credentials[name] = Object.fromEntries( + Object.entries({ + api: credential.api, + web: credential.web, + apiKey: credential.apiKey, + accessToken: credential.accessToken, + refreshToken: credential.refreshToken, + expiresAt: credential.expiresAt + }).filter(([, value]) => !!value) + ); await writeCredentials(credentials); } -export async function removeProfile(name: string) { +export async function removeCredential(name: string) { const credentials = await readCredentialsDictionary(); if (credentials[name]) delete credentials[name]; await writeCredentials(credentials); @@ -85,12 +95,3 @@ export async function removeProfile(name: string) { export function getEnvProfileName() { return process.env.XATA_PROFILE || 'default'; } - -export function buildProfile(base: Partial & { name: string }): Profile { - return { - name: base.name, - apiKey: base.apiKey ?? process.env.XATA_API_KEY ?? '', - web: base.web ?? process.env.XATA_WEB_URL ?? '', - host: parseProviderString(base.api ?? process.env.XATA_API_PROVIDER) ?? 'production' - }; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1399a6139..d66ae54c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -211,6 +211,9 @@ importers: lodash.set: specifier: ^4.3.2 version: 4.3.2 + nanoid: + specifier: ^4.0.2 + version: 4.0.2 node-fetch: specifier: ^3.3.2 version: 3.3.2 @@ -257,6 +260,9 @@ importers: '@types/lodash.set': specifier: ^4.3.9 version: 4.3.9 + '@types/nanoid': + specifier: ^3.0.0 + version: 3.0.0 '@types/relaxed-json': specifier: ^1.0.4 version: 1.0.4 @@ -5440,6 +5446,14 @@ packages: { integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== } dev: true + /@types/nanoid@3.0.0: + resolution: + { integrity: sha512-UXitWSmXCwhDmAKe7D3hNQtQaHeHt5L8LO1CB8GF8jlYVzOv5cBWDNqiJ+oPEWrWei3i3dkZtHY/bUtd0R/uOQ== } + deprecated: This is a stub types definition. nanoid provides its own type definitions, so you do not need this installed. + dependencies: + nanoid: 4.0.2 + dev: true + /@types/node@12.20.55: resolution: { integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ== } @@ -12156,6 +12170,12 @@ packages: engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } hasBin: true + /nanoid@4.0.2: + resolution: + { integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw== } + engines: { node: ^14 || ^16 || >=18 } + hasBin: true + /nanoid@5.0.3: resolution: { integrity: sha512-I7X2b22cxA4LIHXPSqbBCEQSL+1wv8TuoefejsX4HFWyC6jc5JG7CEaxOltiKjc1M+YCS2YkrZZcj4+dytw9GA== }