diff --git a/.vscode/launch.json b/.vscode/launch.json index 97893fd..4f333a1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -74,7 +74,7 @@ "request": "launch", "name": "Debug Pull languages", "program": "${workspaceFolder}/dist/index.mjs", - "args": ["pull-languages", "--space", "2950182323", "--path", ".storyblok"], + "args": ["languages", "pull", "--space", "2950182323", "--path", ".storyblok"], "cwd": "${workspaceFolder}", "console": "integratedTerminal", "sourceMaps": true, diff --git a/src/commands/pull-languages/actions.test.ts b/src/commands/languages/actions.test.ts similarity index 87% rename from src/commands/pull-languages/actions.test.ts rename to src/commands/languages/actions.test.ts index 49d533d..5be351c 100644 --- a/src/commands/pull-languages/actions.test.ts +++ b/src/commands/languages/actions.test.ts @@ -2,7 +2,7 @@ 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' +import { fetchLanguages, saveLanguagesToFile } from './actions' const handlers = [ http.get('https://api.storyblok.com/v1/spaces/12345', async ({ request }) => { @@ -44,7 +44,7 @@ describe('pull languages actions', () => { vol.reset() }) - describe('pullLanguages', () => { + describe('fetchLanguages', () => { it('should pull languages successfully with a valid token', async () => { const mockResponse = { default_lang_name: 'en', @@ -59,12 +59,12 @@ describe('pull languages actions', () => { }, ], } - const result = await pullLanguages('12345', 'valid-token', 'eu') + const result = await fetchLanguages('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( + await expect(fetchLanguages('12345', 'invalid-token', 'eu')).rejects.toThrow( new Error(`The user is not authorized to access the API`), ) }) @@ -90,7 +90,7 @@ describe('pull languages actions', () => { verbose: false, space: '12345', }) - const content = vol.readFileSync('/temp/languages.12345.json', 'utf8') + const content = vol.readFileSync('/temp/languages.json', 'utf8') expect(content).toBe(JSON.stringify(mockResponse, null, 2)) }) }) diff --git a/src/commands/pull-languages/actions.ts b/src/commands/languages/actions.ts similarity index 79% rename from src/commands/pull-languages/actions.ts rename to src/commands/languages/actions.ts index 0cd28cc..1dd31b0 100644 --- a/src/commands/pull-languages/actions.ts +++ b/src/commands/languages/actions.ts @@ -8,7 +8,7 @@ import type { RegionCode } from '../../constants' import type { SpaceInternationalization } from '../../types' import { getStoryblokUrl } from '../../utils/api-routes' -export const pullLanguages = async (space: string, token: string, region: RegionCode): Promise => { +export const fetchLanguages = async (space: string, token: string, region: RegionCode): Promise => { try { const url = getStoryblokUrl(region) const response = await customFetch<{ @@ -31,10 +31,10 @@ export const pullLanguages = async (space: string, token: string, region: Region export const saveLanguagesToFile = async (space: string, internationalizationOptions: SpaceInternationalization, options: PullLanguagesOptions) => { try { - const { filename = 'languages', suffix = space, path } = options + const { filename = 'languages', suffix, path } = options const data = JSON.stringify(internationalizationOptions, null, 2) - const name = `${filename}.${suffix}.json` - const resolvedPath = resolvePath(path, 'languages') + const name = suffix ? `${filename}.${suffix}.json` : `${filename}.json` + const resolvedPath = resolvePath(path, `languages/${space}/`) const filePath = join(resolvedPath, name) await saveToFile(filePath, data) diff --git a/src/commands/pull-languages/constants.ts b/src/commands/languages/constants.ts similarity index 100% rename from src/commands/pull-languages/constants.ts rename to src/commands/languages/constants.ts diff --git a/src/commands/languages/index.test.ts b/src/commands/languages/index.test.ts new file mode 100644 index 0000000..e13bf1a --- /dev/null +++ b/src/commands/languages/index.test.ts @@ -0,0 +1,234 @@ +import chalk from 'chalk' +import { languagesCommand } from '.' +import { session } from '../../session' +import { CommandError, konsola } from '../../utils' +import { fetchLanguages, saveLanguagesToFile } from './actions' +import { colorPalette } from '../../constants' + +vi.mock('./actions', () => ({ + fetchLanguages: vi.fn(), + saveLanguagesToFile: vi.fn(), +})) + +vi.mock('../../creds', () => ({ + getCredentials: vi.fn(), + addCredentials: vi.fn(), + removeCredentials: vi.fn(), + removeAllCredentials: 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('languagesCommand', () => { + describe('pull', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.clearAllMocks() + // Reset the option values + languagesCommand._optionValues = {} + for (const command of languagesCommand.commands) { + command._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(fetchLanguages).mockResolvedValue(mockResponse) + await languagesCommand.parseAsync(['node', 'test', 'pull', '--space', '12345']) + expect(fetchLanguages).toHaveBeenCalledWith('12345', 'valid-token', 'eu') + expect(saveLanguagesToFile).toHaveBeenCalledWith('12345', mockResponse, { + path: undefined, + }) + expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`.storyblok/languages/12345/languages.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 languagesCommand.parseAsync(['node', 'test', 'pull', '--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.`) + await languagesCommand.parseAsync(['node', 'test', 'pull']) + 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(fetchLanguages).mockResolvedValue(mockResponse) + + await languagesCommand.parseAsync(['node', 'test', 'pull', '--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(fetchLanguages).mockResolvedValue(mockResponse) + await languagesCommand.parseAsync(['node', 'test', 'pull', '--space', '12345', '--path', '/tmp']) + expect(fetchLanguages).toHaveBeenCalledWith('12345', 'valid-token', 'eu') + expect(saveLanguagesToFile).toHaveBeenCalledWith('12345', mockResponse, { + path: '/tmp', + }) + expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`/tmp/languages.json`)}`) + }) + }) + + describe('--filename option', () => { + it('should save the file with the provided filename', 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(fetchLanguages).mockResolvedValue(mockResponse) + await languagesCommand.parseAsync(['node', 'test', 'pull', '--space', '12345', '--filename', 'custom-languages']) + expect(fetchLanguages).toHaveBeenCalledWith('12345', 'valid-token', 'eu') + expect(saveLanguagesToFile).toHaveBeenCalledWith('12345', mockResponse, { + filename: 'custom-languages', + path: undefined, + }) + expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`.storyblok/languages/12345/custom-languages.json`)}`) + }) + }) + + describe('--suffix option', () => { + it('should save the file with the provided suffix', 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(fetchLanguages).mockResolvedValue(mockResponse) + await languagesCommand.parseAsync(['node', 'test', 'pull', '--space', '12345', '--suffix', 'custom-suffix']) + expect(fetchLanguages).toHaveBeenCalledWith('12345', 'valid-token', 'eu') + expect(saveLanguagesToFile).toHaveBeenCalledWith('12345', mockResponse, { + suffix: 'custom-suffix', + path: undefined, + }) + expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`.storyblok/languages/12345/languages.custom-suffix.json`)}`) + }) + }) + }) +}) diff --git a/src/commands/pull-languages/index.ts b/src/commands/languages/index.ts similarity index 66% rename from src/commands/pull-languages/index.ts rename to src/commands/languages/index.ts index 59fc3e8..8c48c16 100644 --- a/src/commands/pull-languages/index.ts +++ b/src/commands/languages/index.ts @@ -2,25 +2,31 @@ 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 { fetchLanguages, saveLanguagesToFile } from './actions' import chalk from 'chalk' import type { PullLanguagesOptions } from './constants' const program = getProgram() // Get the shared singleton instance -export const pullLanguagesCommand = program - .command('pull-languages') - .description(`Download your space's languages schema as json`) +export const languagesCommand = program + .command('languages') + .alias('lang') + .description(`Manage your space's languages`) .option('-s, --space ', 'space ID') .option('-p, --path ', 'path to save the file. Default is .storyblok/languages') + +languagesCommand + .command('pull') + .description(`Download your space's languages schema as json`) .option('-f, --filename ', 'filename to save the file as ..json') .option('--su, --suffix ', 'suffix to add to the file name (e.g. languages..json). By default, the space ID is used.') .action(async (options: PullLanguagesOptions) => { - konsola.title(` ${commands.PULL_LANGUAGES} `, colorPalette.PULL_LANGUAGES, 'Pulling languages...') + konsola.title(` ${commands.LANGUAGES} `, colorPalette.LANGUAGES, 'Pulling languages...') // Global options const verbose = program.opts().verbose // Command options - const { space, path, filename = 'languages', suffix = options.space } = options + const { space, path } = languagesCommand.opts() + const { filename = 'languages', suffix = options.space } = options const { state, initializeSession } = session() await initializeSession() @@ -35,14 +41,19 @@ export const pullLanguagesCommand = program } try { - const internationalization = await pullLanguages(space, state.password, state.region) + const internationalization = await fetchLanguages(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, options) - konsola.ok(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(path ? `${path}/${filename}.${suffix}.json` : `.storyblok/languages/${filename}.${suffix}.json`)}`) + await saveLanguagesToFile(space, internationalization, { + ...options, + path, + }) + const fileName = suffix ? `${filename}.${suffix}.json` : `${filename}.json` + const filePath = path ? `${path}/${fileName}` : `.storyblok/languages/${space}/${fileName}` + konsola.ok(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(filePath)}`) } catch (error) { handleError(error as Error, verbose) diff --git a/src/commands/pull-languages/index.test.ts b/src/commands/pull-languages/index.test.ts deleted file mode 100644 index 7c2732f..0000000 --- a/src/commands/pull-languages/index.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -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, { - space: '12345', - }) - expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`.storyblok/languages/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.`) - 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, { - path: '/tmp', - space: '12345', - }) - expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`/tmp/languages.12345.json`)}`) - }) - }) - - describe('--filename option', () => { - it('should save the file with the provided filename', 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', '--filename', 'custom-languages']) - expect(pullLanguages).toHaveBeenCalledWith('12345', 'valid-token', 'eu') - expect(saveLanguagesToFile).toHaveBeenCalledWith('12345', mockResponse, { - filename: 'custom-languages', - space: '12345', - }) - expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`.storyblok/languages/custom-languages.12345.json`)}`) - }) - }) - - describe('--suffix option', () => { - it('should save the file with the provided suffix', 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', '--suffix', 'custom-suffix']) - expect(pullLanguages).toHaveBeenCalledWith('12345', 'valid-token', 'eu') - expect(saveLanguagesToFile).toHaveBeenCalledWith('12345', mockResponse, { - suffix: 'custom-suffix', - space: '12345', - }) - expect(konsola.ok).toHaveBeenCalledWith(`Languages schema downloaded successfully at ${chalk.hex(colorPalette.PRIMARY)(`.storyblok/languages/languages.custom-suffix.json`)}`) - }) - }) -}) diff --git a/src/constants.ts b/src/constants.ts index 8380061..2923190 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,14 +2,14 @@ export const commands = { LOGIN: 'login', LOGOUT: 'logout', USER: 'user', - PULL_LANGUAGES: 'pull-languages', + LANGUAGES: 'languages', } as const export const colorPalette = { PRIMARY: '#45bfb9', LOGIN: '#8556D3', USER: '#8BC34A', - PULL_LANGUAGES: '#FFC107', + LANGUAGES: '#FFC107', } as const export interface ReadonlyArray { diff --git a/src/index.ts b/src/index.ts index 4cdfce6..1723890 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import { getProgram } from './program' import './commands/login' import './commands/logout' import './commands/user' -import './commands/pull-languages' +import './commands/languages' import { session } from './session' diff --git a/src/utils/filesystem.ts b/src/utils/filesystem.ts index 2372973..0f5312f 100644 --- a/src/utils/filesystem.ts +++ b/src/utils/filesystem.ts @@ -14,7 +14,7 @@ export const getStoryblokGlobalPath = () => { return join(homeDirectory, '.storyblok') } -export const saveToFile = async (filePath: string, data: string, options: FileOptions) => { +export const saveToFile = async (filePath: string, data: string, options?: FileOptions) => { // Get the directory path const resolvedPath = parse(filePath).dir