diff --git a/.vscode/launch.json b/.vscode/launch.json index 8f77209..8a87511 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", 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/src/commands/login/actions.ts b/src/commands/login/actions.ts index eb609b6..ffc22ee 100644 --- a/src/commands/login/actions.ts +++ b/src/commands/login/actions.ts @@ -1,12 +1,13 @@ 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' 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(`${url}/users/me`, { headers: { Authorization: token, }, @@ -14,7 +15,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 +25,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 +44,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/pull-languages/actions.ts b/src/commands/pull-languages/actions.ts index 03b57d0..f7134fc 100644 --- a/src/commands/pull-languages/actions.ts +++ b/src/commands/pull-languages/actions.ts @@ -1,38 +1,33 @@ 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(`${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..a6cec42 100644 --- a/src/commands/user/actions.ts +++ b/src/commands/user/actions.ts @@ -1,11 +1,13 @@ -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' -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) + return await customFetch(`${url}/users/me`, { headers: { Authorization: token, }, @@ -13,7 +15,7 @@ export const getUser = async (token: string, region: string) => { } catch (error) { if (error instanceof FetchError) { - const status = error.response?.status + const status = error.response.status switch (status) { case 401: @@ -23,8 +25,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/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..739f102 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,3 +7,18 @@ export interface CommandOptions { */ verbose: boolean } + +/** + * 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 +} diff --git a/src/utils/api-routes.ts b/src/utils/api-routes.ts new file mode 100644 index 0000000..8e7a063 --- /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) => { + 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.ts b/src/utils/fetch.ts new file mode 100644 index 0000000..cad482d --- /dev/null +++ b/src/utils/fetch.ts @@ -0,0 +1,82 @@ +export class FetchError extends Error { + response: { + status: number + statusText: string + data?: any + } + + constructor(message: string, response: { status: number, statusText: string, data?: any }) { + super(message) + this.name = 'FetchError' + this.response = response + } +} + +type FetchOptions = Omit & { + 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: RequestInit = { + ...options, + headers, + } + + if (options.body) { + if (typeof options.body === 'string') { + try { + JSON.parse(options.body) + fetchOptions.body = options.body + } + catch { + fetchOptions.body = JSON.stringify(options.body) + } + } + else { + fetchOptions.body = JSON.stringify(options.body) + } + } + + const response = await fetch(url, fetchOptions) + + if (!response.ok) { + let data + try { + data = await response.json() + } + catch { + // Ignore JSON parse errors + } + throw new FetchError(`HTTP error! status: ${response.status}`, { + status: response.status, + statusText: response.statusText, + data, + }) + } + + // Handle empty responses + const contentType = response.headers.get('content-type') + if (contentType?.includes('application/json')) { + return await response.json() + } + return await response.text() as T + } + 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, + }) + } +}