From 7e448ae0fe73e4d2b6cd8d4f5dd3658abf751112 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 9 Oct 2024 17:44:00 +0200 Subject: [PATCH 01/25] feat: narrow the typesafe of constants --- src/constants.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index baacee34..739cfa42 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,41 +1,41 @@ -export const commands: Record = { +export const commands = { LOGIN: 'login', LOGOUT: 'logout', -} +} as const -export const regions: Record = { +export const regions = { EU: 'eu', US: 'us', CN: 'cn', CA: 'ca', AP: 'ap', -} +} as const -export const regionsDomain: Record = { +export const regionsDomain = { eu: 'api.storyblok.com', us: 'api-us.storyblok.com', cn: 'app.storyblokchina.cn', ca: 'api-ca.storyblok.com', ap: 'api-ap.storyblok.com', -} +} as const -export const managementApiRegions: Record = { +export const managementApiRegions = { eu: 'mapi.storyblok.com', us: 'mapi-us.storyblok.com', cn: 'mapi.storyblokchina.cn', ca: 'mapi-ca.storyblok.com', ap: 'mapi-ap.storyblok.com', -} +} as const -export const regionNames: Record = { +export const regionNames = { eu: 'Europe', us: 'United States', cn: 'China', ca: 'Canada', ap: 'Australia', -} +} as const -export const DEFAULT_AGENT: Record = { +export const DEFAULT_AGENT = { SB_Agent: 'SB-CLI', SB_Agent_Version: process.env.npm_package_version || '4.x', -} +} as const From 779d41ab63c0643e5c61c7b908759d8bea635d0e Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 9 Oct 2024 19:17:44 +0200 Subject: [PATCH 02/25] feat(types): improved regions code typing --- src/api.ts | 5 +++-- src/commands/login/actions.ts | 7 ++++--- src/commands/login/index.ts | 15 +++++++++------ src/constants.ts | 10 ++++++---- src/creds.ts | 3 ++- src/session.ts | 3 ++- 6 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/api.ts b/src/api.ts index 724c765d..9d065a85 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,8 +1,9 @@ import StoryblokClient from 'storyblok-js-client' import { session } from './session' +import type { RegionCode } from './constants' export interface ApiClientState { - region: string + region: RegionCode accessToken: string client: StoryblokClient | null } @@ -35,7 +36,7 @@ export function apiClient() { createClient() } - function setRegion(region: string) { + function setRegion(region: RegionCode) { state.region = region state.client = null createClient() diff --git a/src/commands/login/actions.ts b/src/commands/login/actions.ts index 1c5ea952..28a38d8f 100644 --- a/src/commands/login/actions.ts +++ b/src/commands/login/actions.ts @@ -1,10 +1,11 @@ import chalk from 'chalk' +import type { RegionCode } from '../../constants' import { regionsDomain } from '../../constants' import type { FetchError } from 'ofetch' import { ofetch } from 'ofetch' import { maskToken } from '../../utils' -export const loginWithToken = async (token: string, region: string) => { +export const loginWithToken = async (token: string, region: RegionCode) => { try { return await ofetch(`https://${regionsDomain[region]}/v1/users/me`, { headers: { @@ -25,7 +26,7 @@ export const loginWithToken = async (token: string, region: string) => { } } -export const loginWithEmailAndPassword = async (email: string, password: string, region: string) => { +export const loginWithEmailAndPassword = async (email: string, password: string, region: RegionCode) => { try { return await ofetch(`https://${regionsDomain[region]}/v1/users/login`, { method: 'POST', @@ -37,7 +38,7 @@ export const loginWithEmailAndPassword = async (email: string, password: string, } } -export const loginWithOtp = async (email: string, password: string, otp: string, region: string) => { +export const loginWithOtp = async (email: string, password: string, otp: string, region: RegionCode) => { try { return await ofetch(`https://${regionsDomain[region]}/v1/users/login`, { method: 'POST', diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index a3a5f622..242ccc24 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -1,5 +1,6 @@ import chalk from 'chalk' import { input, password, select } from '@inquirer/prompts' +import type { RegionCode } from '../../constants' import { commands, regionNames, regions, regionsDomain } from '../../constants' import { getProgram } from '../../program' import { formatHeader, handleError, isRegion, konsola } from '../../utils' @@ -36,7 +37,10 @@ export const loginCommand = program `The region you would like to work in. Please keep in mind that the region must match the region of your space. This region flag will be used for the other cli's commands. You can use the values: ${allRegionsText}.`, regions.EU, ) - .action(async (options) => { + .action(async (options: { + token: string + region: RegionCode + }) => { const { token, region } = options if (!isRegion(region)) { konsola.error(new Error(`The provided region: ${region} is not valid. Please use one of the following values: ${Object.values(regions).join(' | ')}`), true) @@ -47,8 +51,7 @@ export const loginCommand = program await initializeSession() if (state.isLoggedIn) { - konsola.ok(`You are already logged in. If you want to login with a different account, please logout first. -`) + konsola.ok(`You are already logged in. If you want to login with a different account, please logout first.`) return } @@ -99,13 +102,13 @@ export const loginCommand = program }) const userRegion = await select({ message: 'Please select the region you would like to work in:', - choices: Object.values(regions).map(region => ({ + choices: Object.values(regions).map((region: RegionCode) => ({ name: regionNames[region], value: region, })), default: regions.EU, }) - const { otp_required } = await loginWithEmailAndPassword(userEmail, userPassword, userRegion as string) + const { otp_required } = await loginWithEmailAndPassword(userEmail, userPassword, userRegion) if (otp_required) { const otp = await input({ @@ -113,7 +116,7 @@ export const loginCommand = program required: true, }) - const { access_token } = await loginWithOtp(userEmail, userPassword, otp, userRegion as string) + const { access_token } = await loginWithOtp(userEmail, userPassword, otp, userRegion) updateSession(userEmail, access_token, userRegion) await persistCredentials(regionsDomain[userRegion]) diff --git a/src/constants.ts b/src/constants.ts index 739cfa42..99b5a210 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,7 +3,9 @@ export const commands = { LOGOUT: 'logout', } as const -export const regions = { +export type RegionCode = 'eu' | 'us' | 'cn' | 'ca' | 'ap' + +export const regions: Record, RegionCode> = { EU: 'eu', US: 'us', CN: 'cn', @@ -11,7 +13,7 @@ export const regions = { AP: 'ap', } as const -export const regionsDomain = { +export const regionsDomain: Record = { eu: 'api.storyblok.com', us: 'api-us.storyblok.com', cn: 'app.storyblokchina.cn', @@ -19,7 +21,7 @@ export const regionsDomain = { ap: 'api-ap.storyblok.com', } as const -export const managementApiRegions = { +export const managementApiRegions: Record = { eu: 'mapi.storyblok.com', us: 'mapi-us.storyblok.com', cn: 'mapi.storyblokchina.cn', @@ -27,7 +29,7 @@ export const managementApiRegions = { ap: 'mapi-ap.storyblok.com', } as const -export const regionNames = { +export const regionNames: Record = { eu: 'Europe', us: 'United States', cn: 'China', diff --git a/src/creds.ts b/src/creds.ts index 903dd56d..65a96e38 100644 --- a/src/creds.ts +++ b/src/creds.ts @@ -2,11 +2,12 @@ import fs from 'node:fs/promises' import path from 'node:path' import { handleError, konsola } from './utils' import chalk from 'chalk' +import type { RegionCode } from './constants' export interface NetrcMachine { login: string password: string - region: string + region: RegionCode } export const getNetrcFilePath = () => { diff --git a/src/session.ts b/src/session.ts index 1d486152..abfbf897 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,4 +1,5 @@ // session.ts +import type { RegionCode } from './constants' import { addNetrcEntry, getCredentialsForMachine, getNetrcCredentials } from './creds' interface SessionState { @@ -73,7 +74,7 @@ function createSession() { } } - function updateSession(login: string, password: string, region: string) { + function updateSession(login: string, password: string, region: RegionCode) { state.isLoggedIn = true state.login = login state.password = password From aece279f552ab5f98dc94b1dc509e6a6d0166688 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Thu, 10 Oct 2024 16:09:23 +0200 Subject: [PATCH 03/25] feat: set module resolution to node for tsconfig and add eslint flat config autofix --- .vscode/settings.json | 45 ++++++++++++++++++++++++--- eslint.config.js => eslint.config.mjs | 0 package.json | 1 - src/api.test.ts | 9 +----- src/creds.ts | 22 ++++++------- tsconfig.json | 10 ++---- tsconfig.node.json | 11 ------- 7 files changed, 56 insertions(+), 42 deletions(-) rename eslint.config.js => eslint.config.mjs (100%) delete mode 100644 tsconfig.node.json diff --git a/.vscode/settings.json b/.vscode/settings.json index e8202d53..953f331b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,45 @@ { + // Enable the ESlint flat config support + "eslint.experimental.useFlatConfig": true, + "eslint.format.enable": true, + + // Disable the default formatter, use eslint instead + "prettier.enable": false, "editor.formatOnSave": false, - "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true + + // Auto fix + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + "source.organizeImports": "never" }, - "editor.defaultFormatter": "esbenp.prettier-vscode" + + // Silent the stylistic rules in you IDE, but still auto fix them + "eslint.rules.customizations": [ + { "rule": "style/*", "severity": "off" }, + { "rule": "format/*", "severity": "off" }, + { "rule": "*-indent", "severity": "off" }, + { "rule": "*-spacing", "severity": "off" }, + { "rule": "*-spaces", "severity": "off" }, + { "rule": "*-order", "severity": "off" }, + { "rule": "*-dangle", "severity": "off" }, + { "rule": "*-newline", "severity": "off" }, + { "rule": "*quotes", "severity": "off" }, + { "rule": "*semi", "severity": "off" } + ], + + // Enable eslint for all supported languages + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "vue", + "html", + "markdown", + "json", + "jsonc", + "yaml", + "toml", + "yml" + ] } diff --git a/eslint.config.js b/eslint.config.mjs similarity index 100% rename from eslint.config.js rename to eslint.config.mjs diff --git a/package.json b/package.json index 6d65734a..4821603d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,5 @@ { "name": "storyblok", - "type": "module", "version": "0.0.0", "packageManager": "pnpm@9.9.0", "description": "Storyblok CLI", diff --git a/src/api.test.ts b/src/api.test.ts index b42b5bc6..045173f2 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -16,7 +16,7 @@ vi.mock('storyblok-js-client', () => { // Mocking the session module vi.mock('./session', () => { - let _cache + let _cache: Record | null = null const session = () => { if (!_cache) { _cache = { @@ -66,11 +66,4 @@ describe('storyblok API Client', () => { const { region } = apiClient() expect(region).toBe('us') }) - - it('should set the access token on the client', () => { - const { setAccessToken } = apiClient() - setAccessToken('test-token') - const { client } = apiClient() - expect(client.config.accessToken).toBe('test-token') - }) }) diff --git a/src/creds.ts b/src/creds.ts index 65a96e38..fb953219 100644 --- a/src/creds.ts +++ b/src/creds.ts @@ -1,5 +1,5 @@ -import fs from 'node:fs/promises' -import path from 'node:path' +import { access, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' import { handleError, konsola } from './utils' import chalk from 'chalk' import type { RegionCode } from './constants' @@ -15,11 +15,11 @@ export const getNetrcFilePath = () => { process.platform.startsWith('win') ? 'USERPROFILE' : 'HOME' ] || process.cwd() - return path.join(homeDirectory, '.netrc') + return join(homeDirectory, '.netrc') } const readNetrcFileAsync = async (filePath: string) => { - return await fs.readFile(filePath, 'utf8') + return await readFile(filePath, 'utf8') } const preprocessNetrcContent = (content: string) => { @@ -78,7 +78,7 @@ const parseNetrcContent = (content: string) => { export const getNetrcCredentials = async (filePath: string = getNetrcFilePath()) => { try { try { - await fs.access(filePath) + await access(filePath) } catch { console.warn(`.netrc file not found at path: ${filePath}`) @@ -154,9 +154,9 @@ export const addNetrcEntry = async ({ // Check if the file exists try { - await fs.access(filePath) + await access(filePath) // File exists, read and parse it - const content = await fs.readFile(filePath, 'utf8') + const content = await readFile(filePath, 'utf8') machines = parseNetrcContent(content) } catch { @@ -175,7 +175,7 @@ export const addNetrcEntry = async ({ const newContent = serializeNetrcMachines(machines) // Write the updated content back to the .netrc file - await fs.writeFile(filePath, newContent, { + await writeFile(filePath, newContent, { mode: 0o600, // Set file permissions }) @@ -196,9 +196,9 @@ export const removeNetrcEntry = async ( // Check if the file exists try { - await fs.access(filePath) + await access(filePath) // File exists, read and parse it - const content = await fs.readFile(filePath, 'utf8') + const content = await readFile(filePath, 'utf8') machines = parseNetrcContent(content) } catch { @@ -220,7 +220,7 @@ export const removeNetrcEntry = async ( const newContent = serializeNetrcMachines(machines) // Write the updated content back to the .netrc file - await fs.writeFile(filePath, newContent, { + await writeFile(filePath, newContent, { mode: 0o600, // Set file permissions }) diff --git a/tsconfig.json b/tsconfig.json index c5f9a27f..bb7f8119 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,27 +2,23 @@ "compilerOptions": { "target": "esnext", "lib": ["esnext", "DOM", "DOM.Iterable"], - "useDefineForClassFields": true, "baseUrl": ".", "module": "esnext", - /* Bundler mode */ - "moduleResolution": "bundler", + "moduleResolution": "Node", "resolveJsonModule": true, "types": ["node", "vitest/globals"], "allowImportingTsExtensions": true, - /* Linting */ "strict": true, "noFallthroughCasesInSwitch": true, "noUnusedLocals": true, "noUnusedParameters": true, "noEmit": true, + "allowSyntheticDefaultImports": true, "isolatedModules": true, "skipLibCheck": true - }, - "references": [{ "path": "./tsconfig.node.json" }], "include": ["src"], - "exclude": ["node_modules", "src/**/*.cy.ts", "src/**/*.test.ts"] + "exclude": ["node_modules"] } diff --git a/tsconfig.node.json b/tsconfig.node.json deleted file mode 100644 index a1f2e8b7..00000000 --- a/tsconfig.node.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "baseUrl": ".", - "module": "ESNext", - "moduleResolution": "Node", - "types": ["vitest/globals"], - "allowSyntheticDefaultImports": true - }, - "include": ["vite.config.ts", "src/**/*.test.ts"] -} From d4ecf66b11072a19ed05b57d1c270dbcc0272150 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Thu, 10 Oct 2024 16:25:47 +0200 Subject: [PATCH 04/25] chore: force npm run dev to always stub the build for dev --- package.json | 2 +- src/program.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4821603d..d22cabc1 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "scripts": { "build": "unbuild", "build:stub": "unbuild --stub", - "dev": "node dist/index.mjs", + "dev": "pnpm run build:stub && STUB=true node dist/index.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "vitest", diff --git a/src/program.ts b/src/program.ts index e42ce34c..22bdbb11 100644 --- a/src/program.ts +++ b/src/program.ts @@ -5,7 +5,7 @@ import { resolve } from 'pathe' import { __dirname, handleError } from './utils' // Read package.json for metadata -const packageJsonPath = resolve(__dirname, process.env.VITEST ? '../../package.json' : '../../package.json') +const packageJsonPath = resolve(__dirname, process.env.VITEST || process.env.STUB ? '../../package.json' : '../package.json') const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) // Declare a variable to hold the singleton instance From 64ef7dbb6e93c08ebad953c60e3a61154f3fafbb Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Thu, 10 Oct 2024 16:37:54 +0200 Subject: [PATCH 05/25] chore(tests): type assertion for mocks --- src/commands/login/actions.test.ts | 17 ++++++++++------- src/program.test.ts | 3 ++- src/session.test.ts | 9 ++++++--- src/utils/index.ts | 3 ++- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/commands/login/actions.test.ts b/src/commands/login/actions.test.ts index 97aec97c..0f89e419 100644 --- a/src/commands/login/actions.test.ts +++ b/src/commands/login/actions.test.ts @@ -1,3 +1,4 @@ +import type { Mock } from 'vitest' import { describe, expect, it, vi } from 'vitest' import { loginWithEmailAndPassword, loginWithOtp, loginWithToken } from './actions' import { ofetch } from 'ofetch' @@ -7,6 +8,8 @@ vi.mock('ofetch', () => ({ ofetch: vi.fn(), })) +const mockedFetch = ofetch as unknown as Mock + describe('login actions', () => { beforeEach(() => { vi.clearAllMocks() @@ -15,7 +18,7 @@ describe('login actions', () => { describe('loginWithToken', () => { it('should login successfully with a valid token', async () => { const mockResponse = { data: 'user data' } - ofetch.mockResolvedValue(mockResponse) + mockedFetch.mockResolvedValue(mockResponse) const result = await loginWithToken('valid-token', 'eu') expect(result).toEqual(mockResponse) @@ -26,7 +29,7 @@ describe('login actions', () => { response: { status: 401 }, data: { error: 'Unauthorized' }, } - ofetch.mockRejectedValue(mockError) + mockedFetch.mockRejectedValue(mockError) await expect(loginWithToken('invalid-token', 'eu')).rejects.toThrow( new Error(`The token provided ${chalk.bold('inva*********')} is invalid: ${chalk.bold('401 Unauthorized')} @@ -37,7 +40,7 @@ describe('login actions', () => { it('should throw a network error if response is empty (network)', async () => { const mockError = new Error('Network error') - ofetch.mockRejectedValue(mockError) + mockedFetch.mockRejectedValue(mockError) await expect(loginWithToken('any-token', 'eu')).rejects.toThrow( 'No response from server, please check if you are correctly connected to internet', @@ -48,7 +51,7 @@ describe('login actions', () => { describe('loginWithEmailAndPassword', () => { it('should login successfully with valid email and password', async () => { const mockResponse = { data: 'user data' } - ofetch.mockResolvedValue(mockResponse) + mockedFetch.mockResolvedValue(mockResponse) const result = await loginWithEmailAndPassword('email@example.com', 'password', 'eu') expect(result).toEqual(mockResponse) @@ -56,7 +59,7 @@ describe('login actions', () => { it('should throw a generic error for login failure', async () => { const mockError = new Error('Network error') - ofetch.mockRejectedValue(mockError) + mockedFetch.mockRejectedValue(mockError) await expect(loginWithEmailAndPassword('email@example.com', 'password', 'eu')).rejects.toThrow( 'Error logging in with email and password', @@ -67,7 +70,7 @@ describe('login actions', () => { describe('loginWithOtp', () => { it('should login successfully with valid email, password, and otp', async () => { const mockResponse = { data: 'user data' } - ofetch.mockResolvedValue(mockResponse) + mockedFetch.mockResolvedValue(mockResponse) const result = await loginWithOtp('email@example.com', 'password', '123456', 'eu') expect(result).toEqual(mockResponse) @@ -75,7 +78,7 @@ describe('login actions', () => { it('should throw a generic error for login failure', async () => { const mockError = new Error('Network error') - ofetch.mockRejectedValue(mockError) + mockedFetch.mockRejectedValue(mockError) await expect(loginWithOtp('email@example.com', 'password', '123456', 'eu')).rejects.toThrow( 'Error logging in with email, password and otp', diff --git a/src/program.test.ts b/src/program.test.ts index 303a4f81..803c388d 100644 --- a/src/program.test.ts +++ b/src/program.test.ts @@ -3,8 +3,9 @@ import { beforeAll, describe, expect, it } from 'vitest' // Import the function after setting up mocks import { getProgram } from './program' // Import resolve to mock +import type { Command } from 'commander' -let program +let program: Command describe('program', () => { beforeAll(() => { program = getProgram() diff --git a/src/session.test.ts b/src/session.test.ts index dbd0ad2f..5dbb0839 100644 --- a/src/session.test.ts +++ b/src/session.test.ts @@ -2,12 +2,15 @@ import { session } from './session' import { getCredentialsForMachine } from './creds' +import type { Mock } from 'vitest' vi.mock('./creds', () => ({ getNetrcCredentials: vi.fn(), getCredentialsForMachine: vi.fn(), })) +const mockedGetCredentialsForMachine = getCredentialsForMachine as Mock + describe('session', () => { beforeEach(() => { vi.resetAllMocks() @@ -15,7 +18,7 @@ describe('session', () => { }) describe('session initialization with netrc', () => { it('should initialize session with netrc credentials', async () => { - getCredentialsForMachine.mockReturnValue({ + mockedGetCredentialsForMachine.mockReturnValue({ login: 'test_login', password: 'test_token', region: 'test_region', @@ -28,7 +31,7 @@ describe('session', () => { expect(userSession.state.region).toBe('test_region') }) it('should initialize session with netrc credentials for a specific machine', async () => { - getCredentialsForMachine.mockReturnValue({ + mockedGetCredentialsForMachine.mockReturnValue({ login: 'test_login', password: 'test_token', region: 'test_region', @@ -42,7 +45,7 @@ describe('session', () => { }) it('should initialize session with netrc credentials for a specific machine when no matching machine is present', async () => { - getCredentialsForMachine.mockReturnValue(undefined) + mockedGetCredentialsForMachine.mockReturnValue(undefined) const userSession = session() await userSession.initializeSession('nonexistent-machine') expect(userSession.state.isLoggedIn).toBe(false) diff --git a/src/utils/index.ts b/src/utils/index.ts index f8a74def..c6c6a8bb 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,6 @@ import { fileURLToPath } from 'node:url' import { dirname } from 'pathe' +import type { RegionCode } from '../constants' import { regions } from '../constants' export * from './error' @@ -7,7 +8,7 @@ export * from './konsola' export const __filename = fileURLToPath(import.meta.url) export const __dirname = dirname(__filename) -export function isRegion(value: string): value is keyof typeof regions { +export function isRegion(value: RegionCode): value is RegionCode { return Object.values(regions).includes(value) } From dc677f956c46170de801b01c22750d5cb47090b0 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Thu, 10 Oct 2024 16:48:05 +0200 Subject: [PATCH 06/25] chore: extremely weird choice of identation --- src/commands/login/index.ts | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index 242ccc24..3f32cb33 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -11,22 +11,21 @@ import { session } from '../../session' const program = getProgram() // Get the shared singleton instance const allRegionsText = Object.values(regions).join(',') -const loginStrategy - = { - message: 'How would you like to login?', - choices: [ - { - name: 'With email', - value: 'login-with-email', - short: 'Email', - }, - { - name: 'With Token (SSO)', - value: 'login-with-token', - short: 'Token', - }, - ], - } +const loginStrategy = { + message: 'How would you like to login?', + choices: [ + { + name: 'With email', + value: 'login-with-email', + short: 'Email', + }, + { + name: 'With Token (SSO)', + value: 'login-with-token', + short: 'Token', + }, + ], +} export const loginCommand = program .command(commands.LOGIN) From 76cba517bbbe71ae38131dccfb0792e2d4b6f311 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Mon, 14 Oct 2024 09:01:33 +0200 Subject: [PATCH 07/25] feat: improve error handling --- src/commands/login/index.ts | 4 ++-- src/commands/logout/index.ts | 3 +-- src/creds.ts | 21 ++++++++++----------- src/index.ts | 11 ++++------- src/utils/error.ts | 6 ++++-- src/utils/konsola.test.ts | 26 ++++++++++++++++++++++++++ src/utils/konsola.ts | 17 ++++++++++++++++- 7 files changed, 63 insertions(+), 25 deletions(-) diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index 3f32cb33..815ba37f 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -42,7 +42,7 @@ export const loginCommand = program }) => { const { token, region } = options if (!isRegion(region)) { - konsola.error(new Error(`The provided region: ${region} is not valid. Please use one of the following values: ${Object.values(regions).join(' | ')}`), true) + handleError(new Error(`The provided region: ${region} is not valid. Please use one of the following values: ${Object.values(regions).join(' | ')}`), true) } const { state, updateSession, persistCredentials, initializeSession } = session() @@ -67,7 +67,7 @@ export const loginCommand = program } } else { - console.log(formatHeader(chalk.bgHex('#8556D3').bold.white(` ${commands.LOGIN} `))) + konsola.title(` ${commands.LOGIN} `, '#8556D3') const strategy = await select(loginStrategy) try { diff --git a/src/commands/logout/index.ts b/src/commands/logout/index.ts index 27e1ce56..a41f8369 100644 --- a/src/commands/logout/index.ts +++ b/src/commands/logout/index.ts @@ -11,8 +11,7 @@ export const logoutCommand = program .action(async () => { const isAuth = await isAuthorized() if (!isAuth) { - konsola.ok(`You are already logged out. If you want to login, please use the login command. - `) + konsola.ok(`You are already logged out. If you want to login, please use the login command.`) return } try { diff --git a/src/creds.ts b/src/creds.ts index fb953219..b5a55073 100644 --- a/src/creds.ts +++ b/src/creds.ts @@ -77,14 +77,13 @@ const parseNetrcContent = (content: string) => { export const getNetrcCredentials = async (filePath: string = getNetrcFilePath()) => { try { - try { - await access(filePath) - } - catch { - console.warn(`.netrc file not found at path: ${filePath}`) - return {} - } - + await access(filePath) + } + catch { + console.warn(`.netrc file not found at path: ${filePath}`) + return {} + } + try { const content = await readNetrcFileAsync(filePath) const machines = parseNetrcContent(content) @@ -182,7 +181,7 @@ export const addNetrcEntry = async ({ konsola.ok(`Successfully added/updated entry for machine ${machineName} in ${chalk.hex('#45bfb9')(filePath)}`, true) } catch (error: unknown) { - handleError(new Error(`Error adding/updating entry for machine ${machineName} in .netrc file: ${(error as Error).message}`), true) + throw new Error(`Error adding/updating entry for machine ${machineName} in .netrc file: ${(error as Error).message}`) } } @@ -227,11 +226,11 @@ export const removeNetrcEntry = async ( konsola.ok(`Successfully removed entries from ${chalk.hex('#45bfb9')(filePath)}`, true) } catch (error: unknown) { - handleError(new Error(`Error removing entry for machine ${machineName} from .netrc file: ${(error as Error).message}`), true) + throw new Error(`Error removing entry for machine ${machineName} from .netrc file: ${(error as Error).message}`) } } -export async function isAuthorized(): Promise { +export async function isAuthorized() { try { const machines = await getNetrcCredentials() // Check if there is any machine with a valid email and token diff --git a/src/index.ts b/src/index.ts index 36a2d904..6ca47826 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,11 +2,10 @@ import chalk from 'chalk' import dotenv from 'dotenv' -import { formatHeader, handleError } from './utils' +import { formatHeader, handleError, konsola } from './utils' import { getProgram } from './program' import './commands/login' import './commands/logout' -import { session } from './session' dotenv.config() // This will load variables from .env into process.env const program = getProgram() @@ -21,14 +20,11 @@ program.option('-s, --space [value]', 'space ID') program.on('command:*', () => { console.error(`Invalid command: ${program.args.join(' ')}`) program.help() + konsola.br() // Add a line break }) program.command('test').action(async () => { - const { state, initializeSession } = session() - - await initializeSession() - - console.log(state) + handleError(new Error('There was an error with credentials'), true) }) /* console.log(` @@ -41,6 +37,7 @@ ${chalk.hex('#45bfb9')(' |/ ')} try { program.parse(process.argv) + konsola.br() // Add a line break } catch (error) { handleError(error as Error) diff --git a/src/utils/error.ts b/src/utils/error.ts index b5c7ca75..86197ef5 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -2,6 +2,8 @@ import { konsola } from '../utils' export function handleError(error: Error, header = false): void { konsola.error(error, header) - // TODO: add conditional to detect if this runs on tests - /* process.exit(1); */ + if (!process.env.VITEST) { + console.log('') // Add a line break + process.exit(1) + } } diff --git a/src/utils/konsola.test.ts b/src/utils/konsola.test.ts index 36d9017f..70dcb740 100644 --- a/src/utils/konsola.test.ts +++ b/src/utils/konsola.test.ts @@ -3,6 +3,32 @@ import { formatHeader, konsola } from './konsola' import { describe, expect, it, vi } from 'vitest' describe('konsola', () => { + describe('title', () => { + it('should prompt a title message', () => { + const consoleSpy = vi.spyOn(console, 'log') + konsola.title('This is a test title', '#45bfb9') + + expect(consoleSpy).toHaveBeenCalledWith(formatHeader(chalk.bgHex('#45bfb9').bold.white(` This is a test title `))) + }) + }) + describe('warn', () => { + it('should prompt a warning message', () => { + const consoleSpy = vi.spyOn(console, 'warn') + konsola.warn('This is a test warning message') + + expect(consoleSpy).toHaveBeenCalledWith(`${chalk.yellow('⚠️')} This is a test warning message`) + }) + + it('should prompt a warning message with header', () => { + const consoleSpy = vi.spyOn(console, 'warn') + konsola.warn('This is a test warning message', true) + const warnText = chalk.bgYellow.bold.black(` Warning `) + + expect(consoleSpy).toHaveBeenCalledWith(formatHeader(warnText, + )) + }) + }) + describe('success', () => { it('should prompt an success message', () => { const consoleSpy = vi.spyOn(console, 'log') diff --git a/src/utils/konsola.ts b/src/utils/konsola.ts index a0db1ebc..e975bba7 100644 --- a/src/utils/konsola.ts +++ b/src/utils/konsola.ts @@ -6,7 +6,12 @@ export function formatHeader(title: string) { ` } export const konsola = { - + title: (message: string, color: string) => { + console.log(formatHeader(chalk.bgHex(color).bold.white(` ${message} `))) + }, + br: () => { + console.log('') // Add a line break + }, ok: (message?: string, header: boolean = false) => { if (header) { console.log('') // Add a line break @@ -16,11 +21,21 @@ export const konsola = { console.log(message ? `${chalk.green('✔')} ${message}` : '') }, + warn: (message?: string, header: boolean = false) => { + if (header) { + console.log('') // Add a line break + const warnHeader = chalk.bgYellow.bold.black(` Warning `) + console.warn(formatHeader(warnHeader)) + } + + console.warn(message ? `${chalk.yellow('⚠️')} ${message}` : '') + }, error: (err: Error, header: boolean = false) => { if (header) { console.log('') // Add a line break const errorHeader = chalk.bgRed.bold.white(` Error `) console.error(formatHeader(errorHeader)) + console.error('a111') // Add a line break } console.error(`${chalk.red('x')} ${err.message || err}`) From de2f3bba03f6738a7efb61846d935f6fe6a05fa6 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Mon, 14 Oct 2024 09:08:23 +0200 Subject: [PATCH 08/25] tests: vi.mocked for the typescript win --- src/commands/login/actions.test.ts | 17 ++++++-------- src/commands/login/index.test.ts | 37 +++++++++++++++--------------- src/commands/logout/index.test.ts | 4 ++-- src/creds.test.ts | 2 +- 4 files changed, 29 insertions(+), 31 deletions(-) diff --git a/src/commands/login/actions.test.ts b/src/commands/login/actions.test.ts index 0f89e419..003ff600 100644 --- a/src/commands/login/actions.test.ts +++ b/src/commands/login/actions.test.ts @@ -1,4 +1,3 @@ -import type { Mock } from 'vitest' import { describe, expect, it, vi } from 'vitest' import { loginWithEmailAndPassword, loginWithOtp, loginWithToken } from './actions' import { ofetch } from 'ofetch' @@ -8,8 +7,6 @@ vi.mock('ofetch', () => ({ ofetch: vi.fn(), })) -const mockedFetch = ofetch as unknown as Mock - describe('login actions', () => { beforeEach(() => { vi.clearAllMocks() @@ -18,7 +15,7 @@ describe('login actions', () => { describe('loginWithToken', () => { it('should login successfully with a valid token', async () => { const mockResponse = { data: 'user data' } - mockedFetch.mockResolvedValue(mockResponse) + vi.mocked(ofetch).mockResolvedValue(mockResponse) const result = await loginWithToken('valid-token', 'eu') expect(result).toEqual(mockResponse) @@ -29,7 +26,7 @@ describe('login actions', () => { response: { status: 401 }, data: { error: 'Unauthorized' }, } - mockedFetch.mockRejectedValue(mockError) + vi.mocked(ofetch).mockRejectedValue(mockError) await expect(loginWithToken('invalid-token', 'eu')).rejects.toThrow( new Error(`The token provided ${chalk.bold('inva*********')} is invalid: ${chalk.bold('401 Unauthorized')} @@ -40,7 +37,7 @@ describe('login actions', () => { it('should throw a network error if response is empty (network)', async () => { const mockError = new Error('Network error') - mockedFetch.mockRejectedValue(mockError) + vi.mocked(ofetch).mockRejectedValue(mockError) await expect(loginWithToken('any-token', 'eu')).rejects.toThrow( 'No response from server, please check if you are correctly connected to internet', @@ -51,7 +48,7 @@ describe('login actions', () => { describe('loginWithEmailAndPassword', () => { it('should login successfully with valid email and password', async () => { const mockResponse = { data: 'user data' } - mockedFetch.mockResolvedValue(mockResponse) + vi.mocked(ofetch).mockResolvedValue(mockResponse) const result = await loginWithEmailAndPassword('email@example.com', 'password', 'eu') expect(result).toEqual(mockResponse) @@ -59,7 +56,7 @@ describe('login actions', () => { it('should throw a generic error for login failure', async () => { const mockError = new Error('Network error') - mockedFetch.mockRejectedValue(mockError) + vi.mocked(ofetch).mockRejectedValue(mockError) await expect(loginWithEmailAndPassword('email@example.com', 'password', 'eu')).rejects.toThrow( 'Error logging in with email and password', @@ -70,7 +67,7 @@ describe('login actions', () => { describe('loginWithOtp', () => { it('should login successfully with valid email, password, and otp', async () => { const mockResponse = { data: 'user data' } - mockedFetch.mockResolvedValue(mockResponse) + vi.mocked(ofetch).mockResolvedValue(mockResponse) const result = await loginWithOtp('email@example.com', 'password', '123456', 'eu') expect(result).toEqual(mockResponse) @@ -78,7 +75,7 @@ describe('login actions', () => { it('should throw a generic error for login failure', async () => { const mockError = new Error('Network error') - mockedFetch.mockRejectedValue(mockError) + vi.mocked(ofetch).mockRejectedValue(mockError) await expect(loginWithOtp('email@example.com', 'password', '123456', 'eu')).rejects.toThrow( 'Error logging in with email, password and otp', diff --git a/src/commands/login/index.test.ts b/src/commands/login/index.test.ts index 276cd791..dd730e25 100644 --- a/src/commands/login/index.test.ts +++ b/src/commands/login/index.test.ts @@ -22,7 +22,7 @@ vi.mock('../../creds', () => ({ // Mocking the session module vi.mock('../../session', () => { - let _cache + let _cache: Record | null = null const session = () => { if (!_cache) { _cache = { @@ -48,6 +48,7 @@ vi.mock('../../utils', async () => { ...actualUtils, konsola: { ok: vi.fn(), + title: vi.fn(), error: vi.fn(), }, handleError: (error: Error, header = false) => { @@ -83,15 +84,15 @@ describe('loginCommand', () => { vi.resetAllMocks() }) it('should prompt the user for email and password when login-with-email is selected', async () => { - select + vi.mocked(select) .mockResolvedValueOnce('login-with-email') // For login strategy .mockResolvedValueOnce('eu') // For region - input + vi.mocked(input) .mockResolvedValueOnce('user@example.com') // For email .mockResolvedValueOnce('123456') // For OTP code - password.mockResolvedValueOnce('test-password') + vi.mocked(password).mockResolvedValueOnce('test-password') await loginCommand.parseAsync(['node', 'test']) @@ -109,18 +110,18 @@ describe('loginCommand', () => { }) it('should login with email and password if provided using login-with-email strategy', async () => { - select + vi.mocked(select) .mockResolvedValueOnce('login-with-email') // For login strategy .mockResolvedValueOnce('eu') // For region - input + vi.mocked(input) .mockResolvedValueOnce('user@example.com') // For email .mockResolvedValueOnce('123456') // For OTP code - password.mockResolvedValueOnce('test-password') + vi.mocked(password).mockResolvedValueOnce('test-password') - loginWithEmailAndPassword.mockResolvedValueOnce({ otp_required: true }) - loginWithOtp.mockResolvedValueOnce({ access_token: 'test-token' }) + vi.mocked(loginWithEmailAndPassword).mockResolvedValueOnce({ otp_required: true }) + vi.mocked(loginWithOtp).mockResolvedValueOnce({ access_token: 'test-token' }) await loginCommand.parseAsync(['node', 'test']) @@ -130,8 +131,8 @@ describe('loginCommand', () => { }) it('should throw an error for invalid email and password', async () => { - select.mockResolvedValueOnce('login-with-email') - input.mockResolvedValueOnce('eu') + vi.mocked(select).mockResolvedValueOnce('login-with-email') + vi.mocked(input).mockResolvedValueOnce('eu') const mockError = new Error('Error logging in with email and password') loginWithEmailAndPassword.mockRejectedValueOnce(mockError) @@ -155,10 +156,10 @@ describe('loginCommand', () => { }) it('should login with token if token is provided using login-with-token strategy', async () => { - select.mockResolvedValueOnce('login-with-token') - password.mockResolvedValueOnce('test-token') + vi.mocked(select).mockResolvedValueOnce('login-with-token') + vi.mocked(password).mockResolvedValueOnce('test-token') const mockUser = { email: 'user@example.com' } - loginWithToken.mockResolvedValue({ user: mockUser }) + vi.mocked(loginWithToken).mockResolvedValue({ user: mockUser }) await loginCommand.parseAsync(['node', 'test']) @@ -178,7 +179,7 @@ describe('loginCommand', () => { it('should login with a valid token', async () => { const mockToken = 'test-token' const mockUser = { email: 'test@example.com' } - loginWithToken.mockResolvedValue({ user: mockUser }) + vi.mocked(loginWithToken).mockResolvedValue({ user: mockUser }) await loginCommand.parseAsync(['node', 'test', '--token', mockToken]) @@ -190,7 +191,7 @@ describe('loginCommand', () => { it('should login with a valid token in another region --region', async () => { const mockToken = 'test-token' const mockUser = { email: 'test@example.com' } - loginWithToken.mockResolvedValue({ user: mockUser }) + vi.mocked(loginWithToken).mockResolvedValue({ user: mockUser }) await loginCommand.parseAsync(['node', 'test', '--token', mockToken, '--region', 'us']) @@ -204,7 +205,7 @@ describe('loginCommand', () => { Please make sure you are using the correct token and try again.`) - loginWithToken.mockRejectedValue(mockError) + vi.mocked(loginWithToken).mockRejectedValue(mockError) await loginCommand.parseAsync(['node', 'test', '--token', 'invalid-token']) @@ -220,7 +221,7 @@ describe('loginCommand', () => { expect(konsola.error).toHaveBeenCalledWith(expect.any(Error), true) // Access the error argument - const errorArg = konsola.error.mock.calls[0][0] + const errorArg = vi.mocked(konsola.error).mock.calls[0][0] // Build the expected error message const expectedMessage = `The provided region: invalid-region is not valid. Please use one of the following values: ${Object.values(regions).join(' | ')}` diff --git a/src/commands/logout/index.test.ts b/src/commands/logout/index.test.ts index 8985f1d3..30db71aa 100644 --- a/src/commands/logout/index.test.ts +++ b/src/commands/logout/index.test.ts @@ -13,14 +13,14 @@ describe('logoutCommand', () => { }) it('should log out the user if has previously login', async () => { - isAuthorized.mockResolvedValue(true) + vi.mocked(isAuthorized).mockResolvedValue(true) await logoutCommand.parseAsync(['node', 'test']) expect(removeNetrcEntry).toHaveBeenCalled() }) it('should not log out the user if has not previously login', async () => { - isAuthorized.mockResolvedValue(false) + vi.mocked(isAuthorized).mockResolvedValue(false) await logoutCommand.parseAsync(['node', 'test']) expect(removeNetrcEntry).not.toHaveBeenCalled() }) diff --git a/src/creds.test.ts b/src/creds.test.ts index 225cceed..eb0f0b17 100644 --- a/src/creds.test.ts +++ b/src/creds.test.ts @@ -198,7 +198,7 @@ describe('creds', async () => { getNetrcCredentials: async () => { return { 'api.storyblok.com': { - login: 'julio.iglesias@storyblok.co,m', + login: 'julio.iglesias@storyblok.com', password: 'my_access', region: 'eu', }, From 32eb04b8f56e61490bb1b247552060c51cf537c8 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Mon, 14 Oct 2024 09:12:04 +0200 Subject: [PATCH 09/25] chore: remove unused code for linter --- src/commands/login/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index 815ba37f..9107bb03 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -3,7 +3,7 @@ import { input, password, select } from '@inquirer/prompts' import type { RegionCode } from '../../constants' import { commands, regionNames, regions, regionsDomain } from '../../constants' import { getProgram } from '../../program' -import { formatHeader, handleError, isRegion, konsola } from '../../utils' +import { handleError, isRegion, konsola } from '../../utils' import { loginWithEmailAndPassword, loginWithOtp, loginWithToken } from './actions' import { session } from '../../session' From 59686808f78679d51d7e9d0163128e8355b21904 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Mon, 14 Oct 2024 09:24:00 +0200 Subject: [PATCH 10/25] ci: remove pkg.pr.new temporarely until we do a release on next --- .github/workflows/pkg.pr.new.yml | 38 -------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 .github/workflows/pkg.pr.new.yml diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml deleted file mode 100644 index e5101b4b..00000000 --- a/.github/workflows/pkg.pr.new.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Publish Any Commit -on: - push: - branches: - - '**' - tags: - - '!**' - -env: - PNPM_CACHE_FOLDER: .pnpm-store - HUSKY: 0 # Bypass husky commit hook for CI - -permissions: {} - -concurrency: - group: ${{ github.workflow }}-${{ github.event.number }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - node-version: [20] - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - run: corepack enable - - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: "pnpm" - - name: Install dependencies - run: pnpm install - - name: Build - run: pnpm build - - run: pnpx pkg-pr-new publish --compact --pnpm \ No newline at end of file From 7449f5f83abf47e653e2758d53c8c700fd964041 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Tue, 15 Oct 2024 16:28:42 +0200 Subject: [PATCH 11/25] feat: improved error handling part 1, api errors and command errors --- package.json | 1 + src/commands/login/actions.ts | 27 ++++++------ src/commands/login/index.ts | 7 ++-- src/index.ts | 14 ++++++- src/utils/error.ts | 9 ---- src/utils/error/api-error.ts | 64 +++++++++++++++++++++++++++++ src/utils/error/command-error.ts | 14 +++++++ src/utils/{ => error}/error.test.ts | 0 src/utils/error/error.ts | 48 ++++++++++++++++++++++ src/utils/error/filesystem-error.ts | 26 ++++++++++++ src/utils/error/index.ts | 4 ++ src/utils/index.ts | 2 +- src/utils/konsola.ts | 48 +++++++++++++++++----- tsconfig.json | 4 +- 14 files changed, 227 insertions(+), 41 deletions(-) delete mode 100644 src/utils/error.ts create mode 100644 src/utils/error/api-error.ts create mode 100644 src/utils/error/command-error.ts rename src/utils/{ => error}/error.test.ts (100%) create mode 100644 src/utils/error/error.ts create mode 100644 src/utils/error/filesystem-error.ts create mode 100644 src/utils/error/index.ts diff --git a/package.json b/package.json index d22cabc1..e6382408 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "storyblok", + "type": "module", "version": "0.0.0", "packageManager": "pnpm@9.9.0", "description": "Storyblok CLI", diff --git a/src/commands/login/actions.ts b/src/commands/login/actions.ts index 28a38d8f..a372167a 100644 --- a/src/commands/login/actions.ts +++ b/src/commands/login/actions.ts @@ -1,9 +1,8 @@ import chalk from 'chalk' import type { RegionCode } from '../../constants' import { regionsDomain } from '../../constants' -import type { FetchError } from 'ofetch' -import { ofetch } from 'ofetch' -import { maskToken } from '../../utils' +import { FetchError, ofetch } from 'ofetch' +import { APIError, handleAPIError, maskToken } from '../../utils' export const loginWithToken = async (token: string, region: RegionCode) => { try { @@ -14,15 +13,19 @@ export const loginWithToken = async (token: string, region: RegionCode) => { }) } catch (error) { - if ((error as FetchError).response?.status === 401) { - throw new Error(`The token provided ${chalk.bold(maskToken(token))} is invalid: ${chalk.bold(`401 ${(error as FetchError).data.error}`)} + if (error instanceof FetchError) { + const status = error.response?.status - Please make sure you are using the correct token and try again.`) + switch (status) { + case 401: + throw new APIError('unauthorized', 'login_with_token', error, `The token provided ${chalk.bold(maskToken(token))} is invalid: ${chalk.bold(`401 ${(error as FetchError).data.error}`)} + Please make sure you are using the correct token and try again.`) + case 422: + throw new APIError('invalid_credentials', 'login_with_token', error) + default: + throw new APIError('network_error', 'login_with_token', error) + } } - if (!(error as FetchError).response) { - throw new Error('No response from server, please check if you are correctly connected to internet', error as Error) - } - throw new Error('Error logging with token', error as Error) } } @@ -34,7 +37,7 @@ export const loginWithEmailAndPassword = async (email: string, password: string, }) } catch (error) { - throw new Error('Error logging in with email and password', error as Error) + handleAPIError('login_email_password', error as Error) } } @@ -46,6 +49,6 @@ export const loginWithOtp = async (email: string, password: string, otp: string, }) } catch (error) { - throw new Error('Error logging in with email, password and otp', error as Error) + handleAPIError('login_with_otp', error as Error) } } diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index 9107bb03..c3492c4e 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -3,7 +3,7 @@ import { input, password, select } from '@inquirer/prompts' import type { RegionCode } from '../../constants' import { commands, regionNames, regions, regionsDomain } from '../../constants' import { getProgram } from '../../program' -import { handleError, isRegion, konsola } from '../../utils' +import { CommandError, handleError, isRegion, konsola } from '../../utils' import { loginWithEmailAndPassword, loginWithOtp, loginWithToken } from './actions' import { session } from '../../session' @@ -40,9 +40,10 @@ export const loginCommand = program token: string region: RegionCode }) => { + konsola.title(` ${commands.LOGIN} `, '#8556D3') const { token, region } = options if (!isRegion(region)) { - handleError(new Error(`The provided region: ${region} is not valid. Please use one of the following values: ${Object.values(regions).join(' | ')}`), true) + handleError(new CommandError(`The provided region: ${region} is not valid. Please use one of the following values: ${Object.values(regions).join(' | ')}`)) } const { state, updateSession, persistCredentials, initializeSession } = session() @@ -67,8 +68,6 @@ export const loginCommand = program } } else { - konsola.title(` ${commands.LOGIN} `, '#8556D3') - const strategy = await select(loginStrategy) try { if (strategy === 'login-with-token') { diff --git a/src/index.ts b/src/index.ts index 6ca47826..fd79cc62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ import { formatHeader, handleError, konsola } from './utils' import { getProgram } from './program' import './commands/login' import './commands/logout' +import { APIError } from './utils/error/api-error' +import { loginWithEmailAndPassword, loginWithToken } from './commands/login/actions' dotenv.config() // This will load variables from .env into process.env const program = getProgram() @@ -16,6 +18,7 @@ console.log(formatHeader(` ${introText} ${messageText}`)) program.option('-s, --space [value]', 'space ID') +program.option('-v, --verbose', 'Enable verbose output') program.on('command:*', () => { console.error(`Invalid command: ${program.args.join(' ')}`) @@ -23,8 +26,15 @@ program.on('command:*', () => { konsola.br() // Add a line break }) -program.command('test').action(async () => { - handleError(new Error('There was an error with credentials'), true) +program.command('test').action(async (options) => { + konsola.title(`Test`, '#8556D3', 'Attempting a test...') + try { + // await loginWithEmailAndPassword('aw', 'passwrod', 'eu') + await loginWithToken('WYSYDHYASDHSYD', 'eu') + } + catch (error) { + handleError(error as Error) + } }) /* console.log(` diff --git a/src/utils/error.ts b/src/utils/error.ts deleted file mode 100644 index 86197ef5..00000000 --- a/src/utils/error.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { konsola } from '../utils' - -export function handleError(error: Error, header = false): void { - konsola.error(error, header) - if (!process.env.VITEST) { - console.log('') // Add a line break - process.exit(1) - } -} diff --git a/src/utils/error/api-error.ts b/src/utils/error/api-error.ts new file mode 100644 index 00000000..bf5fe987 --- /dev/null +++ b/src/utils/error/api-error.ts @@ -0,0 +1,64 @@ +import { FetchError } from 'ofetch' + +export const API_ACTIONS = { + login: 'login', + login_with_token: 'Failed to log in with token', + login_with_otp: 'Failed to log in with email, password and otp', + login_email_password: 'Failed to log in with email and password', +} as const + +export const API_ERRORS = { + unauthorized: 'The user is not authorized to access the API', + 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', +} as const + +export function handleAPIError(action: keyof typeof API_ACTIONS, error: Error): void { + if (error instanceof FetchError) { + const status = error.response?.status + + switch (status) { + case 401: + throw new APIError('unauthorized', action, error) + case 422: + throw new APIError('invalid_credentials', action, error) + default: + 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) + } + else { + throw new APIError('timeout', action, error) + } +} + +export class APIError extends Error { + errorId: string + cause: string + code: number + messageStack: string[] + error: FetchError | undefined + + constructor(errorId: keyof typeof API_ERRORS, action: keyof typeof API_ACTIONS, error?: FetchError, customMessage?: string) { + super(customMessage || API_ERRORS[errorId]) + this.name = 'API Error' + this.errorId = errorId + this.cause = API_ERRORS[errorId] + this.code = error?.response?.status || 0 + this.messageStack = [API_ACTIONS[action], customMessage || API_ERRORS[errorId]] + this.error = error + } + + getInfo() { + return { + name: this.name, + message: this.message, + cause: this.cause, + errorId: this.errorId, + stack: this.stack, + } + } +} diff --git a/src/utils/error/command-error.ts b/src/utils/error/command-error.ts new file mode 100644 index 00000000..1b44c3de --- /dev/null +++ b/src/utils/error/command-error.ts @@ -0,0 +1,14 @@ +export class CommandError extends Error { + constructor(message: string) { + super(message) + this.name = 'Command Error' + } + + getInfo() { + return { + name: this.name, + message: this.message, + stack: this.stack, + } + } +} diff --git a/src/utils/error.test.ts b/src/utils/error/error.test.ts similarity index 100% rename from src/utils/error.test.ts rename to src/utils/error/error.test.ts diff --git a/src/utils/error/error.ts b/src/utils/error/error.ts new file mode 100644 index 00000000..325470b4 --- /dev/null +++ b/src/utils/error/error.ts @@ -0,0 +1,48 @@ +import { konsola } from '..' +import { APIError } from './api-error' +import { CommandError } from './command-error' +import { FileSystemError } from './filesystem-error' + +export function handleError(error: Error, verbose = false): void { + // If verbose flag is true and the error has getInfo method + if (verbose && typeof (error as any).getInfo === 'function') { + const errorDetails = (error as any).getInfo() + if (error instanceof CommandError) { + konsola.error(`Command Error: ${error.getInfo().message}`, errorDetails) + } + else if (error instanceof APIError) { + console.log('error', error) + konsola.error(`API Error: ${error.getInfo().cause}`, errorDetails) + } + else if (error instanceof FileSystemError) { + konsola.error(`File System Error: ${error.getInfo().cause}`, errorDetails) + } + else { + konsola.error(`Unexpected Error: ${error.message}`, errorDetails) + } + } + else { + // Print the message stack if it exists + if ((error as any).messageStack) { + const messageStack = (error as any).messageStack + messageStack.forEach((message: string, index: number) => { + konsola.error(message, null, { + header: index === 0, + margin: false, + }) + }) + konsola.br() + konsola.info('For more information about the error, run the command with the `--verbose` flag') + } + else { + konsola.error(error.message, null, { + header: true, + }) + } + } + + if (!process.env.VITEST) { + console.log('') // Add a line break for readability + process.exit(1) // Exit process if not in a test environment + } +} diff --git a/src/utils/error/filesystem-error.ts b/src/utils/error/filesystem-error.ts new file mode 100644 index 00000000..fa3e824b --- /dev/null +++ b/src/utils/error/filesystem-error.ts @@ -0,0 +1,26 @@ +const FS_ERRORS = { + file_not_found: 'The file requested was not found', + permission_denied: 'Permission denied while accessing the file', +} + +export class FileSystemError extends Error { + errorId: string + cause: string + + constructor(message: string, errorId: keyof typeof FS_ERRORS) { + super(message) + this.name = 'File System Error' + this.errorId = errorId + this.cause = FS_ERRORS[errorId] + } + + getInfo() { + return { + name: this.name, + message: this.message, + cause: this.cause, + errorId: this.errorId, + stack: this.stack, + } + } +} diff --git a/src/utils/error/index.ts b/src/utils/error/index.ts new file mode 100644 index 00000000..61bc96df --- /dev/null +++ b/src/utils/error/index.ts @@ -0,0 +1,4 @@ +export * from './api-error' +export * from './command-error' +export * from './error' +export * from './filesystem-error' diff --git a/src/utils/index.ts b/src/utils/index.ts index c6c6a8bb..f601a669 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,7 +3,7 @@ import { dirname } from 'pathe' import type { RegionCode } from '../constants' import { regions } from '../constants' -export * from './error' +export * from './error/' export * from './konsola' export const __filename = fileURLToPath(import.meta.url) export const __dirname = dirname(__filename) diff --git a/src/utils/konsola.ts b/src/utils/konsola.ts index e975bba7..9e7791c9 100644 --- a/src/utils/konsola.ts +++ b/src/utils/konsola.ts @@ -1,13 +1,20 @@ import chalk from 'chalk' +export interface KonsolaFormatOptions { + header?: boolean + margin?: boolean +} + export function formatHeader(title: string) { - return `${title} - - ` + return `${title}` } export const konsola = { - title: (message: string, color: string) => { - console.log(formatHeader(chalk.bgHex(color).bold.white(` ${message} `))) + title: (message: string, color: string, subtitle?: string) => { + console.log('') // Add a line break + console.log('') // Add a line break + console.log(`${formatHeader(chalk.bgHex(color).bold.white(` ${message} `))} ${subtitle || ''}`) + console.log('') // Add a line break + console.log('') // Add a line break }, br: () => { console.log('') // Add a line break @@ -21,6 +28,21 @@ export const konsola = { console.log(message ? `${chalk.green('✔')} ${message}` : '') }, + info: (message: string, options: KonsolaFormatOptions = { + header: false, + margin: true, + }) => { + if (options.header) { + console.log('') // Add a line break + const infoHeader = chalk.bgBlue.bold.white(` Info `) + console.log(formatHeader(infoHeader)) + } + + console.log(message ? `${chalk.blue('ℹ')} ${message}` : '') + if (options.margin) { + console.error('') // Add a line break + } + }, warn: (message?: string, header: boolean = false) => { if (header) { console.log('') // Add a line break @@ -30,15 +52,19 @@ export const konsola = { console.warn(message ? `${chalk.yellow('⚠️')} ${message}` : '') }, - error: (err: Error, header: boolean = false) => { - if (header) { - console.log('') // Add a line break + error: (message: string, info: unknown, options: KonsolaFormatOptions = { + header: false, + margin: true, + }) => { + if (options.header) { const errorHeader = chalk.bgRed.bold.white(` Error `) console.error(formatHeader(errorHeader)) - console.error('a111') // Add a line break + console.log('') // Add a line break } - console.error(`${chalk.red('x')} ${err.message || err}`) - console.log('') // Add a line break + console.error(`${chalk.red.bold('▲ error')} ${message}`, info || '') + if (options.margin) { + console.error('') // Add a line break + } }, } diff --git a/tsconfig.json b/tsconfig.json index bb7f8119..594e7bc2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,8 +4,7 @@ "lib": ["esnext", "DOM", "DOM.Iterable"], "baseUrl": ".", "module": "esnext", - - "moduleResolution": "Node", + "moduleResolution": "bundler", "resolveJsonModule": true, "types": ["node", "vitest/globals"], "allowImportingTsExtensions": true, @@ -16,6 +15,7 @@ "noUnusedParameters": true, "noEmit": true, "allowSyntheticDefaultImports": true, + "esModuleInterop": true, "isolatedModules": true, "skipLibCheck": true }, From 90ccd5708cb6bf7463a51e8a2842b47a87b19d29 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 16 Oct 2024 13:15:12 +0200 Subject: [PATCH 12/25] tests: update tests and use msw for api mocks --- package.json | 1 + pnpm-lock.yaml | 238 ++++++++++++++++++++++++++-- src/commands/login/actions.test.ts | 118 ++++++++------ src/commands/login/actions.ts | 7 +- src/creds.ts | 23 +-- src/index.ts | 5 +- src/utils/error/api-error.ts | 3 +- src/utils/error/error.ts | 1 - src/utils/error/filesystem-error.ts | 59 ++++++- src/utils/konsola.test.ts | 14 +- src/utils/konsola.ts | 16 +- 11 files changed, 396 insertions(+), 89 deletions(-) diff --git a/package.json b/package.json index e6382408..b16a207d 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@vitest/coverage-v8": "^2.1.1", "eslint": "^9.10.0", "memfs": "^4.11.2", + "msw": "^2.4.11", "pathe": "^1.1.2", "typescript": "^5.6.2", "unbuild": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92a8655e..a71d9f2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,7 +32,7 @@ importers: devDependencies: '@storyblok/eslint-config': specifier: ^0.2.0 - version: 0.2.0(@typescript-eslint/utils@8.5.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(@vue/compiler-sfc@3.5.5)(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)(vitest@2.1.1(@types/node@22.5.4)) + version: 0.2.0(@typescript-eslint/utils@8.5.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(@vue/compiler-sfc@3.5.5)(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)(vitest@2.1.1(@types/node@22.5.4)(msw@2.4.11(typescript@5.6.2))) '@types/inquirer': specifier: ^9.0.7 version: 9.0.7 @@ -41,13 +41,16 @@ importers: version: 22.5.4 '@vitest/coverage-v8': specifier: ^2.1.1 - version: 2.1.1(vitest@2.1.1(@types/node@22.5.4)) + version: 2.1.1(vitest@2.1.1(@types/node@22.5.4)(msw@2.4.11(typescript@5.6.2))) eslint: specifier: ^9.10.0 version: 9.10.0(jiti@1.21.6) memfs: specifier: ^4.11.2 version: 4.11.2 + msw: + specifier: ^2.4.11 + version: 2.4.11(typescript@5.6.2) pathe: specifier: ^1.1.2 version: 1.1.2 @@ -62,7 +65,7 @@ importers: version: 5.4.5(@types/node@22.5.4) vitest: specifier: ^2.1.1 - version: 2.1.1(@types/node@22.5.4) + version: 2.1.1(@types/node@22.5.4)(msw@2.4.11(typescript@5.6.2)) packages: @@ -200,6 +203,15 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bundled-es-modules/cookie@2.0.0': + resolution: {integrity: sha512-Or6YHg/kamKHpxULAdSqhGqnWFneIXu1NKvvfBBzKGwpVsYuFIQ5aBPHDnnoR3ghW1nvSkALd+EF9iMtY7Vjxw==} + + '@bundled-es-modules/statuses@1.0.1': + resolution: {integrity: sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==} + + '@bundled-es-modules/tough-cookie@0.1.6': + resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==} + '@clack/core@0.3.4': resolution: {integrity: sha512-H4hxZDXgHtWTwV3RAVenqcC4VbJZNegbBjlPvzOzCouXtS2y3sDvlO3IsbrPNWuLWPPlYVYPghQdSF64683Ldw==} @@ -699,6 +711,10 @@ packages: resolution: {integrity: sha512-0hm2nrToWUdD6/UHnel/UKGdk1//ke5zGUpHIvk5ZWmaKezlGxZkOJXNSWsdxO/rEqTkbB3lNC2J6nBElV2aAQ==} engines: {node: '>=18'} + '@inquirer/confirm@3.2.0': + resolution: {integrity: sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw==} + engines: {node: '>=18'} + '@inquirer/confirm@4.0.1': resolution: {integrity: sha512-46yL28o2NJ9doViqOy0VDcoTzng7rAb6yPQKU7VDLqkmbCaH4JqK4yk4XqlzNWy9PVC5pG1ZUXPBQv+VqnYs2w==} engines: {node: '>=18'} @@ -747,6 +763,10 @@ packages: resolution: {integrity: sha512-lUDGUxPhdWMkN/fHy1Lk7pF3nK1fh/gqeyWXmctefhxLYxlDsc7vsPBEpxrfVGDsVdyYJsiJoD4bJ1b623cV1Q==} engines: {node: '>=18'} + '@inquirer/type@1.5.5': + resolution: {integrity: sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==} + engines: {node: '>=18'} + '@inquirer/type@2.0.0': resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==} engines: {node: '>=18'} @@ -795,6 +815,10 @@ packages: peerDependencies: tslib: '2' + '@mswjs/interceptors@0.35.9': + resolution: {integrity: sha512-SSnyl/4ni/2ViHKkiZb8eajA/eN1DNFaHjhGiLUdZvDz6PKF4COSf/17xqSz64nOo2Ia29SA6B2KNCsyCbVmaQ==} + engines: {node: '>=18'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -807,6 +831,15 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -964,6 +997,9 @@ packages: resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -994,9 +1030,15 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/statuses@2.0.5': + resolution: {integrity: sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==} + '@types/through@0.0.33': resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -1315,6 +1357,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@0.5.0: + resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} + engines: {node: '>= 0.6'} + core-js-compat@3.38.1: resolution: {integrity: sha512-JRH6gfXxGmrzF3tZ57lFx97YARxCXPaMzPo6jELZhv88pBH5VXpQ+y0znKGlFnzuaihqhLbefxSJxWJMPtfDzw==} @@ -1820,6 +1866,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphql@16.9.0: + resolution: {integrity: sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -1832,6 +1882,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -1898,6 +1951,9 @@ packages: is-module@1.0.0: resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -2223,6 +2279,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msw@2.4.11: + resolution: {integrity: sha512-TVEw9NOPTc6ufOQLJ53234S9NBRxQbu7xFMxs+OCP43JQcNEIOKiZHxEm2nDzYIrwccoIhUxUf8wr99SukD76A==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + mute-stream@1.0.0: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -2268,6 +2334,9 @@ packages: resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} engines: {node: '>=0.10.0'} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -2325,6 +2394,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -2550,10 +2622,16 @@ packages: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} + psl@1.9.0: + resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2585,6 +2663,9 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2696,12 +2777,19 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + std-env@3.7.0: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} storyblok-js-client@6.9.2: resolution: {integrity: sha512-31GM5X/SIP4eJsSMCpAnaPDRmmUotSSWD3Umnuzf3CGqjyakot2Gv5QmuV23fRM7TCDUQlg5wurROmAzkKMKKg==} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2808,6 +2896,10 @@ packages: resolution: {integrity: sha512-khrZo4buq4qVmsGzS5yQjKe/WsFvV8fGfOjDQN0q4iy9FjRfPWRgTFrU8u1R2iu/SfWLhY9WnCi4Jhdrcbtg+g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + tree-dump@1.0.2: resolution: {integrity: sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==} engines: {node: '>=10.0'} @@ -2843,6 +2935,10 @@ packages: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} + type-fest@4.26.1: + resolution: {integrity: sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==} + engines: {node: '>=16'} + typescript@5.6.2: resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==} engines: {node: '>=14.17'} @@ -2875,6 +2971,10 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + untyped@1.4.2: resolution: {integrity: sha512-nC5q0DnPEPVURPhfPQLahhSTnemVtPzdx7ofiRxXpOB2SYnb3MfdU3DVGyJdS8Lx+tBWeAePO8BfU/3EgksM7Q==} hasBin: true @@ -2888,6 +2988,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -3036,7 +3139,7 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 - '@antfu/eslint-config@3.6.0(@typescript-eslint/utils@8.5.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(@vue/compiler-sfc@3.5.5)(eslint-plugin-format@0.1.2(eslint@9.10.0(jiti@1.21.6)))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)(vitest@2.1.1(@types/node@22.5.4))': + '@antfu/eslint-config@3.6.0(@typescript-eslint/utils@8.5.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(@vue/compiler-sfc@3.5.5)(eslint-plugin-format@0.1.2(eslint@9.10.0(jiti@1.21.6)))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)(vitest@2.1.1(@types/node@22.5.4)(msw@2.4.11(typescript@5.6.2)))': dependencies: '@antfu/install-pkg': 0.4.1 '@clack/prompts': 0.7.0 @@ -3045,7 +3148,7 @@ snapshots: '@stylistic/eslint-plugin': 2.8.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) '@typescript-eslint/eslint-plugin': 8.5.0(@typescript-eslint/parser@8.5.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) '@typescript-eslint/parser': 8.5.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) - '@vitest/eslint-plugin': 1.1.4(@typescript-eslint/utils@8.5.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)(vitest@2.1.1(@types/node@22.5.4)) + '@vitest/eslint-plugin': 1.1.4(@typescript-eslint/utils@8.5.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)(vitest@2.1.1(@types/node@22.5.4)(msw@2.4.11(typescript@5.6.2))) eslint: 9.10.0(jiti@1.21.6) eslint-config-flat-gitignore: 0.3.0(eslint@9.10.0(jiti@1.21.6)) eslint-flat-config-utils: 0.4.0 @@ -3207,6 +3310,19 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@bundled-es-modules/cookie@2.0.0': + dependencies: + cookie: 0.5.0 + + '@bundled-es-modules/statuses@1.0.1': + dependencies: + statuses: 2.0.1 + + '@bundled-es-modules/tough-cookie@0.1.6': + dependencies: + '@types/tough-cookie': 4.0.5 + tough-cookie: 4.1.4 + '@clack/core@0.3.4': dependencies: picocolors: 1.1.0 @@ -3506,6 +3622,11 @@ snapshots: ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 + '@inquirer/confirm@3.2.0': + dependencies: + '@inquirer/core': 9.2.1 + '@inquirer/type': 1.5.5 + '@inquirer/confirm@4.0.1': dependencies: '@inquirer/core': 9.2.1 @@ -3590,6 +3711,10 @@ snapshots: ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 + '@inquirer/type@1.5.5': + dependencies: + mute-stream: 1.0.0 + '@inquirer/type@2.0.0': dependencies: mute-stream: 1.0.0 @@ -3638,6 +3763,15 @@ snapshots: dependencies: tslib: 2.7.0 + '@mswjs/interceptors@0.35.9': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3650,6 +3784,15 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -3752,9 +3895,9 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.21.3': optional: true - '@storyblok/eslint-config@0.2.0(@typescript-eslint/utils@8.5.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(@vue/compiler-sfc@3.5.5)(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)(vitest@2.1.1(@types/node@22.5.4))': + '@storyblok/eslint-config@0.2.0(@typescript-eslint/utils@8.5.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(@vue/compiler-sfc@3.5.5)(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)(vitest@2.1.1(@types/node@22.5.4)(msw@2.4.11(typescript@5.6.2)))': dependencies: - '@antfu/eslint-config': 3.6.0(@typescript-eslint/utils@8.5.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(@vue/compiler-sfc@3.5.5)(eslint-plugin-format@0.1.2(eslint@9.10.0(jiti@1.21.6)))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)(vitest@2.1.1(@types/node@22.5.4)) + '@antfu/eslint-config': 3.6.0(@typescript-eslint/utils@8.5.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(@vue/compiler-sfc@3.5.5)(eslint-plugin-format@0.1.2(eslint@9.10.0(jiti@1.21.6)))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)(vitest@2.1.1(@types/node@22.5.4)(msw@2.4.11(typescript@5.6.2))) eslint: 9.10.0(jiti@1.21.6) eslint-plugin-format: 0.1.2(eslint@9.10.0(jiti@1.21.6)) transitivePeerDependencies: @@ -3791,6 +3934,8 @@ snapshots: '@trysound/sax@0.2.0': {} + '@types/cookie@0.6.0': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 0.7.34 @@ -3824,10 +3969,14 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/statuses@2.0.5': {} + '@types/through@0.0.33': dependencies: '@types/node': 22.5.4 + '@types/tough-cookie@4.0.5': {} + '@types/unist@3.0.3': {} '@types/wrap-ansi@3.0.0': {} @@ -3913,7 +4062,7 @@ snapshots: '@typescript-eslint/types': 8.5.0 eslint-visitor-keys: 3.4.3 - '@vitest/coverage-v8@2.1.1(vitest@2.1.1(@types/node@22.5.4))': + '@vitest/coverage-v8@2.1.1(vitest@2.1.1(@types/node@22.5.4)(msw@2.4.11(typescript@5.6.2)))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -3927,17 +4076,17 @@ snapshots: std-env: 3.7.0 test-exclude: 7.0.1 tinyrainbow: 1.2.0 - vitest: 2.1.1(@types/node@22.5.4) + vitest: 2.1.1(@types/node@22.5.4)(msw@2.4.11(typescript@5.6.2)) transitivePeerDependencies: - supports-color - '@vitest/eslint-plugin@1.1.4(@typescript-eslint/utils@8.5.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)(vitest@2.1.1(@types/node@22.5.4))': + '@vitest/eslint-plugin@1.1.4(@typescript-eslint/utils@8.5.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2))(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2)(vitest@2.1.1(@types/node@22.5.4)(msw@2.4.11(typescript@5.6.2)))': dependencies: eslint: 9.10.0(jiti@1.21.6) optionalDependencies: '@typescript-eslint/utils': 8.5.0(eslint@9.10.0(jiti@1.21.6))(typescript@5.6.2) typescript: 5.6.2 - vitest: 2.1.1(@types/node@22.5.4) + vitest: 2.1.1(@types/node@22.5.4)(msw@2.4.11(typescript@5.6.2)) '@vitest/expect@2.1.1': dependencies: @@ -3946,12 +4095,13 @@ snapshots: chai: 5.1.1 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.1(@vitest/spy@2.1.1)(vite@5.4.5(@types/node@22.5.4))': + '@vitest/mocker@2.1.1(@vitest/spy@2.1.1)(msw@2.4.11(typescript@5.6.2))(vite@5.4.5(@types/node@22.5.4))': dependencies: '@vitest/spy': 2.1.1 estree-walker: 3.0.3 magic-string: 0.30.11 optionalDependencies: + msw: 2.4.11(typescript@5.6.2) vite: 5.4.5(@types/node@22.5.4) '@vitest/pretty-format@2.1.1': @@ -4174,6 +4324,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@0.5.0: {} + core-js-compat@3.38.1: dependencies: browserslist: 4.23.3 @@ -4842,6 +4994,8 @@ snapshots: graphemer@1.4.0: {} + graphql@16.9.0: {} + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -4850,6 +5004,8 @@ snapshots: dependencies: function-bind: 1.1.2 + headers-polyfill@4.0.3: {} + hookable@5.5.3: {} hosted-git-info@2.8.9: {} @@ -4900,6 +5056,8 @@ snapshots: is-module@1.0.0: {} + is-node-process@1.2.0: {} + is-number@7.0.0: {} is-path-inside@3.0.3: {} @@ -5387,6 +5545,28 @@ snapshots: ms@2.1.3: {} + msw@2.4.11(typescript@5.6.2): + dependencies: + '@bundled-es-modules/cookie': 2.0.0 + '@bundled-es-modules/statuses': 1.0.1 + '@bundled-es-modules/tough-cookie': 0.1.6 + '@inquirer/confirm': 3.2.0 + '@mswjs/interceptors': 0.35.9 + '@open-draft/until': 2.1.0 + '@types/cookie': 0.6.0 + '@types/statuses': 2.0.5 + chalk: 4.1.2 + graphql: 16.9.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + strict-event-emitter: 0.5.1 + type-fest: 4.26.1 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.6.2 + mute-stream@1.0.0: {} nanoid@3.3.7: {} @@ -5433,6 +5613,8 @@ snapshots: os-tmpdir@1.0.2: {} + outvariant@1.4.3: {} + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -5484,6 +5666,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-to-regexp@6.3.0: {} + path-type@4.0.0: {} pathe@1.1.2: {} @@ -5681,8 +5865,12 @@ snapshots: pretty-bytes@6.1.1: {} + psl@1.9.0: {} + punycode@2.3.1: {} + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} read-pkg-up@7.0.1: @@ -5715,6 +5903,8 @@ snapshots: require-directory@2.1.1: {} + requires-port@1.0.0: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -5826,10 +6016,14 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.1: {} + std-env@3.7.0: {} storyblok-js-client@6.9.2: {} + strict-event-emitter@0.5.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -5929,6 +6123,13 @@ snapshots: dependencies: eslint-visitor-keys: 3.4.3 + tough-cookie@4.1.4: + dependencies: + psl: 1.9.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + tree-dump@1.0.2(tslib@2.7.0): dependencies: tslib: 2.7.0 @@ -5951,6 +6152,8 @@ snapshots: type-fest@0.8.1: {} + type-fest@4.26.1: {} + typescript@5.6.2: {} ufo@1.5.4: {} @@ -6009,6 +6212,8 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 + universalify@0.2.0: {} + untyped@1.4.2: dependencies: '@babel/core': 7.25.2 @@ -6031,6 +6236,11 @@ snapshots: dependencies: punycode: 2.3.1 + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + util-deprecate@1.0.2: {} validate-npm-package-license@3.0.4: @@ -6064,10 +6274,10 @@ snapshots: '@types/node': 22.5.4 fsevents: 2.3.3 - vitest@2.1.1(@types/node@22.5.4): + vitest@2.1.1(@types/node@22.5.4)(msw@2.4.11(typescript@5.6.2)): dependencies: '@vitest/expect': 2.1.1 - '@vitest/mocker': 2.1.1(@vitest/spy@2.1.1)(vite@5.4.5(@types/node@22.5.4)) + '@vitest/mocker': 2.1.1(@vitest/spy@2.1.1)(msw@2.4.11(typescript@5.6.2))(vite@5.4.5(@types/node@22.5.4)) '@vitest/pretty-format': 2.1.1 '@vitest/runner': 2.1.1 '@vitest/snapshot': 2.1.1 diff --git a/src/commands/login/actions.test.ts b/src/commands/login/actions.test.ts index 003ff600..32e74dd6 100644 --- a/src/commands/login/actions.test.ts +++ b/src/commands/login/actions.test.ts @@ -1,44 +1,66 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, expect } from 'vitest' + +import { setupServer } from 'msw/node' +import { http, HttpResponse } from 'msw' import { loginWithEmailAndPassword, loginWithOtp, loginWithToken } from './actions' -import { ofetch } from 'ofetch' import chalk from 'chalk' -vi.mock('ofetch', () => ({ - ofetch: vi.fn(), -})) +const emailRegex = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/ -describe('login actions', () => { - beforeEach(() => { - vi.clearAllMocks() - }) +const handlers = [ + http.get('https://api.storyblok.com/v1/users/me', async ({ request }) => { + const token = request.headers.get('Authorization') + if (token === 'valid-token') { return HttpResponse.json({ data: 'user data' }) } + return new HttpResponse('Unauthorized', { status: 401 }) + }), + http.post('https://api.storyblok.com/v1/users/login', async ({ request }) => { + const body = await request.json() as { email: string, password: string } + + if (!emailRegex.test(body.email)) { + return new HttpResponse('Unprocessable Entity', { status: 422 }) + } + + if (body?.email === 'julio.iglesias@storyblok.com' && body?.password === 'password') { + return HttpResponse.json({ otp_required: true }) + } + else { + return new HttpResponse('Unauthorized', { status: 401 }) + } + }), +] + +const server = setupServer(...handlers) + +// Start server before all tests +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +// Close server after all tests +afterAll(() => server.close()) + +// Reset handlers after each test `important for test isolation` +afterEach(() => server.resetHandlers()) + +describe('login actions', () => { describe('loginWithToken', () => { it('should login successfully with a valid token', async () => { const mockResponse = { data: 'user data' } - vi.mocked(ofetch).mockResolvedValue(mockResponse) - const result = await loginWithToken('valid-token', 'eu') expect(result).toEqual(mockResponse) }) it('should throw an masked error for invalid token', async () => { - const mockError = { - response: { status: 401 }, - data: { error: 'Unauthorized' }, - } - vi.mocked(ofetch).mockRejectedValue(mockError) - await expect(loginWithToken('invalid-token', 'eu')).rejects.toThrow( - new Error(`The token provided ${chalk.bold('inva*********')} is invalid: ${chalk.bold('401 Unauthorized')} - - Please make sure you are using the correct token and try again.`), + new Error(`The token provided ${chalk.bold('inva*********')} is invalid. + Please make sure you are using the correct token and try again.`), ) }) it('should throw a network error if response is empty (network)', async () => { - const mockError = new Error('Network error') - vi.mocked(ofetch).mockRejectedValue(mockError) - + server.use( + http.get('https://api.storyblok.com/v1/users/me', () => { + return new HttpResponse(null, { status: 500 }) + }), + ) await expect(loginWithToken('any-token', 'eu')).rejects.toThrow( 'No response from server, please check if you are correctly connected to internet', ) @@ -46,40 +68,44 @@ describe('login actions', () => { }) describe('loginWithEmailAndPassword', () => { - it('should login successfully with valid email and password', async () => { - const mockResponse = { data: 'user data' } - vi.mocked(ofetch).mockResolvedValue(mockResponse) - - const result = await loginWithEmailAndPassword('email@example.com', 'password', 'eu') - expect(result).toEqual(mockResponse) + it('should get if the user requires otp', async () => { + const expected = { otp_required: true } + const result = await loginWithEmailAndPassword('julio.iglesias@storyblok.com', 'password', 'eu') + expect(result).toEqual(expected) }) - it('should throw a generic error for login failure', async () => { - const mockError = new Error('Network error') - vi.mocked(ofetch).mockRejectedValue(mockError) + it('should throw an error for invalid email', async () => { + await expect(loginWithEmailAndPassword('invalid-email', 'password', 'eu')).rejects.toThrow( + 'The provided credentials are invalid', + ) + }) - await expect(loginWithEmailAndPassword('email@example.com', 'password', 'eu')).rejects.toThrow( - 'Error logging in with email and password', + it('should throw an error for invalid credentials', async () => { + await expect(loginWithEmailAndPassword('david.bisbal@storyblok.com', 'password', 'eu')).rejects.toThrow( + 'The user is not authorized to access the API', ) }) }) describe('loginWithOtp', () => { it('should login successfully with valid email, password, and otp', async () => { - const mockResponse = { data: 'user data' } - vi.mocked(ofetch).mockResolvedValue(mockResponse) - - const result = await loginWithOtp('email@example.com', 'password', '123456', 'eu') - expect(result).toEqual(mockResponse) - }) + server.use( + http.post('https://api.storyblok.com/v1/users/login', async ({ request }) => { + const body = await request.json() as { email: string, password: string, otp_attempt: string } + if (body?.email === 'julio.iglesias@storyblok.com' && body?.password === 'password' && body?.otp_attempt === '123456') { + return HttpResponse.json({ access_token: 'Awiwi' }) + } + + else { + return new HttpResponse('Unauthorized', { status: 401 }) + } + }), + ) + const expected = { access_token: 'Awiwi' } - it('should throw a generic error for login failure', async () => { - const mockError = new Error('Network error') - vi.mocked(ofetch).mockRejectedValue(mockError) + const result = await loginWithOtp('julio.iglesias@storyblok.com', 'password', '123456', 'eu') - await expect(loginWithOtp('email@example.com', 'password', '123456', 'eu')).rejects.toThrow( - 'Error logging in with email, password and otp', - ) + expect(result).toEqual(expected) }) }) }) diff --git a/src/commands/login/actions.ts b/src/commands/login/actions.ts index a372167a..eb609b68 100644 --- a/src/commands/login/actions.ts +++ b/src/commands/login/actions.ts @@ -18,14 +18,15 @@ export const loginWithToken = async (token: string, region: RegionCode) => { switch (status) { case 401: - throw new APIError('unauthorized', 'login_with_token', error, `The token provided ${chalk.bold(maskToken(token))} is invalid: ${chalk.bold(`401 ${(error as FetchError).data.error}`)} + throw new APIError('unauthorized', 'login_with_token', error, `The token provided ${chalk.bold(maskToken(token))} is invalid. Please make sure you are using the correct token and try again.`) - case 422: - throw new APIError('invalid_credentials', 'login_with_token', error) default: throw new APIError('network_error', 'login_with_token', error) } } + else { + throw new APIError('generic', 'login_with_token', error as Error) + } } } diff --git a/src/creds.ts b/src/creds.ts index b5a55073..62a31fcc 100644 --- a/src/creds.ts +++ b/src/creds.ts @@ -1,6 +1,6 @@ import { access, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import { handleError, konsola } from './utils' +import { FileSystemError, handleFileSystemError, konsola } from './utils' import chalk from 'chalk' import type { RegionCode } from './constants' @@ -55,7 +55,12 @@ const parseNetrcTokens = (tokens: string[]) => { ) { const key = tokens[i] as keyof NetrcMachine const value = tokens[++i] - machineData[key] = value + if (key === 'region') { + machineData[key] = value as RegionCode + } + else { + machineData[key] = value + } i++ } @@ -80,7 +85,7 @@ export const getNetrcCredentials = async (filePath: string = getNetrcFilePath()) await access(filePath) } catch { - console.warn(`.netrc file not found at path: ${filePath}`) + konsola.warn(`.netrc file not found at path: ${filePath}`) return {} } try { @@ -90,7 +95,7 @@ export const getNetrcCredentials = async (filePath: string = getNetrcFilePath()) return machines } catch (error) { - console.error('Error reading or parsing .netrc file:', error) + handleFileSystemError('read', error as NodeJS.ErrnoException) return {} } } @@ -160,7 +165,7 @@ export const addNetrcEntry = async ({ } catch { // File does not exist - console.warn(`.netrc file not found at path: ${filePath}. A new file will be created.`) + konsola.warn(`.netrc file not found at path: ${filePath}. A new file will be created.`) } // Add or update the machine entry @@ -180,8 +185,8 @@ export const addNetrcEntry = async ({ konsola.ok(`Successfully added/updated entry for machine ${machineName} in ${chalk.hex('#45bfb9')(filePath)}`, true) } - catch (error: unknown) { - throw new Error(`Error adding/updating entry for machine ${machineName} in .netrc file: ${(error as Error).message}`) + catch (error) { + throw new FileSystemError('invalid_argument', 'write', error as NodeJS.ErrnoException, `Error adding/updating entry for machine ${machineName} in .netrc file`) } } @@ -202,7 +207,7 @@ export const removeNetrcEntry = async ( } catch { // File does not exist - console.warn(`.netrc file not found at path: ${filePath}. No action taken.`) + konsola.warn(`.netrc file not found at path: ${filePath}. No action taken.`) return } @@ -242,7 +247,7 @@ export async function isAuthorized() { return false } catch (error: unknown) { - handleError(new Error(`Error checking authorization in .netrc file: ${(error as Error).message}`), true) + handleFileSystemError('authorization_check', error as NodeJS.ErrnoException) return false } } diff --git a/src/index.ts b/src/index.ts index fd79cc62..970dc317 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,13 +28,14 @@ program.on('command:*', () => { program.command('test').action(async (options) => { konsola.title(`Test`, '#8556D3', 'Attempting a test...') - try { + konsola.error('This is an error message') + /* try { // await loginWithEmailAndPassword('aw', 'passwrod', 'eu') await loginWithToken('WYSYDHYASDHSYD', 'eu') } catch (error) { handleError(error as Error) - } + } */ }) /* console.log(` diff --git a/src/utils/error/api-error.ts b/src/utils/error/api-error.ts index bf5fe987..5406ba27 100644 --- a/src/utils/error/api-error.ts +++ b/src/utils/error/api-error.ts @@ -12,6 +12,7 @@ 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', } as const export function handleAPIError(action: keyof typeof API_ACTIONS, error: Error): void { @@ -31,7 +32,7 @@ export function handleAPIError(action: keyof typeof API_ACTIONS, error: Error): throw new APIError('network_error', action, error) } else { - throw new APIError('timeout', action, error) + throw new APIError('generic', action, error) } } diff --git a/src/utils/error/error.ts b/src/utils/error/error.ts index 325470b4..f8a8b704 100644 --- a/src/utils/error/error.ts +++ b/src/utils/error/error.ts @@ -11,7 +11,6 @@ export function handleError(error: Error, verbose = false): void { konsola.error(`Command Error: ${error.getInfo().message}`, errorDetails) } else if (error instanceof APIError) { - console.log('error', error) konsola.error(`API Error: ${error.getInfo().cause}`, errorDetails) } else if (error instanceof FileSystemError) { diff --git a/src/utils/error/filesystem-error.ts b/src/utils/error/filesystem-error.ts index fa3e824b..20c2cd0f 100644 --- a/src/utils/error/filesystem-error.ts +++ b/src/utils/error/filesystem-error.ts @@ -1,17 +1,72 @@ const FS_ERRORS = { file_not_found: 'The file requested was not found', permission_denied: 'Permission denied while accessing the file', + operation_on_directory: 'The operation is not allowed on a directory', + not_a_directory: 'The path provided is not a directory', + file_already_exists: 'The file already exists', + directory_not_empty: 'The directory is not empty', + too_many_open_files: 'Too many open files', + no_space_left: 'No space left on the device', + invalid_argument: 'An invalid argument was provided', + unknown_error: 'An unknown error occurred', +} + +const FS_ACTIONS = { + read: 'Failed to read/parse the .netrc file:', + write: 'Writing file', + delete: 'Deleting file', + mkdir: 'Creating directory', + rmdir: 'Removing directory', + authorization_check: 'Failed to check authorization in .netrc file:', +} + +export function handleFileSystemError(action: keyof typeof FS_ACTIONS, error: NodeJS.ErrnoException): void { + if (error.code) { + switch (error.code) { + case 'ENOENT': + throw new FileSystemError('file_not_found', action, error) + case 'EACCES': + case 'EPERM': + throw new FileSystemError('permission_denied', action, error) + case 'EISDIR': + throw new FileSystemError('operation_on_directory', action, error) + case 'ENOTDIR': + throw new FileSystemError('not_a_directory', action, error) + case 'EEXIST': + throw new FileSystemError('file_already_exists', action, error) + case 'ENOTEMPTY': + throw new FileSystemError('directory_not_empty', action, error) + case 'EMFILE': + throw new FileSystemError('too_many_open_files', action, error) + case 'ENOSPC': + throw new FileSystemError('no_space_left', action, error) + case 'EINVAL': + throw new FileSystemError('invalid_argument', action, error) + default: + throw new FileSystemError('unknown_error', action, error) + } + } + else { + // In case the error does not have a known `fs` error code, throw a general error + throw new FileSystemError('unknown_error', action, error) + } } export class FileSystemError extends Error { errorId: string cause: string + code: string | undefined + messageStack: string[] + error: NodeJS.ErrnoException | undefined - constructor(message: string, errorId: keyof typeof FS_ERRORS) { - super(message) + constructor(errorId: keyof typeof FS_ERRORS, action: keyof typeof FS_ACTIONS, error: NodeJS.ErrnoException, customMessage?: string) { + super(customMessage || FS_ERRORS[errorId]) this.name = 'File System Error' this.errorId = errorId this.cause = FS_ERRORS[errorId] + this.code = error.code + this.messageStack = [FS_ACTIONS[action], customMessage || FS_ERRORS[errorId]] + this.error = error } getInfo() { diff --git a/src/utils/konsola.test.ts b/src/utils/konsola.test.ts index 70dcb740..771eb268 100644 --- a/src/utils/konsola.test.ts +++ b/src/utils/konsola.test.ts @@ -49,17 +49,23 @@ describe('konsola', () => { it('should prompt an error message', () => { const consoleSpy = vi.spyOn(console, 'error') - konsola.error(new Error('Oh gosh, this is embarrasing')) - const errorText = `${chalk.red('x')} Oh gosh, this is embarrasing` - expect(consoleSpy).toHaveBeenCalledWith(errorText) + konsola.error('Oh gosh, this is embarrasing') + const errorText = `${chalk.red.bold('▲ error')} Oh gosh, this is embarrasing` + expect(consoleSpy).toHaveBeenCalledWith(errorText, '') }) it('should prompt an error message with header', () => { const consoleSpy = vi.spyOn(console, 'error') - konsola.error(new Error('Oh gosh, this is embarrasing'), true) + konsola.error('Oh gosh, this is embarrasing', null, { header: true }) const errorText = chalk.bgRed.bold.white(` Error `) expect(consoleSpy).toHaveBeenCalledWith(formatHeader(errorText)) }) + + it('should add a line break if margin set to true ', () => { + const consoleSpy = vi.spyOn(console, 'error') + konsola.error('Oh gosh, this is embarrasing', null, { margin: true }) + expect(consoleSpy).toHaveBeenCalledWith('') + }) }) }) diff --git a/src/utils/konsola.ts b/src/utils/konsola.ts index 9e7791c9..5ddb8102 100644 --- a/src/utils/konsola.ts +++ b/src/utils/konsola.ts @@ -12,7 +12,12 @@ export const konsola = { title: (message: string, color: string, subtitle?: string) => { console.log('') // Add a line break console.log('') // Add a line break - console.log(`${formatHeader(chalk.bgHex(color).bold.white(` ${message} `))} ${subtitle || ''}`) + if (subtitle) { + console.log(`${formatHeader(chalk.bgHex(color).bold.white(` ${message} `))} ${subtitle}`) + } + else { + console.log(formatHeader(chalk.bgHex(color).bold.white(` ${message} `))) + } console.log('') // Add a line break console.log('') // Add a line break }, @@ -52,18 +57,15 @@ export const konsola = { console.warn(message ? `${chalk.yellow('⚠️')} ${message}` : '') }, - error: (message: string, info: unknown, options: KonsolaFormatOptions = { - header: false, - margin: true, - }) => { - if (options.header) { + error: (message: string, info?: unknown, options?: KonsolaFormatOptions) => { + if (options?.header) { const errorHeader = chalk.bgRed.bold.white(` Error `) console.error(formatHeader(errorHeader)) console.log('') // Add a line break } console.error(`${chalk.red.bold('▲ error')} ${message}`, info || '') - if (options.margin) { + if (options?.margin) { console.error('') // Add a line break } }, From 2b489276ac08b82b71aa674e1a701cad34483f6a Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 16 Oct 2024 14:47:31 +0200 Subject: [PATCH 13/25] chore: lint and removing unnecesary else --- src/commands/login/actions.test.ts | 10 ++++------ src/utils/error/api-error.ts | 4 +--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/commands/login/actions.test.ts b/src/commands/login/actions.test.ts index 32e74dd6..5f4ceae4 100644 --- a/src/commands/login/actions.test.ts +++ b/src/commands/login/actions.test.ts @@ -10,7 +10,9 @@ const emailRegex = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/ const handlers = [ http.get('https://api.storyblok.com/v1/users/me', async ({ request }) => { const token = request.headers.get('Authorization') - if (token === 'valid-token') { return HttpResponse.json({ data: 'user data' }) } + if (token === 'valid-token') { + return HttpResponse.json({ data: 'user data' }) + } return new HttpResponse('Unauthorized', { status: 401 }) }), http.post('https://api.storyblok.com/v1/users/login', async ({ request }) => { @@ -31,14 +33,10 @@ const handlers = [ const server = setupServer(...handlers) -// Start server before all tests beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) -// Close server after all tests -afterAll(() => server.close()) - -// Reset handlers after each test `important for test isolation` afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) describe('login actions', () => { describe('loginWithToken', () => { diff --git a/src/utils/error/api-error.ts b/src/utils/error/api-error.ts index 5406ba27..cafa7259 100644 --- a/src/utils/error/api-error.ts +++ b/src/utils/error/api-error.ts @@ -31,9 +31,7 @@ export function handleAPIError(action: keyof typeof API_ACTIONS, error: Error): else if (error.message.includes('NetworkError') || error.message.includes('Failed to fetch')) { throw new APIError('network_error', action, error) } - else { - throw new APIError('generic', action, error) - } + throw new APIError('generic', action, error) } export class APIError extends Error { From 4c3582a464f556da11ef3814a2fb307dc6540cd5 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 16 Oct 2024 14:48:49 +0200 Subject: [PATCH 14/25] chore: lint --- src/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 970dc317..29b69e6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,6 @@ import { formatHeader, handleError, konsola } from './utils' import { getProgram } from './program' import './commands/login' import './commands/logout' -import { APIError } from './utils/error/api-error' -import { loginWithEmailAndPassword, loginWithToken } from './commands/login/actions' dotenv.config() // This will load variables from .env into process.env const program = getProgram() @@ -26,7 +24,7 @@ program.on('command:*', () => { konsola.br() // Add a line break }) -program.command('test').action(async (options) => { +program.command('test').action(async () => { konsola.title(`Test`, '#8556D3', 'Attempting a test...') konsola.error('This is an error message') /* try { From 658202e465e9bf955150d65f17a8149c5a088168 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 16 Oct 2024 15:13:03 +0200 Subject: [PATCH 15/25] feat: refactor removeNetrcEntry to make machineName required --- src/creds.test.ts | 2 +- src/creds.ts | 25 ++++++++++--------------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/creds.test.ts b/src/creds.test.ts index eb0f0b17..4a5a2986 100644 --- a/src/creds.test.ts +++ b/src/creds.test.ts @@ -172,7 +172,7 @@ describe('creds', async () => { region eu`, }, '/temp') - await removeNetrcEntry('/temp/test/.netrc') + await removeNetrcEntry('/temp/test/.netrc', 'api.storyblok.com') const content = vol.readFileSync('/temp/test/.netrc', 'utf8') diff --git a/src/creds.ts b/src/creds.ts index 62a31fcc..9a04f4fc 100644 --- a/src/creds.ts +++ b/src/creds.ts @@ -193,7 +193,7 @@ export const addNetrcEntry = async ({ // Function to remove an entry from the .netrc file asynchronously export const removeNetrcEntry = async ( filePath = getNetrcFilePath(), - machineName?: string, + machineName: string, ) => { try { let machines: Record = {} @@ -211,24 +211,19 @@ export const removeNetrcEntry = async ( return } - if (machineName) { + if (machines[machineName]) { // Remove the machine entry delete machines[machineName] - } - else { - // Remove all machine entries - machines = {} - } + // Serialize machines back into .netrc format + const newContent = serializeNetrcMachines(machines) - // Serialize machines back into .netrc format - const newContent = serializeNetrcMachines(machines) + // Write the updated content back to the .netrc file + await writeFile(filePath, newContent, { + mode: 0o600, // Set file permissions + }) - // Write the updated content back to the .netrc file - await writeFile(filePath, newContent, { - mode: 0o600, // Set file permissions - }) - - konsola.ok(`Successfully removed entries from ${chalk.hex('#45bfb9')(filePath)}`, true) + konsola.ok(`Successfully removed entry from ${chalk.hex('#45bfb9')(filePath)}`, true) + } } catch (error: unknown) { throw new Error(`Error removing entry for machine ${machineName} from .netrc file: ${(error as Error).message}`) From c1f0eefd1422a7bfde1bab9f4a2696b9d8b81113 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 16 Oct 2024 15:14:38 +0200 Subject: [PATCH 16/25] feat: change order of `removeNetrcEntry` params --- src/commands/logout/index.ts | 2 +- src/creds.test.ts | 2 +- src/creds.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/logout/index.ts b/src/commands/logout/index.ts index a41f8369..6cdc1652 100644 --- a/src/commands/logout/index.ts +++ b/src/commands/logout/index.ts @@ -15,7 +15,7 @@ export const logoutCommand = program return } try { - await removeNetrcEntry() + await removeNetrcEntry('api.storyblok.com') konsola.ok(`Successfully logged out`) } diff --git a/src/creds.test.ts b/src/creds.test.ts index 4a5a2986..766a89f8 100644 --- a/src/creds.test.ts +++ b/src/creds.test.ts @@ -172,7 +172,7 @@ describe('creds', async () => { region eu`, }, '/temp') - await removeNetrcEntry('/temp/test/.netrc', 'api.storyblok.com') + await removeNetrcEntry('api.storyblok.com', '/temp/test/.netrc') const content = vol.readFileSync('/temp/test/.netrc', 'utf8') diff --git a/src/creds.ts b/src/creds.ts index 9a04f4fc..f394f53e 100644 --- a/src/creds.ts +++ b/src/creds.ts @@ -192,8 +192,8 @@ export const addNetrcEntry = async ({ // Function to remove an entry from the .netrc file asynchronously export const removeNetrcEntry = async ( - filePath = getNetrcFilePath(), machineName: string, + filePath = getNetrcFilePath(), ) => { try { let machines: Record = {} From f7a49105ae8b3765587ba42b2080d4754034a2ad Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 16 Oct 2024 16:29:16 +0200 Subject: [PATCH 17/25] feat: remove casting in favor of runtime checking --- src/constants.ts | 6 +++++- src/creds.ts | 16 ++++++++++------ src/index.ts | 7 ++++--- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 99b5a210..2012db69 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,7 +3,11 @@ export const commands = { LOGOUT: 'logout', } as const -export type RegionCode = 'eu' | 'us' | 'cn' | 'ca' | 'ap' +export interface ReadonlyArray { + includes: (searchElement: any, fromIndex?: number) => searchElement is T +} +export const regionCodes = ['eu', 'us', 'cn', 'ca', 'ap'] as const +export type RegionCode = typeof regionCodes[number] export const regions: Record, RegionCode> = { EU: 'eu', diff --git a/src/creds.ts b/src/creds.ts index f394f53e..a0ccc0f1 100644 --- a/src/creds.ts +++ b/src/creds.ts @@ -2,12 +2,12 @@ import { access, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { FileSystemError, handleFileSystemError, konsola } from './utils' import chalk from 'chalk' -import type { RegionCode } from './constants' +import { regionCodes } from './constants' export interface NetrcMachine { login: string password: string - region: RegionCode + region: string } export const getNetrcFilePath = () => { @@ -36,6 +36,10 @@ const tokenizeNetrcContent = (content: string) => { .filter(token => token.length > 0) } +function includes(coll: ReadonlyArray, el: U): el is T { + return coll.includes(el as T) +} + const parseNetrcTokens = (tokens: string[]) => { const machines: Record = {} let i = 0 @@ -53,12 +57,12 @@ const parseNetrcTokens = (tokens: string[]) => { && tokens[i] !== 'machine' && tokens[i] !== 'default' ) { - const key = tokens[i] as keyof NetrcMachine + const key = tokens[i] const value = tokens[++i] - if (key === 'region') { - machineData[key] = value as RegionCode + if (key === 'region' && includes(regionCodes, value)) { + machineData[key] = value } - else { + else if (key === 'login' || key === 'password') { machineData[key] = value } i++ diff --git a/src/index.ts b/src/index.ts index 29b69e6b..61e6ad40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { formatHeader, handleError, konsola } from './utils' import { getProgram } from './program' import './commands/login' import './commands/logout' +import { loginWithToken } from './commands/login/actions' dotenv.config() // This will load variables from .env into process.env const program = getProgram() @@ -26,14 +27,14 @@ program.on('command:*', () => { program.command('test').action(async () => { konsola.title(`Test`, '#8556D3', 'Attempting a test...') - konsola.error('This is an error message') - /* try { + + try { // await loginWithEmailAndPassword('aw', 'passwrod', 'eu') await loginWithToken('WYSYDHYASDHSYD', 'eu') } catch (error) { handleError(error as Error) - } */ + } }) /* console.log(` From 0201fad609daf974aac80d715cd0333e6371e459 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 16 Oct 2024 16:44:59 +0200 Subject: [PATCH 18/25] feat: verbose mode --- .vscode/launch.json | 11 +++++++++++ src/commands/login/index.ts | 5 +++-- src/commands/logout/index.ts | 4 +++- src/index.ts | 4 ++-- src/utils/error/error.ts | 35 +++++++++++++++++------------------ 5 files changed, 36 insertions(+), 23 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index fe0403e8..cd95b2d7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -34,6 +34,17 @@ "console": "integratedTerminal", "sourceMaps": true, "outFiles": ["${workspaceFolder}/dist/**/*.js"] + }, + { + "type": "node", + "request": "launch", + "name": "Debug test", + "program": "${workspaceFolder}/dist/index.mjs", + "args": ["test", "--verbose"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "outFiles": ["${workspaceFolder}/dist/**/*.js"] } ] } diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index c3492c4e..491d8bf9 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -41,6 +41,7 @@ export const loginCommand = program region: RegionCode }) => { konsola.title(` ${commands.LOGIN} `, '#8556D3') + const verbose = program.opts().verbose 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(' | ')}`)) @@ -64,7 +65,7 @@ export const loginCommand = program konsola.ok(`Successfully logged in with token`) } catch (error) { - handleError(error as Error, true) + handleError(error as Error, verbose) } } else { @@ -123,7 +124,7 @@ export const loginCommand = program } } catch (error) { - handleError(error as Error, true) + handleError(error as Error, verbose) } } }) diff --git a/src/commands/logout/index.ts b/src/commands/logout/index.ts index 6cdc1652..405d633b 100644 --- a/src/commands/logout/index.ts +++ b/src/commands/logout/index.ts @@ -9,6 +9,8 @@ export const logoutCommand = program .command(commands.LOGOUT) .description('Logout from the Storyblok CLI') .action(async () => { + const verbose = program.opts().verbose + const isAuth = await isAuthorized() if (!isAuth) { konsola.ok(`You are already logged out. If you want to login, please use the login command.`) @@ -20,6 +22,6 @@ export const logoutCommand = program konsola.ok(`Successfully logged out`) } catch (error) { - handleError(error as Error, true) + handleError(error as Error, verbose) } }) diff --git a/src/index.ts b/src/index.ts index 61e6ad40..7a6a7df9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,13 +27,13 @@ program.on('command:*', () => { 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') } catch (error) { - handleError(error as Error) + handleError(error as Error, verbose) } }) diff --git a/src/utils/error/error.ts b/src/utils/error/error.ts index f8a8b704..93f22594 100644 --- a/src/utils/error/error.ts +++ b/src/utils/error/error.ts @@ -4,7 +4,21 @@ import { CommandError } from './command-error' import { FileSystemError } from './filesystem-error' export function handleError(error: Error, verbose = false): void { - // If verbose flag is true and the error has getInfo method + // Print the message stack if it exists + if ((error as any).messageStack) { + const messageStack = (error as any).messageStack + messageStack.forEach((message: string, index: number) => { + konsola.error(message, null, { + header: index === 0, + margin: false, + }) + }) + } + else { + konsola.error(error.message, null, { + header: true, + }) + } if (verbose && typeof (error as any).getInfo === 'function') { const errorDetails = (error as any).getInfo() if (error instanceof CommandError) { @@ -21,23 +35,8 @@ export function handleError(error: Error, verbose = false): void { } } else { - // Print the message stack if it exists - if ((error as any).messageStack) { - const messageStack = (error as any).messageStack - messageStack.forEach((message: string, index: number) => { - konsola.error(message, null, { - header: index === 0, - margin: false, - }) - }) - konsola.br() - konsola.info('For more information about the error, run the command with the `--verbose` flag') - } - else { - konsola.error(error.message, null, { - header: true, - }) - } + konsola.br() + konsola.info('For more information about the error, run the command with the `--verbose` flag') } if (!process.env.VITEST) { From 432b64125bc1ba79e4f4bef2daa334021a15a4a1 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Tue, 29 Oct 2024 13:28:51 +0100 Subject: [PATCH 19/25] feat: added correct flow when user doesnt require otp --- src/commands/login/index.ts | 14 ++++++++------ src/session.ts | 3 +++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index 491d8bf9..9a000adf 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -51,7 +51,7 @@ export const loginCommand = program await initializeSession() - if (state.isLoggedIn) { + if (state.isLoggedIn && !state.envLogin) { konsola.ok(`You are already logged in. If you want to login with a different account, please logout first.`) return } @@ -107,9 +107,9 @@ export const loginCommand = program })), default: regions.EU, }) - const { otp_required } = await loginWithEmailAndPassword(userEmail, userPassword, userRegion) + const response = await loginWithEmailAndPassword(userEmail, userPassword, userRegion) - if (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, @@ -117,10 +117,12 @@ export const loginCommand = program const { access_token } = await loginWithOtp(userEmail, userPassword, otp, userRegion) updateSession(userEmail, access_token, userRegion) - await persistCredentials(regionsDomain[userRegion]) - - konsola.ok(`Successfully logged in with email ${chalk.hex('#45bfb9')(userEmail)}`) } + else { + updateSession(userEmail, response.access_token, userRegion) + } + await persistCredentials(regionsDomain[userRegion]) + konsola.ok(`Successfully logged in with email ${chalk.hex('#45bfb9')(userEmail)}`) } } catch (error) { diff --git a/src/session.ts b/src/session.ts index abfbf897..d370e137 100644 --- a/src/session.ts +++ b/src/session.ts @@ -7,6 +7,7 @@ interface SessionState { login?: string password?: string region?: string + envLogin?: boolean } let sessionInstance: ReturnType | null = null @@ -24,6 +25,7 @@ function createSession() { state.login = envCredentials.login state.password = envCredentials.password state.region = envCredentials.region + state.envLogin = true return } @@ -43,6 +45,7 @@ function createSession() { state.password = undefined state.region = undefined } + state.envLogin = false } function getEnvCredentials() { From 72d788f5bea30ce0cb77c7dad2ef86f535ffc4cc Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Tue, 29 Oct 2024 13:35:27 +0100 Subject: [PATCH 20/25] feat: handle user cancelation error when inquirer is prompting --- src/commands/login/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index 9a000adf..044d53ae 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -69,8 +69,8 @@ export const loginCommand = program } } else { - const strategy = await select(loginStrategy) try { + const strategy = await select(loginStrategy) if (strategy === 'login-with-token') { const userToken = await password({ message: 'Please enter your token:', From 1860665981b1dba9a7b184bedc3d5713ea5fa8f9 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Tue, 29 Oct 2024 13:46:32 +0100 Subject: [PATCH 21/25] feat: remove all netrc entries on logout --- src/commands/login/index.test.ts | 6 +++--- src/commands/logout/index.test.ts | 7 ++++--- src/commands/logout/index.ts | 4 ++-- src/creds.test.ts | 18 +++++++++++++++++- src/creds.ts | 6 ++++++ 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/commands/login/index.test.ts b/src/commands/login/index.test.ts index dd730e25..be7fb574 100644 --- a/src/commands/login/index.test.ts +++ b/src/commands/login/index.test.ts @@ -139,7 +139,7 @@ describe('loginCommand', () => { await loginCommand.parseAsync(['node', 'test']) - expect(konsola.error).toHaveBeenCalledWith(mockError, true) + expect(konsola.error).toHaveBeenCalledWith(mockError, false) }) }) @@ -210,7 +210,7 @@ describe('loginCommand', () => { await loginCommand.parseAsync(['node', 'test', '--token', 'invalid-token']) // expect(handleError).toHaveBeenCalledWith(mockError, true) - expect(konsola.error).toHaveBeenCalledWith(mockError, true) + expect(konsola.error).toHaveBeenCalledWith(mockError, false) }) }) @@ -218,7 +218,7 @@ describe('loginCommand', () => { it('should handle invalid region error with correct message', async () => { await loginCommand.parseAsync(['node', 'test', '--region', 'invalid-region']) - expect(konsola.error).toHaveBeenCalledWith(expect.any(Error), true) + expect(konsola.error).toHaveBeenCalledWith(expect.any(Error), false) // Access the error argument const errorArg = vi.mocked(konsola.error).mock.calls[0][0] diff --git a/src/commands/logout/index.test.ts b/src/commands/logout/index.test.ts index 30db71aa..54c01a91 100644 --- a/src/commands/logout/index.test.ts +++ b/src/commands/logout/index.test.ts @@ -1,9 +1,10 @@ -import { isAuthorized, removeNetrcEntry } from '../../creds' +import { isAuthorized, removeAllNetrcEntries } from '../../creds' import { logoutCommand } from './' vi.mock('../../creds', () => ({ isAuthorized: vi.fn(), removeNetrcEntry: vi.fn(), + removeAllNetrcEntries: vi.fn(), })) describe('logoutCommand', () => { @@ -16,12 +17,12 @@ describe('logoutCommand', () => { vi.mocked(isAuthorized).mockResolvedValue(true) await logoutCommand.parseAsync(['node', 'test']) - expect(removeNetrcEntry).toHaveBeenCalled() + expect(removeAllNetrcEntries).toHaveBeenCalled() }) it('should not log out the user if has not previously login', async () => { vi.mocked(isAuthorized).mockResolvedValue(false) await logoutCommand.parseAsync(['node', 'test']) - expect(removeNetrcEntry).not.toHaveBeenCalled() + expect(removeAllNetrcEntries).not.toHaveBeenCalled() }) }) diff --git a/src/commands/logout/index.ts b/src/commands/logout/index.ts index 405d633b..78ea43be 100644 --- a/src/commands/logout/index.ts +++ b/src/commands/logout/index.ts @@ -1,4 +1,4 @@ -import { isAuthorized, removeNetrcEntry } from '../../creds' +import { isAuthorized, removeAllNetrcEntries, removeNetrcEntry } from '../../creds' import { commands } from '../../constants' import { getProgram } from '../../program' import { handleError, konsola } from '../../utils' @@ -17,7 +17,7 @@ export const logoutCommand = program return } try { - await removeNetrcEntry('api.storyblok.com') + await removeAllNetrcEntries() konsola.ok(`Successfully logged out`) } diff --git a/src/creds.test.ts b/src/creds.test.ts index 766a89f8..5d8585a2 100644 --- a/src/creds.test.ts +++ b/src/creds.test.ts @@ -1,4 +1,4 @@ -import { addNetrcEntry, getNetrcCredentials, getNetrcFilePath, isAuthorized, removeNetrcEntry } from './creds' +import { addNetrcEntry, getNetrcCredentials, getNetrcFilePath, isAuthorized, removeAllNetrcEntries, removeNetrcEntry } from './creds' import { vol } from 'memfs' import { join } from 'pathe' // tell vitest to use fs mock from __mocks__ folder @@ -180,6 +180,22 @@ describe('creds', async () => { }) }) + describe('removeAllNetrcEntries', () => { + it('should remove all entries from .netrc file', async () => { + vol.fromJSON({ + 'test/.netrc': `machine api.storyblok.com + login julio.iglesias@storyblok.com + password my_access_token + region eu`, + }, '/temp') + + await removeAllNetrcEntries('/temp/test/.netrc') + + const content = vol.readFileSync('/temp/test/.netrc', 'utf8') + + expect(content).toBe('') + }) + }) describe('isAuthorized', () => { beforeEach(() => { vol.reset() diff --git a/src/creds.ts b/src/creds.ts index a0ccc0f1..69e8b68c 100644 --- a/src/creds.ts +++ b/src/creds.ts @@ -234,6 +234,12 @@ export const removeNetrcEntry = async ( } } +export function removeAllNetrcEntries(filePath = getNetrcFilePath()) { + return writeFile(filePath, '', { + mode: 0o600, // Set file permissions + }) +} + export async function isAuthorized() { try { const machines = await getNetrcCredentials() From c63d38260641eee58003b6b59b7eb27a13f7f4fb Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Tue, 29 Oct 2024 13:50:47 +0100 Subject: [PATCH 22/25] feat: added http response code to api error verbose mode --- src/utils/error/api-error.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/error/api-error.ts b/src/utils/error/api-error.ts index cafa7259..dda384f0 100644 --- a/src/utils/error/api-error.ts +++ b/src/utils/error/api-error.ts @@ -55,6 +55,7 @@ export class APIError extends Error { return { name: this.name, message: this.message, + httpCode: this.code, cause: this.cause, errorId: this.errorId, stack: this.stack, From 3235497ddbde464b5396fade954e6fcd48b88c69 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Tue, 29 Oct 2024 13:55:37 +0100 Subject: [PATCH 23/25] feat: remove unnecesary netrc handling warnings --- src/commands/logout/index.ts | 2 +- src/creds.ts | 16 ++++------------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/commands/logout/index.ts b/src/commands/logout/index.ts index 78ea43be..23e3d758 100644 --- a/src/commands/logout/index.ts +++ b/src/commands/logout/index.ts @@ -1,4 +1,4 @@ -import { isAuthorized, removeAllNetrcEntries, removeNetrcEntry } from '../../creds' +import { isAuthorized, removeAllNetrcEntries } from '../../creds' import { commands } from '../../constants' import { getProgram } from '../../program' import { handleError, konsola } from '../../utils' diff --git a/src/creds.ts b/src/creds.ts index 69e8b68c..a0a8714d 100644 --- a/src/creds.ts +++ b/src/creds.ts @@ -161,16 +161,10 @@ export const addNetrcEntry = async ({ let machines: Record = {} // Check if the file exists - try { - await access(filePath) - // File exists, read and parse it - const content = await readFile(filePath, 'utf8') - machines = parseNetrcContent(content) - } - catch { - // File does not exist - konsola.warn(`.netrc file not found at path: ${filePath}. A new file will be created.`) - } + await access(filePath) + // File exists, read and parse it + const content = await readFile(filePath, 'utf8') + machines = parseNetrcContent(content) // Add or update the machine entry machines[machineName] = { @@ -210,8 +204,6 @@ export const removeNetrcEntry = async ( machines = parseNetrcContent(content) } catch { - // File does not exist - konsola.warn(`.netrc file not found at path: ${filePath}. No action taken.`) return } From c8472d0ceb563ff6ec2ede325edbbfc3a1b119e8 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Tue, 29 Oct 2024 14:15:36 +0100 Subject: [PATCH 24/25] feat: improved error handling for file permissions --- src/commands/logout/index.ts | 11 +++++------ src/creds.ts | 26 ++++++++++++++++++-------- src/utils/error/filesystem-error.ts | 1 + 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/commands/logout/index.ts b/src/commands/logout/index.ts index 23e3d758..5a63a72c 100644 --- a/src/commands/logout/index.ts +++ b/src/commands/logout/index.ts @@ -10,13 +10,12 @@ export const logoutCommand = program .description('Logout from the Storyblok CLI') .action(async () => { const verbose = program.opts().verbose - - const isAuth = await isAuthorized() - if (!isAuth) { - konsola.ok(`You are already logged out. If you want to login, please use the login command.`) - return - } try { + const isAuth = await isAuthorized() + if (!isAuth) { + konsola.ok(`You are already logged out. If you want to login, please use the login command.`) + return + } await removeAllNetrcEntries() konsola.ok(`Successfully logged out`) diff --git a/src/creds.ts b/src/creds.ts index a0a8714d..a4f019ab 100644 --- a/src/creds.ts +++ b/src/creds.ts @@ -89,7 +89,6 @@ export const getNetrcCredentials = async (filePath: string = getNetrcFilePath()) await access(filePath) } catch { - konsola.warn(`.netrc file not found at path: ${filePath}`) return {} } try { @@ -161,10 +160,16 @@ export const addNetrcEntry = async ({ let machines: Record = {} // Check if the file exists - await access(filePath) - // File exists, read and parse it - const content = await readFile(filePath, 'utf8') - machines = parseNetrcContent(content) + try { + await access(filePath) + // File exists, read and parse it + const content = await readFile(filePath, 'utf8') + machines = parseNetrcContent(content) + } + catch { + // File does not exist + konsola.ok(`.netrc file not found at path: ${filePath}. A new file will be created.`) + } // Add or update the machine entry machines[machineName] = { @@ -227,9 +232,14 @@ export const removeNetrcEntry = async ( } export function removeAllNetrcEntries(filePath = getNetrcFilePath()) { - return writeFile(filePath, '', { - mode: 0o600, // Set file permissions - }) + try { + writeFile(filePath, '', { + mode: 0o600, // Set file permissions + }) + } + catch (error) { + handleFileSystemError('write', error as NodeJS.ErrnoException) + } } export async function isAuthorized() { diff --git a/src/utils/error/filesystem-error.ts b/src/utils/error/filesystem-error.ts index 20c2cd0f..ab42bad1 100644 --- a/src/utils/error/filesystem-error.ts +++ b/src/utils/error/filesystem-error.ts @@ -73,6 +73,7 @@ export class FileSystemError extends Error { return { name: this.name, message: this.message, + code: this.code, cause: this.cause, errorId: this.errorId, stack: this.stack, From ef9efb2ea1357f0979dea50404f6d3d25043e964 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Tue, 29 Oct 2024 16:36:27 +0100 Subject: [PATCH 25/25] chore: add LICENSE --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..697cfa22 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Storyblok GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file