diff --git a/.gitignore b/.gitignore index 1d7923c..9452434 100644 --- a/.gitignore +++ b/.gitignore @@ -111,4 +111,7 @@ dist # CLI generated files components.*.json presets.*.json -storyblok-component-types.d.ts \ No newline at end of file +storyblok-component-types.d.ts + +# storyblok folder +.storyblok/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 2ddf456..8f77209 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -54,6 +54,20 @@ "env": { "STUB": "true" } + }, + { + "type": "node", + "request": "launch", + "name": "Debug Pull languages", + "program": "${workspaceFolder}/dist/index.mjs", + "args": ["pull-languages", "--space", "2950182323", "--path", ".storyblok"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "env": { + "STUB": "true" + } } ] } diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index fd08231..a329dd8 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -41,8 +41,11 @@ export const loginCommand = program region: RegionCode }) => { konsola.title(` ${commands.LOGIN} `, colorPalette.LOGIN) + // Global options const verbose = program.opts().verbose + // Command options const { token, region } = options + if (!isRegion(region)) { handleError(new CommandError(`The provided region: ${region} is not valid. Please use one of the following values: ${Object.values(regions).join(' | ')}`)) } diff --git a/src/commands/pull-languages/actions.test.ts b/src/commands/pull-languages/actions.test.ts new file mode 100644 index 0000000..dbec82b --- /dev/null +++ b/src/commands/pull-languages/actions.test.ts @@ -0,0 +1,92 @@ +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { vol } from 'memfs' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { pullLanguages, saveLanguagesToFile } from './actions' + +const handlers = [ + http.get('https://api.storyblok.com/v1/spaces/12345', async ({ request }) => { + const token = request.headers.get('Authorization') + if (token === 'valid-token') { + return HttpResponse.json({ + space: { + default_lang_name: 'en', + languages: [ + { + code: 'ca', + name: 'Catalan', + }, + { + code: 'fr', + name: 'French', + }, + ], + }, + }) + } + return new HttpResponse('Unauthorized', { status: 401 }) + }), +] + +const server = setupServer(...handlers) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +vi.mock('node:fs') +vi.mock('node:fs/promises') + +describe('pull languages actions', () => { + beforeEach(() => { + vi.clearAllMocks() + vol.reset() + }) + + describe('pullLanguages', () => { + it('should pull languages successfully with a valid token', async () => { + const mockResponse = { + default_lang_name: 'en', + languages: [ + { + code: 'ca', + name: 'Catalan', + }, + { + code: 'fr', + name: 'French', + }, + ], + } + const result = await pullLanguages('12345', 'valid-token', 'eu') + expect(result).toEqual(mockResponse) + }) + }) + it('should throw an masked error for invalid token', async () => { + await expect(pullLanguages('12345', 'invalid-token', 'eu')).rejects.toThrow( + new Error(`The user is not authorized to access the API`), + ) + }) + + describe('saveLanguagesToFile', () => { + it('should save languages to a json file with space number', async () => { + const mockResponse = { + default_lang_name: 'en', + languages: [ + { + code: 'ca', + name: 'Catalan', + }, + { + code: 'fr', + name: 'French', + }, + ], + } + await saveLanguagesToFile('12345', mockResponse, '/temp') + const content = vol.readFileSync('/temp/languages.12345.json', 'utf8') + expect(content).toBe(JSON.stringify(mockResponse, null, 2)) + }) + }) +}) diff --git a/src/commands/pull-languages/actions.ts b/src/commands/pull-languages/actions.ts new file mode 100644 index 0000000..b1d963f --- /dev/null +++ b/src/commands/pull-languages/actions.ts @@ -0,0 +1,65 @@ +import { access, constants, mkdir, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' + +import { handleAPIError, handleFileSystemError } from '../../utils' +import { ofetch } from 'ofetch' +import { regionsDomain } from '../../constants' + +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 => { + try { + const response = await ofetch(`https://${regionsDomain[region]}/v1/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) + } +} + +export const saveLanguagesToFile = async (space: string, internationalizationOptions: SpaceInternationalizationOptions, path?: string) => { + try { + const data = JSON.stringify(internationalizationOptions, null, 2) + const filename = `languages.${space}.json` + const resolvedPath = path ? resolve(process.cwd(), path) : process.cwd() + const filePath = join(resolvedPath, filename) + + // Check if the path exists, and create it if it doesn't + try { + await access(resolvedPath, constants.F_OK) + } + catch { + try { + await mkdir(resolvedPath, { recursive: true }) + } + catch (mkdirError) { + handleFileSystemError('mkdir', mkdirError as Error) + return // Exit early if the directory creation fails + } + } + + try { + await writeFile(filePath, data, { mode: 0o600 }) + } + catch (writeError) { + handleFileSystemError('write', writeError as Error) + } + } + catch (error) { + handleFileSystemError('write', error as Error) + } +} diff --git a/src/commands/pull-languages/index.test.ts b/src/commands/pull-languages/index.test.ts new file mode 100644 index 0000000..1f938fb --- /dev/null +++ b/src/commands/pull-languages/index.test.ts @@ -0,0 +1,164 @@ +import chalk from 'chalk' +import { pullLanguagesCommand } from '.' +import { session } from '../../session' +import { CommandError, konsola } from './../../utils/' +import { pullLanguages, saveLanguagesToFile } from './actions' +import { colorPalette } from '../../constants' + +vi.mock('./actions', () => ({ + pullLanguages: vi.fn(), + saveLanguagesToFile: vi.fn(), +})) + +vi.mock('../../creds', () => ({ + addNetrcEntry: vi.fn(), + isAuthorized: vi.fn(), + getNetrcCredentials: vi.fn(), + getCredentialsForMachine: vi.fn(), +})) + +// Mocking the session module +vi.mock('../../session', () => { + let _cache: Record | null = null + const session = () => { + if (!_cache) { + _cache = { + state: { + isLoggedIn: false, + }, + updateSession: vi.fn(), + persistCredentials: vi.fn(), + initializeSession: vi.fn(), + } + } + return _cache + } + + return { + session, + } +}) + +vi.mock('../../utils', async () => { + const actualUtils = await vi.importActual('../../utils') + return { + ...actualUtils, + konsola: { + ok: vi.fn(), + title: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + handleError: (error: Error, header = false) => { + konsola.error(error, header) + // Optionally, prevent process.exit during tests + }, + } +}) + +describe('pullLanguages', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.clearAllMocks() + // Reset the option values + pullLanguagesCommand._optionValues = {} + }) + + describe('default mode', () => { + it('should prompt the user if operation was sucessfull', async () => { + const mockResponse = { + default_lang_name: 'en', + languages: [ + { + code: 'ca', + name: 'Catalan', + }, + { + code: 'fr', + name: 'French', + }, + ], + } + session().state = { + isLoggedIn: true, + password: 'valid-token', + region: 'eu', + } + + vi.mocked(pullLanguages).mockResolvedValue(mockResponse) + await pullLanguagesCommand.parseAsync(['node', 'test', '--space', '12345']) + expect(pullLanguages).toHaveBeenCalledWith('12345', 'valid-token', 'eu') + expect(saveLanguagesToFile).toHaveBeenCalledWith('12345', mockResponse, undefined) + expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`languages.12345.json`)}`) + }) + + it('should throw an error if the user is not logged in', async () => { + session().state = { + isLoggedIn: false, + } + const mockError = new CommandError(`You are currently not logged in. Please login first to get your user info.`) + await pullLanguagesCommand.parseAsync(['node', 'test', '--space', '12345']) + expect(konsola.error).toHaveBeenCalledWith(mockError, false) + }) + + it('should throw an error if the space is not provided', async () => { + session().state = { + isLoggedIn: true, + password: 'valid-token', + region: 'eu', + } + + const mockError = new CommandError(`Please provide the space as argument --space YOUR_SPACE_ID.`) + + console.log(pullLanguagesCommand) + await pullLanguagesCommand.parseAsync(['node', 'test']) + expect(konsola.error).toHaveBeenCalledWith(mockError, false) + }) + + it('should prompt a warning the user if no languages are found', async () => { + const mockResponse = { + default_lang_name: 'en', + languages: [], + } + session().state = { + isLoggedIn: true, + password: 'valid-token', + region: 'eu', + } + + vi.mocked(pullLanguages).mockResolvedValue(mockResponse) + + await pullLanguagesCommand.parseAsync(['node', 'test', '--space', '24568']) + expect(konsola.warn).toHaveBeenCalledWith(`No languages found in the space 24568`) + }) + }) + + describe('--path option', () => { + it('should save the file at the provided path', async () => { + const mockResponse = { + default_lang_name: 'en', + languages: [ + { + code: 'ca', + name: 'Catalan', + }, + { + code: 'fr', + name: 'French', + }, + ], + } + session().state = { + isLoggedIn: true, + password: 'valid-token', + region: 'eu', + } + + vi.mocked(pullLanguages).mockResolvedValue(mockResponse) + await pullLanguagesCommand.parseAsync(['node', 'test', '--space', '12345', '--path', '/tmp']) + expect(pullLanguages).toHaveBeenCalledWith('12345', 'valid-token', 'eu') + expect(saveLanguagesToFile).toHaveBeenCalledWith('12345', mockResponse, '/tmp') + expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`/tmp/languages.12345.json`)}`) + }) + }) +}) diff --git a/src/commands/pull-languages/index.ts b/src/commands/pull-languages/index.ts new file mode 100644 index 0000000..e567e5c --- /dev/null +++ b/src/commands/pull-languages/index.ts @@ -0,0 +1,47 @@ +import { colorPalette, commands } from '../../constants' +import { CommandError, handleError, konsola } from '../../utils' +import { getProgram } from '../../program' +import { session } from '../../session' +import { pullLanguages, saveLanguagesToFile } from './actions' +import chalk from 'chalk' + +const program = getProgram() // Get the shared singleton instance + +export const pullLanguagesCommand = program + .command('pull-languages') + .description(`Download your space's languages schema as json`) + .option('-s, --space ', 'space ID') + .option('-p, --path ', 'path to save the file') + .action(async (options) => { + konsola.title(` ${commands.PULL_LANGUAGES} `, colorPalette.PULL_LANGUAGES, 'Pulling languages...') + // Global options + const verbose = program.opts().verbose + // Command options + const { space, path } = options + + const { state, initializeSession } = session() + await initializeSession() + + if (!state.isLoggedIn || !state.password || !state.region) { + handleError(new CommandError(`You are currently not logged in. Please login first to get your user info.`), verbose) + return + } + if (!space) { + handleError(new CommandError(`Please provide the space as argument --space YOUR_SPACE_ID.`), verbose) + return + } + + try { + const internationalization = await pullLanguages(space, state.password, state.region) + + if (!internationalization || internationalization.languages?.length === 0) { + konsola.warn(`No languages found in the space ${space}`) + return + } + await saveLanguagesToFile(space, internationalization, path) + konsola.ok(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(path ? `${path}/languages.${space}.json` : `languages.${space}.json`)}`) + } + catch (error) { + handleError(error as Error, verbose) + } + }) diff --git a/src/commands/user/index.test.ts b/src/commands/user/index.test.ts index f869e5c..f350f1a 100644 --- a/src/commands/user/index.test.ts +++ b/src/commands/user/index.test.ts @@ -68,7 +68,7 @@ describe('userCommand', () => { password: 'valid-token', region: 'eu', } - getUser.mockResolvedValue(mockResponse) + vi.mocked(getUser).mockResolvedValue(mockResponse) await userCommand.parseAsync(['node', 'test']) expect(getUser).toHaveBeenCalledWith('valid-token', 'eu') @@ -95,7 +95,7 @@ describe('userCommand', () => { const mockError = new Error('Network error') - getUser.mockRejectedValue(mockError) + vi.mocked(getUser).mockRejectedValue(mockError) await userCommand.parseAsync(['node', 'test']) diff --git a/src/constants.ts b/src/constants.ts index cc527b2..8380061 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,12 +2,14 @@ export const commands = { LOGIN: 'login', LOGOUT: 'logout', USER: 'user', + PULL_LANGUAGES: 'pull-languages', } as const export const colorPalette = { PRIMARY: '#45bfb9', LOGIN: '#8556D3', - USER: '#8BC34A', // Changed to a less saturated green color + USER: '#8BC34A', + PULL_LANGUAGES: '#FFC107', } as const export interface ReadonlyArray { diff --git a/src/index.ts b/src/index.ts index c378fe7..2e83379 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,8 @@ import { getProgram } from './program' import './commands/login' import './commands/logout' import './commands/user' +import './commands/pull-languages' + import { loginWithToken } from './commands/login/actions' dotenv.config() // This will load variables from .env into process.env @@ -17,7 +19,6 @@ const messageText = ` ` console.log(formatHeader(` ${introText} ${messageText}`)) -program.option('-s, --space [value]', 'space ID') program.option('-v, --verbose', 'Enable verbose output') program.on('command:*', () => { diff --git a/src/utils/error/api-error.ts b/src/utils/error/api-error.ts index 0a859ee..122e11c 100644 --- a/src/utils/error/api-error.ts +++ b/src/utils/error/api-error.ts @@ -6,6 +6,7 @@ export const API_ACTIONS = { login_with_otp: 'Failed to log in with email, password and otp', login_email_password: 'Failed to log in with email and password', get_user: 'Failed to get user', + pull_languages: 'Failed to pull languages', } as const export const API_ERRORS = { @@ -13,7 +14,8 @@ export const API_ERRORS = { network_error: 'No response from server, please check if you are correctly connected to internet', invalid_credentials: 'The provided credentials are invalid', timeout: 'The API request timed out', - generic: 'Error logging in', + generic: 'Error fetching data from the API', + not_found: 'The requested resource was not found', } as const export function handleAPIError(action: keyof typeof API_ACTIONS, error: Error): void { @@ -23,6 +25,8 @@ export function handleAPIError(action: keyof typeof API_ACTIONS, error: Error): switch (status) { case 401: throw new APIError('unauthorized', action, error) + case 404: + throw new APIError('not_found', action, error) case 422: throw new APIError('invalid_credentials', action, error) default: