diff --git a/.vscode/launch.json b/.vscode/launch.json index 8f77209..97893fd 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -27,6 +27,20 @@ "STUB": "true" } }, + { + "type": "node", + "request": "launch", + "name": "Debug login by token", + "program": "${workspaceFolder}/dist/index.mjs", + "args": ["login", "--token", "1234567890"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "env": { + "STUB": "true" + } + }, { "type": "node", "request": "launch", @@ -68,6 +82,20 @@ "env": { "STUB": "true" } + }, + { + "type": "node", + "request": "launch", + "name": "Debug Test", + "program": "${workspaceFolder}/dist/index.mjs", + "args": ["test"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "env": { + "STUB": "true" + } } ] } diff --git a/package.json b/package.json index 9e2df9f..fb32ee8 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "chalk": "^5.3.0", "commander": "^12.1.0", "dotenv": "^16.4.5", - "ofetch": "^1.4.0", "storyblok-js-client": "^6.9.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16a0bdc..f2bcfc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,6 @@ importers: dotenv: specifier: ^16.4.5 version: 16.4.5 - ofetch: - specifier: ^1.4.0 - version: 1.4.0 storyblok-js-client: specifier: ^6.9.2 version: 6.9.2 @@ -1468,9 +1465,6 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - destr@2.0.3: - resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==} - devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -2336,9 +2330,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - node-fetch-native@1.6.4: - resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} - node-releases@2.0.18: resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} @@ -2352,9 +2343,6 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - ofetch@1.4.0: - resolution: {integrity: sha512-MuHgsEhU6zGeX+EMh+8mSMrYTnsqJQQrpM00Q6QHMKNqQ0bKy0B43tk8tL1wg+CnsSTy1kg4Ir2T5Ig6rD+dfQ==} - once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -4499,8 +4487,6 @@ snapshots: dequal@2.0.3: {} - destr@2.0.3: {} - devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -5652,8 +5638,6 @@ snapshots: natural-compare@1.4.0: {} - node-fetch-native@1.6.4: {} - node-releases@2.0.18: {} normalize-package-data@2.5.0: @@ -5669,12 +5653,6 @@ snapshots: dependencies: boolbase: 1.0.0 - ofetch@1.4.0: - dependencies: - destr: 2.0.3 - node-fetch-native: 1.6.4 - ufo: 1.5.4 - once@1.4.0: dependencies: wrappy: 1.0.2 diff --git a/src/commands/login/actions.ts b/src/commands/login/actions.ts index eb609b6..9cc0945 100644 --- a/src/commands/login/actions.ts +++ b/src/commands/login/actions.ts @@ -1,12 +1,16 @@ import chalk from 'chalk' import type { RegionCode } from '../../constants' -import { regionsDomain } from '../../constants' -import { FetchError, ofetch } from 'ofetch' +import { customFetch, FetchError } from '../../utils/fetch' import { APIError, handleAPIError, maskToken } from '../../utils' +import { getStoryblokUrl } from '../../utils/api-routes' +import type { StoryblokLoginResponse, StoryblokLoginWithOtpResponse, StoryblokUser } from '../../types' export const loginWithToken = async (token: string, region: RegionCode) => { try { - return await ofetch(`https://${regionsDomain[region]}/v1/users/me`, { + const url = getStoryblokUrl(region) + return await customFetch<{ + user: StoryblokUser + }>(`${url}/users/me`, { headers: { Authorization: token, }, @@ -14,7 +18,7 @@ export const loginWithToken = async (token: string, region: RegionCode) => { } catch (error) { if (error instanceof FetchError) { - const status = error.response?.status + const status = error.response.status switch (status) { case 401: @@ -24,17 +28,16 @@ export const loginWithToken = async (token: string, region: RegionCode) => { throw new APIError('network_error', 'login_with_token', error) } } - else { - throw new APIError('generic', 'login_with_token', error as Error) - } + throw new APIError('generic', 'login_with_token', error as FetchError) } } export const loginWithEmailAndPassword = async (email: string, password: string, region: RegionCode) => { try { - return await ofetch(`https://${regionsDomain[region]}/v1/users/login`, { + const url = getStoryblokUrl(region) + return await customFetch(`${url}/users/login`, { method: 'POST', - body: JSON.stringify({ email, password }), + body: { email, password }, }) } catch (error) { @@ -44,9 +47,10 @@ export const loginWithEmailAndPassword = async (email: string, password: string, export const loginWithOtp = async (email: string, password: string, otp: string, region: RegionCode) => { try { - return await ofetch(`https://${regionsDomain[region]}/v1/users/login`, { + const url = getStoryblokUrl(region) + return await customFetch(`${url}/users/login`, { method: 'POST', - body: JSON.stringify({ email, password, otp_attempt: otp }), + body: { email, password, otp_attempt: otp }, }) } catch (error) { diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index a329dd8..c1edbd3 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -112,16 +112,18 @@ export const loginCommand = program }) const response = await loginWithEmailAndPassword(userEmail, userPassword, userRegion) - if (response.otp_required) { + if (response?.otp_required) { const otp = await input({ message: 'Add the code from your Authenticator app, or the one we sent to your e-mail / phone:', required: true, }) - const { access_token } = await loginWithOtp(userEmail, userPassword, otp, userRegion) - updateSession(userEmail, access_token, userRegion) + const otpResponse = await loginWithOtp(userEmail, userPassword, otp, userRegion) + if (otpResponse?.access_token) { + updateSession(userEmail, otpResponse?.access_token, userRegion) + } } - else { + else if (response?.access_token) { updateSession(userEmail, response.access_token, userRegion) } await persistCredentials(regionsDomain[userRegion]) diff --git a/src/commands/pull-languages/actions.ts b/src/commands/pull-languages/actions.ts index 03b57d0..0cd28cc 100644 --- a/src/commands/pull-languages/actions.ts +++ b/src/commands/pull-languages/actions.ts @@ -1,38 +1,35 @@ import { join } from 'node:path' - import { handleAPIError, handleFileSystemError } from '../../utils' -import { ofetch } from 'ofetch' -import { regionsDomain } from '../../constants' +import type { FetchError } from '../../utils/fetch' +import { customFetch } from '../../utils/fetch' import { resolvePath, saveToFile } from '../../utils/filesystem' import type { PullLanguagesOptions } from './constants' +import type { RegionCode } from '../../constants' +import type { SpaceInternationalization } from '../../types' +import { getStoryblokUrl } from '../../utils/api-routes' -export interface SpaceInternationalizationOptions { - languages: SpaceLanguage[] - default_lang_name: string -} -export interface SpaceLanguage { - code: string - name: string -} - -export const pullLanguages = async (space: string, token: string, region: string): Promise => { +export const pullLanguages = async (space: string, token: string, region: RegionCode): Promise => { try { - const response = await ofetch(`https://${regionsDomain[region]}/v1/spaces/${space}`, { + const url = getStoryblokUrl(region) + const response = await customFetch<{ + space: SpaceInternationalization + }>(`${url}/spaces/${space}`, { headers: { Authorization: token, }, }) + return { default_lang_name: response.space.default_lang_name, languages: response.space.languages, } } catch (error) { - handleAPIError('pull_languages', error as Error) + handleAPIError('pull_languages', error as FetchError) } } -export const saveLanguagesToFile = async (space: string, internationalizationOptions: SpaceInternationalizationOptions, options: PullLanguagesOptions) => { +export const saveLanguagesToFile = async (space: string, internationalizationOptions: SpaceInternationalization, options: PullLanguagesOptions) => { try { const { filename = 'languages', suffix = space, path } = options const data = JSON.stringify(internationalizationOptions, null, 2) diff --git a/src/commands/user/actions.ts b/src/commands/user/actions.ts index 1265639..b0bd5dc 100644 --- a/src/commands/user/actions.ts +++ b/src/commands/user/actions.ts @@ -1,19 +1,25 @@ -import { FetchError, ofetch } from 'ofetch' -import { regionsDomain } from '../../constants' import chalk from 'chalk' +import type { RegionCode } from '../../constants' +import { customFetch, FetchError } from '../../utils/fetch' import { APIError, maskToken } from '../../utils' +import { getStoryblokUrl } from '../../utils/api-routes' +import type { StoryblokUser } from '../../types' -export const getUser = async (token: string, region: string) => { +export const getUser = async (token: string, region: RegionCode) => { try { - return await ofetch(`https://${regionsDomain[region]}/v1/users/me`, { + const url = getStoryblokUrl(region) + const response = await customFetch<{ + user: StoryblokUser + }>(`${url}/users/me`, { headers: { Authorization: token, }, }) + return response } catch (error) { if (error instanceof FetchError) { - const status = error.response?.status + const status = error.response.status switch (status) { case 401: @@ -23,8 +29,6 @@ export const getUser = async (token: string, region: string) => { throw new APIError('network_error', 'get_user', error) } } - else { - throw new APIError('generic', 'get_user', error as Error) - } + throw new APIError('generic', 'get_user', error as FetchError) } } diff --git a/src/creds.ts b/src/creds.ts index ecdae14..c39eba5 100644 --- a/src/creds.ts +++ b/src/creds.ts @@ -3,11 +3,12 @@ import { join } from 'node:path' import { FileSystemError, handleFileSystemError, konsola } from './utils' import chalk from 'chalk' import { colorPalette, regionCodes } from './constants' +import type { RegionCode } from './constants' export interface NetrcMachine { login: string password: string - region: string + region: RegionCode } export const getNetrcFilePath = () => { diff --git a/src/index.ts b/src/index.ts index 2e83379..5c92d31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,9 @@ import './commands/logout' import './commands/user' import './commands/pull-languages' -import { loginWithToken } from './commands/login/actions' +import { customFetch } from './utils/fetch' +import { getStoryblokUrl } from './utils/api-routes' +import { session } from './session' dotenv.config() // This will load variables from .env into process.env const program = getProgram() @@ -31,8 +33,20 @@ program.command('test').action(async () => { konsola.title(`Test`, '#8556D3', 'Attempting a test...') const verbose = program.opts().verbose try { - // await loginWithEmailAndPassword('aw', 'passwrod', 'eu') - await loginWithToken('WYSYDHYASDHSYD', 'eu') + const { state, initializeSession } = session() + await initializeSession() + const url = getStoryblokUrl() + + if (!state.password) { + throw new Error('No password found') + } + + const response = await customFetch(`${url}/spaces/2950170505/components`, { + headers: { + Authorization: state.password, + }, + }) + console.log(response) } catch (error) { handleError(error as Error, verbose) diff --git a/src/session.ts b/src/session.ts index d370e13..8107f93 100644 --- a/src/session.ts +++ b/src/session.ts @@ -6,7 +6,7 @@ interface SessionState { isLoggedIn: boolean login?: string password?: string - region?: string + region?: RegionCode envLogin?: boolean } @@ -24,7 +24,7 @@ function createSession() { state.isLoggedIn = true state.login = envCredentials.login state.password = envCredentials.password - state.region = envCredentials.region + state.region = envCredentials.region as RegionCode state.envLogin = true return } @@ -36,7 +36,7 @@ function createSession() { state.isLoggedIn = true state.login = creds.login state.password = creds.password - state.region = creds.region + state.region = creds.region as RegionCode } else { // No credentials found; set state to logged out diff --git a/src/types/errors.ts b/src/types/errors.ts new file mode 100644 index 0000000..12a1876 --- /dev/null +++ b/src/types/errors.ts @@ -0,0 +1,10 @@ +/** + * Interface representing an HTTP response error + */ +export interface ResponseError extends Error { + response?: { + status: number + statusText: string + data?: any + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 05ce6d8..88bb521 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,3 +7,45 @@ export interface CommandOptions { */ verbose: boolean } + +// All these types should come from a general package + +/** + * Interface representing a language in Storyblok + */ +export interface Language { + name: string + code: string + fallback_code?: string + ai_translation_code: string | null +} + +export interface SpaceInternationalization { + languages: Language[] + default_lang_name: string +} + +export interface StoryblokUser { + id: number + email: string + username: string + friendly_name: string + otp_required: boolean + access_token: string +} + +export interface StoryblokLoginResponse { + otp_required: boolean + login_strategy: string + configured_2fa_options: string[] + access_token?: string +} + +export interface StoryblokLoginWithOtpResponse { + access_token: string + email: string + token_type: string + user_id: number + role: string + has_partner: boolean +} diff --git a/src/utils/api-routes.ts b/src/utils/api-routes.ts new file mode 100644 index 0000000..aeb67f0 --- /dev/null +++ b/src/utils/api-routes.ts @@ -0,0 +1,8 @@ +import type { RegionCode } from '../constants' +import { regionsDomain } from '../constants' + +const API_VERSION = 'v1' + +export const getStoryblokUrl = (region: RegionCode = 'eu') => { + return `https://${regionsDomain[region]}/${API_VERSION}` +} diff --git a/src/utils/error/api-error.ts b/src/utils/error/api-error.ts index 122e11c..42c19a5 100644 --- a/src/utils/error/api-error.ts +++ b/src/utils/error/api-error.ts @@ -1,4 +1,4 @@ -import { FetchError } from 'ofetch' +import { FetchError } from '../fetch' export const API_ACTIONS = { login: 'login', @@ -18,9 +18,9 @@ export const API_ERRORS = { not_found: 'The requested resource was not found', } as const -export function handleAPIError(action: keyof typeof API_ACTIONS, error: Error): void { +export function handleAPIError(action: keyof typeof API_ACTIONS, error: unknown): void { if (error instanceof FetchError) { - const status = error.response?.status + const status = error.response.status switch (status) { case 401: @@ -33,10 +33,7 @@ export function handleAPIError(action: keyof typeof API_ACTIONS, error: Error): throw new APIError('network_error', action, error) } } - else if (error.message.includes('NetworkError') || error.message.includes('Failed to fetch')) { - throw new APIError('network_error', action, error) - } - throw new APIError('generic', action, error) + throw new APIError('generic', action, error as FetchError) } export class APIError extends Error { diff --git a/src/utils/error/error.ts b/src/utils/error/error.ts index 93f2259..4459f3b 100644 --- a/src/utils/error/error.ts +++ b/src/utils/error/error.ts @@ -1,12 +1,13 @@ import { konsola } from '..' +import type { FetchError } from '../fetch' import { APIError } from './api-error' import { CommandError } from './command-error' import { FileSystemError } from './filesystem-error' -export function handleError(error: Error, verbose = false): void { +export function handleError(error: Error | FetchError, verbose = false): void { // Print the message stack if it exists - if ((error as any).messageStack) { - const messageStack = (error as any).messageStack + if (error instanceof APIError || error instanceof FileSystemError) { + const messageStack = (error).messageStack messageStack.forEach((message: string, index: number) => { konsola.error(message, null, { header: index === 0, @@ -19,8 +20,8 @@ export function handleError(error: Error, verbose = false): void { header: true, }) } - if (verbose && typeof (error as any).getInfo === 'function') { - const errorDetails = (error as any).getInfo() + if (verbose && (error instanceof APIError || error instanceof FileSystemError)) { + const errorDetails = error.getInfo() if (error instanceof CommandError) { konsola.error(`Command Error: ${error.getInfo().message}`, errorDetails) } @@ -31,7 +32,7 @@ export function handleError(error: Error, verbose = false): void { konsola.error(`File System Error: ${error.getInfo().cause}`, errorDetails) } else { - konsola.error(`Unexpected Error: ${error.message}`, errorDetails) + konsola.error(`Unexpected Error: ${error}`, errorDetails) } } else { diff --git a/src/utils/fetch.test.ts b/src/utils/fetch.test.ts new file mode 100644 index 0000000..9b71617 --- /dev/null +++ b/src/utils/fetch.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from 'vitest' +import { customFetch, FetchError } from './fetch' + +// Mock fetch +const mockFetch = vi.fn() +globalThis.fetch = mockFetch + +describe('customFetch', () => { + it('should make a successful GET request', async () => { + const mockResponse = { data: 'test' } + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => 'application/json' }, + json: () => Promise.resolve(mockResponse), + }) + + const result = await customFetch('https://api.test.com') + expect(result).toEqual(mockResponse) + }) + + it('should handle object body by stringifying it', async () => { + const body = { test: 'data' } + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => 'application/json' }, + json: () => Promise.resolve({}), + }) + + await customFetch('https://api.test.com', { body }) + + expect(mockFetch).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ + body: JSON.stringify(body), + })) + }) + + it('should pass string body as-is without modification', async () => { + const body = '{"test":"data"}' + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => 'application/json' }, + json: () => Promise.resolve({}), + }) + + await customFetch('https://api.test.com', { body }) + + expect(mockFetch).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ + body, + })) + }) + + it('should handle array body by stringifying it', async () => { + const body = ['test', 'data'] + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => 'application/json' }, + json: () => Promise.resolve({}), + }) + + await customFetch('https://api.test.com', { body }) + + expect(mockFetch).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ + body: JSON.stringify(body), + })) + }) + + it('should handle non-JSON responses', async () => { + const textResponse = 'Hello World' + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => 'text/plain' }, + text: () => Promise.resolve(textResponse), + }) + + await expect(customFetch('https://api.test.com')).rejects.toThrow(FetchError) + }) + + it('should throw FetchError for HTTP errors', async () => { + const errorResponse = { message: 'Not Found' } + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + json: () => Promise.resolve(errorResponse), + }) + + await expect(customFetch('https://api.test.com')).rejects.toThrow(FetchError) + await expect(customFetch('https://api.test.com')).rejects.toMatchObject({ + response: { + status: 404, + statusText: 'Not Found', + data: errorResponse, + }, + }) + }) + + it('should handle network errors', async () => { + mockFetch.mockRejectedValue(new Error('Network Error')) + + await expect(customFetch('https://api.test.com')).rejects.toThrow(FetchError) + await expect(customFetch('https://api.test.com')).rejects.toMatchObject({ + response: { + status: 0, + statusText: 'Network Error', + data: null, + }, + }) + }) + + it('should set correct headers', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: { get: () => 'application/json' }, + json: () => Promise.resolve({}), + }) + + await customFetch('https://api.test.com', { + headers: { + Authorization: 'Bearer token', + }, + }) + + expect(mockFetch).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer token', + }), + })) + }) +}) diff --git a/src/utils/fetch.ts b/src/utils/fetch.ts new file mode 100644 index 0000000..34e4099 --- /dev/null +++ b/src/utils/fetch.ts @@ -0,0 +1,76 @@ +export class FetchError extends Error { + response: { + status: number + statusText: string + data?: Record | null + } + + constructor(message: string, response: { status: number, statusText: string, data?: Record | null }) { + super(message) + this.name = 'FetchError' + this.response = response + } +} + +export interface FetchOptions { + headers?: Record + method?: string + body?: any +} + +export async function customFetch(url: string, options: FetchOptions = {}): Promise { + try { + const headers = { + 'Content-Type': 'application/json', + ...options.headers, + } + + // Handle JSON body + const fetchOptions: FetchOptions = { + ...options, + headers, + } + + if (options.body) { + fetchOptions.body = typeof options.body === 'string' + ? options.body + : JSON.stringify(options.body) + } + + const response = await fetch(url, fetchOptions) + let data + try { + // We try to parse the response as JSON + data = await response.json() + } + catch { + // If it fails, we throw an error + throw new FetchError(`Non-JSON response`, { + status: response.status, + statusText: response.statusText, + data: null, + }) + } + + if (!response.ok) { + throw new FetchError(`HTTP error! status: ${response.status}`, { + status: response.status, + statusText: response.statusText, + data, + }) + } + + return data + } + catch (error) { + if (error instanceof FetchError) { + throw error + } + // For network errors or other non-HTTP errors, create a FetchError + throw new FetchError(error instanceof Error ? error.message : String(error), { + status: 0, + statusText: 'Network Error', + data: null, + }) + } +}