From 5854401492742c758fa001c9fb4d632988dfa89e Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 18 Sep 2024 12:17:33 +0200 Subject: [PATCH 01/47] feat: api client logic --- package.json | 4 +-- pnpm-lock.yaml | 8 ++++++ src/api.test.ts | 52 +++++++++++++++++++++++++++++++++++ src/api.ts | 46 +++++++++++++++++++++++++++++++ src/commands/login/actions.ts | 9 +++++- src/commands/login/index.ts | 44 ++++++++++++++++++++++++++--- src/constants.ts | 11 ++++++++ src/index.ts | 1 + vitest.config.ts | 1 + 9 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 src/api.test.ts create mode 100644 src/api.ts diff --git a/package.json b/package.json index 30366907..5c4ca294 100644 --- a/package.json +++ b/package.json @@ -30,13 +30,13 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "vitest" - }, "dependencies": { "chalk": "^5.3.0", "commander": "^12.1.0", "consola": "^3.2.3", - "inquirer": "^10.2.2" + "inquirer": "^10.2.2", + "storyblok-js-client": "^6.9.2" }, "devDependencies": { "@storyblok/eslint-config": "^0.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46dda474..2f430df7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: inquirer: specifier: ^10.2.2 version: 10.2.2 + storyblok-js-client: + specifier: ^6.9.2 + version: 6.9.2 devDependencies: '@storyblok/eslint-config': specifier: ^0.2.0 @@ -2565,6 +2568,9 @@ packages: std-env@3.7.0: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} + storyblok-js-client@6.9.2: + resolution: {integrity: sha512-31GM5X/SIP4eJsSMCpAnaPDRmmUotSSWD3Umnuzf3CGqjyakot2Gv5QmuV23fRM7TCDUQlg5wurROmAzkKMKKg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -5532,6 +5538,8 @@ snapshots: std-env@3.7.0: {} + storyblok-js-client@6.9.2: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 diff --git a/src/api.test.ts b/src/api.test.ts new file mode 100644 index 00000000..94f6a4cd --- /dev/null +++ b/src/api.test.ts @@ -0,0 +1,52 @@ +import { apiClient } from './api' +import StoryblokClient from 'storyblok-js-client'; + +// Mock the StoryblokClient to prevent actual HTTP requests +vi.mock('storyblok-js-client', () => { + const StoryblokClientMock = vi.fn().mockImplementation((config) => { + return { + config, + }; + }); + + return { + default: StoryblokClientMock, + __esModule: true, // Important for ESM modules + }; +}); + +describe('Storyblok API Client', () => { + beforeEach(() => { + // Reset the module state before each test to ensure test isolation + vi.resetModules(); + }); + + it('should have a default region of "eu"', () => { + const { region } = apiClient() + expect(region).toBe('eu') + }) + + it('should return the same client instance when called multiple times without changes', () => { + const api1 = apiClient(); + const client1 = api1.client; + + const api2 = apiClient(); + const client2 = api2.client; + + expect(client1).toBe(client2); + }); + + it('should set the region on the client', () => { + const { setRegion } = apiClient(); + setRegion('us'); + 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'); + }) +}) \ No newline at end of file diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 00000000..1c9ab23f --- /dev/null +++ b/src/api.ts @@ -0,0 +1,46 @@ +import StoryblokClient from "storyblok-js-client"; +import { regions } from "./constants"; + +export interface ApiClientState { + region: string, + accessToken: string, + client: StoryblokClient | null +} + +const state: ApiClientState = { + region: 'eu', + accessToken: '', + client: null +} + +export function apiClient() { + if (!state.client) { + createClient() + } + + function createClient() { + state.client = new StoryblokClient({ + accessToken: state.accessToken, + region: state.region + }) + } + + function setAccessToken(accessToken: string) { + state.accessToken = accessToken + state.client = null + createClient() + } + + function setRegion(region: string) { + state.region = region + state.client = null + createClient() + } + + return { + region: state.region, + client: state.client, + setAccessToken, + setRegion, + } +} \ No newline at end of file diff --git a/src/commands/login/actions.ts b/src/commands/login/actions.ts index 53950f36..e4050c50 100644 --- a/src/commands/login/actions.ts +++ b/src/commands/login/actions.ts @@ -1,4 +1,11 @@ -export const login = () => { + + +export const loginWithToken = () => { // eslint-disable-next-line no-console console.log('Login') } + +export const loginWithEmailAndPassword = () => { + +} + diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index 81e5f95a..bad653d0 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -1,17 +1,53 @@ import chalk from 'chalk' -import { commands } from '../../constants' +import { commands, regions } from '../../constants' import { getProgram } from '../../program' import { formatHeader, handleError } from '../../utils' +import { loginWithToken } from './actions' +import inquirer from 'inquirer' const program = getProgram() // Get the shared singleton instance +const allRegionsText = Object.values(regions).join(", "); +const loginStrategy = [ + { + type: 'list', + name: 'strategy', + 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) .description('Login to the Storyblok CLI') - .action(async () => { - try { + .option("-t, --token ", "Token to login directly without questions, like for CI environments") + .option( + "-r, --region ", + `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 + ) + .option("-ci", '--ci', false) + .action(async (options) => { + if(options.token || options.Ci) { + console.log('CI version') + } else { console.log(formatHeader(chalk.bgHex('#8556D3').bold.white(` ${commands.LOGIN} `))) - /* login() */ + + const { strategy } = await inquirer.prompt(loginStrategy) + console.log(strategy) + } + try { + loginWithToken() } catch (error) { handleError(error as Error) diff --git a/src/constants.ts b/src/constants.ts index e69317c8..3e92cb3d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,14 @@ export const commands = { LOGIN: 'login', } + +export const regions = { + EU: 'eu', + US: 'us', + CN: 'cn' +} + +export const DEFAULT_AGENT = { + SB_Agent: 'SB-CLI', + SB_Agent_Version: process.env.npm_package_version || '4.x' +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index f8235c55..9775cdfd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ const messageText = ` Starting Blok machine... ` console.log(formatHeader(` ${introText} ${messageText}`)) +program.option("-s, --space [value]", "space ID"); program.on('command:*', () => { console.error(`Invalid command: ${program.args.join(' ')}`) diff --git a/vitest.config.ts b/vitest.config.ts index 6aedff33..fd531798 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'vite' export default defineConfig({ test: { + globals: true // ... Specify options here. }, }) From 55f33fa2c8d5253cd6675a7e421d7146212fcafe Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 18 Sep 2024 12:20:33 +0200 Subject: [PATCH 02/47] chore: lint fix --- .github/.dependabot.yml | 10 +++---- .github/ISSUE_TEMPLATE/bug_report.md | 1 - .github/workflows/pkg.pr.new.yml | 4 +-- .vscode/launch.json | 2 +- .vscode/settings.json | 2 +- build.config.ts | 4 +-- eslint.config.js | 8 ++--- src/api.test.ts | 45 ++++++++++++++-------------- src/api.ts | 13 ++++---- src/commands/login/actions.ts | 6 +--- src/commands/login/index.ts | 25 ++++++++-------- src/constants.ts | 6 ++-- src/index.ts | 5 ++-- src/program.test.ts | 2 +- src/utils/error.test.ts | 6 ++-- src/utils/error.ts | 6 ++-- src/utils/index.ts | 10 +++---- src/utils/konsola.test.ts | 23 +++++++------- src/utils/konsola.ts | 18 +++++------ tsconfig.json | 2 +- vitest.config.ts | 2 +- 21 files changed, 95 insertions(+), 105 deletions(-) diff --git a/.github/.dependabot.yml b/.github/.dependabot.yml index 88cb6099..6f39ced4 100644 --- a/.github/.dependabot.yml +++ b/.github/.dependabot.yml @@ -3,11 +3,11 @@ version: 2 updates: - - package-ecosystem: "npm" - directory: "/" + - package-ecosystem: npm + directory: / schedule: - interval: "monthly" + interval: monthly allow: - - dependency-name: "@storyblok/region-helper" + - dependency-name: '@storyblok/region-helper' reviewers: - - "storyblok/plugins-team" \ No newline at end of file + - storyblok/plugins-team diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 95e87e1b..b357b006 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,7 +7,6 @@ assignees: '' --- - **Current behavior:** diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml index e5101b4b..e1400ce7 100644 --- a/.github/workflows/pkg.pr.new.yml +++ b/.github/workflows/pkg.pr.new.yml @@ -30,9 +30,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - cache: "pnpm" + 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 + - run: pnpx pkg-pr-new publish --compact --pnpm diff --git a/.vscode/launch.json b/.vscode/launch.json index 00d4fb04..56ba92c4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -31,4 +31,4 @@ "outFiles": ["${workspaceFolder}/dist/**/*.js"] } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index e2839582..e8202d53 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "editor.formatOnSave": false, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true, + "editor.formatOnSave": true }, "editor.defaultFormatter": "esbenp.prettier-vscode" } diff --git a/build.config.ts b/build.config.ts index 9e330b08..352a5c13 100644 --- a/build.config.ts +++ b/build.config.ts @@ -1,7 +1,7 @@ -import { defineBuildConfig } from 'unbuild'; +import { defineBuildConfig } from 'unbuild' export default defineBuildConfig({ declaration: true, entries: ['./src/index'], externals: ['consola', 'pathe'], -}); \ No newline at end of file +}) diff --git a/eslint.config.js b/eslint.config.js index cfc4570b..7475dab7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,9 +1,7 @@ import { storyblokLintConfig } from '@storyblok/eslint-config' export default storyblokLintConfig({ - rules: [ - { - 'no-console': 'off' - } - ] + rules: { + 'no-console': 'off', + }, }) diff --git a/src/api.test.ts b/src/api.test.ts index 94f6a4cd..c129e8a3 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -1,25 +1,24 @@ import { apiClient } from './api' -import StoryblokClient from 'storyblok-js-client'; // Mock the StoryblokClient to prevent actual HTTP requests vi.mock('storyblok-js-client', () => { const StoryblokClientMock = vi.fn().mockImplementation((config) => { return { config, - }; - }); + } + }) return { default: StoryblokClientMock, __esModule: true, // Important for ESM modules - }; -}); + } +}) -describe('Storyblok API Client', () => { +describe('storyblok API Client', () => { beforeEach(() => { // Reset the module state before each test to ensure test isolation - vi.resetModules(); - }); + vi.resetModules() + }) it('should have a default region of "eu"', () => { const { region } = apiClient() @@ -27,26 +26,26 @@ describe('Storyblok API Client', () => { }) it('should return the same client instance when called multiple times without changes', () => { - const api1 = apiClient(); - const client1 = api1.client; + const api1 = apiClient() + const client1 = api1.client - const api2 = apiClient(); - const client2 = api2.client; + const api2 = apiClient() + const client2 = api2.client - expect(client1).toBe(client2); - }); + expect(client1).toBe(client2) + }) it('should set the region on the client', () => { - const { setRegion } = apiClient(); - setRegion('us'); - const { region } = apiClient(); - expect(region).toBe('us'); + const { setRegion } = apiClient() + setRegion('us') + 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'); + const { setAccessToken } = apiClient() + setAccessToken('test-token') + const { client } = apiClient() + expect(client.config.accessToken).toBe('test-token') }) -}) \ No newline at end of file +}) diff --git a/src/api.ts b/src/api.ts index 1c9ab23f..4887c755 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,16 +1,15 @@ -import StoryblokClient from "storyblok-js-client"; -import { regions } from "./constants"; +import StoryblokClient from 'storyblok-js-client' export interface ApiClientState { - region: string, - accessToken: string, + region: string + accessToken: string client: StoryblokClient | null } const state: ApiClientState = { region: 'eu', accessToken: '', - client: null + client: null, } export function apiClient() { @@ -21,7 +20,7 @@ export function apiClient() { function createClient() { state.client = new StoryblokClient({ accessToken: state.accessToken, - region: state.region + region: state.region, }) } @@ -43,4 +42,4 @@ export function apiClient() { setAccessToken, setRegion, } -} \ No newline at end of file +} diff --git a/src/commands/login/actions.ts b/src/commands/login/actions.ts index e4050c50..6f3543de 100644 --- a/src/commands/login/actions.ts +++ b/src/commands/login/actions.ts @@ -1,11 +1,7 @@ - - export const loginWithToken = () => { - // eslint-disable-next-line no-console console.log('Login') } export const loginWithEmailAndPassword = () => { - -} +} diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index bad653d0..a2582504 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -7,7 +7,7 @@ import inquirer from 'inquirer' const program = getProgram() // Get the shared singleton instance -const allRegionsText = Object.values(regions).join(", "); +const allRegionsText = Object.values(regions).join(', ') const loginStrategy = [ { type: 'list', @@ -17,30 +17,31 @@ const loginStrategy = [ { name: 'With email', value: 'login-with-email', - short: 'Email' + short: 'Email', }, { name: 'With Token (SSO)', value: 'login-with-token', - short: 'Token' - } - ] - } + short: 'Token', + }, + ], + }, ] export const loginCommand = program .command(commands.LOGIN) .description('Login to the Storyblok CLI') - .option("-t, --token ", "Token to login directly without questions, like for CI environments") + .option('-t, --token ', 'Token to login directly without questions, like for CI environments') .option( - "-r, --region ", + '-r, --region ', `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 + regions.EU, ) - .option("-ci", '--ci', false) + .option('-ci', '--ci', false) .action(async (options) => { - if(options.token || options.Ci) { + if (options.token || options.Ci) { console.log('CI version') - } else { + } + else { console.log(formatHeader(chalk.bgHex('#8556D3').bold.white(` ${commands.LOGIN} `))) const { strategy } = await inquirer.prompt(loginStrategy) diff --git a/src/constants.ts b/src/constants.ts index 3e92cb3d..5715e9d0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,10 +5,10 @@ export const commands = { export const regions = { EU: 'eu', US: 'us', - CN: 'cn' + CN: 'cn', } export const DEFAULT_AGENT = { SB_Agent: 'SB-CLI', - SB_Agent_Version: process.env.npm_package_version || '4.x' -} \ No newline at end of file + SB_Agent_Version: process.env.npm_package_version || '4.x', +} diff --git a/src/index.ts b/src/index.ts index 9775cdfd..d1e7a10c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import chalk from 'chalk' -import { __dirname, formatHeader, handleError } from './utils' +import { formatHeader, handleError } from './utils' import { getProgram } from './program' import './commands/login' @@ -11,7 +11,7 @@ const messageText = ` Starting Blok machine... ` console.log(formatHeader(` ${introText} ${messageText}`)) -program.option("-s, --space [value]", "space ID"); +program.option('-s, --space [value]', 'space ID') program.on('command:*', () => { console.error(`Invalid command: ${program.args.join(' ')}`) @@ -24,4 +24,3 @@ try { catch (error) { handleError(error as Error) } - diff --git a/src/program.test.ts b/src/program.test.ts index 7b3afeb2..303a4f81 100644 --- a/src/program.test.ts +++ b/src/program.test.ts @@ -1,5 +1,5 @@ // program.test.ts -import { beforeAll, describe, expect, it, vi } from 'vitest' +import { beforeAll, describe, expect, it } from 'vitest' // Import the function after setting up mocks import { getProgram } from './program' // Import resolve to mock diff --git a/src/utils/error.test.ts b/src/utils/error.test.ts index 83df6c40..f1d6fcf9 100644 --- a/src/utils/error.test.ts +++ b/src/utils/error.test.ts @@ -1,5 +1,5 @@ -import { handleError } from "./error" -import { beforeAll, describe, expect, it, vi } from 'vitest' +import { handleError } from './error' +import { describe, expect, it, vi } from 'vitest' describe('error handling', () => { it('should prompt an error message', () => { @@ -7,4 +7,4 @@ describe('error handling', () => { handleError(new Error('This is an error')) expect(consoleSpy).toBeCalled() }) -}) \ No newline at end of file +}) diff --git a/src/utils/error.ts b/src/utils/error.ts index a0afffba..5e9840ff 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -1,7 +1,7 @@ -import { konsola } from '../utils'; +import { konsola } from '../utils' export function handleError(error: Error): void { - konsola.error(error) + konsola.error(error) // TODO: add conditional to detect if this runs on tests /* process.exit(1); */ -} \ No newline at end of file +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 0c466e3b..d81283c1 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,7 +1,7 @@ -import { fileURLToPath } from 'node:url'; -import { dirname } from 'pathe'; +import { fileURLToPath } from 'node:url' +import { dirname } from 'pathe' -export * from './error'; +export * from './error' export * from './konsola' -export const __filename = fileURLToPath(import.meta.url); -export const __dirname = dirname(__filename); \ No newline at end of file +export const __filename = fileURLToPath(import.meta.url) +export const __dirname = dirname(__filename) diff --git a/src/utils/konsola.test.ts b/src/utils/konsola.test.ts index 02e4b3c0..d51f68b7 100644 --- a/src/utils/konsola.test.ts +++ b/src/utils/konsola.test.ts @@ -1,21 +1,20 @@ -import chalk from "chalk" -import { konsola, formatHeader } from "./konsola" -import { beforeAll, describe, expect, it, vi } from 'vitest' - +import chalk from 'chalk' +import { formatHeader, konsola } from './konsola' +import { describe, expect, it, vi } from 'vitest' describe('konsola', () => { describe('success', () => { it('should prompt an success message', () => { const consoleSpy = vi.spyOn(console, 'log') - konsola.ok('Component A created succesfully') + konsola.ok('Component A created succesfully') expect(consoleSpy).toHaveBeenCalledWith(`${chalk.green('✔')} Component A created succesfully`) }) - + it('should prompt an success message with header', () => { const consoleSpy = vi.spyOn(console, 'log') konsola.ok('Component A created succesfully', true) - const successText = chalk.bgGreen.bold.white(` Success `) + const successText = chalk.bgGreen.bold.white(` Success `) expect(consoleSpy).toHaveBeenCalledWith(formatHeader(successText)) }) @@ -24,16 +23,16 @@ describe('konsola', () => { it('should prompt an error message', () => { const consoleSpy = vi.spyOn(console, 'error') - konsola.error(new Error('Oh gosh, this is embarrasing')) + konsola.error(new Error('Oh gosh, this is embarrasing')) expect(consoleSpy).toHaveBeenCalledWith(chalk.red(`Oh gosh, this is embarrasing`)) }) - + it('should prompt an error message with header', () => { const consoleSpy = vi.spyOn(console, 'error') konsola.error(new Error('Oh gosh, this is embarrasing'), true) - const errorText = chalk.bgRed.bold.white(` Error `) - + const errorText = chalk.bgRed.bold.white(` Error `) + expect(consoleSpy).toHaveBeenCalledWith(formatHeader(errorText)) }) }) -}) \ No newline at end of file +}) diff --git a/src/utils/konsola.ts b/src/utils/konsola.ts index 165f5506..5cea8f1b 100644 --- a/src/utils/konsola.ts +++ b/src/utils/konsola.ts @@ -1,26 +1,26 @@ import chalk from 'chalk' -export function formatHeader(title:string) { +export function formatHeader(title: string) { return `${title} ` } export const konsola = { - + ok: (message?: string, header: boolean = false) => { - if(header) { - const successHeader = chalk.bgGreen.bold.white(` Success `) + if (header) { + const successHeader = chalk.bgGreen.bold.white(` Success `) console.log(formatHeader(successHeader)) } console.log(message ? `${chalk.green('✔')} ${message}` : '') }, error: (err: Error, header: boolean = false) => { - if(header) { - const errorHeader = chalk.bgRed.bold.white(` Error `) + if (header) { + const errorHeader = chalk.bgRed.bold.white(` Error `) console.error(formatHeader(errorHeader)) } - console.error(chalk.red(err.message || err)); - } -} \ No newline at end of file + console.error(chalk.red(err.message || err)) + }, +} diff --git a/tsconfig.json b/tsconfig.json index 40b6b55a..c5f9a27f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ /* Bundler mode */ "moduleResolution": "bundler", "resolveJsonModule": true, - "types": [ "node", "vitest/globals"], + "types": ["node", "vitest/globals"], "allowImportingTsExtensions": true, /* Linting */ diff --git a/vitest.config.ts b/vitest.config.ts index fd531798..7528fea2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'vite' export default defineConfig({ test: { - globals: true + globals: true, // ... Specify options here. }, }) From 81047b7ead371922c73738edf6eacb2b6d8c4610 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Mon, 23 Sep 2024 12:48:30 +0200 Subject: [PATCH 03/47] feat: netrc credentials logic --- __mocks__/fs.cjs | 6 ++ __mocks__/fs/promises.cjs | 6 ++ __mocks__/test.netrc | 4 + package.json | 1 + pnpm-lock.yaml | 74 +++++++++++++++ src/commands/login/actions.ts | 19 +++- src/commands/login/index.ts | 15 +-- src/creds.test.ts | 166 ++++++++++++++++++++++++++++++++++ src/creds.ts | 165 +++++++++++++++++++++++++++++++++ src/utils/error.ts | 4 +- src/utils/konsola.ts | 3 + 11 files changed, 452 insertions(+), 11 deletions(-) create mode 100644 __mocks__/fs.cjs create mode 100644 __mocks__/fs/promises.cjs create mode 100644 __mocks__/test.netrc create mode 100644 src/creds.test.ts create mode 100644 src/creds.ts diff --git a/__mocks__/fs.cjs b/__mocks__/fs.cjs new file mode 100644 index 00000000..1d156260 --- /dev/null +++ b/__mocks__/fs.cjs @@ -0,0 +1,6 @@ +// we can also use `import`, but then +// every export should be explicitly defined + +const { fs } = require('memfs') + +module.exports = fs diff --git a/__mocks__/fs/promises.cjs b/__mocks__/fs/promises.cjs new file mode 100644 index 00000000..9fa31bcf --- /dev/null +++ b/__mocks__/fs/promises.cjs @@ -0,0 +1,6 @@ +// we can also use `import`, but then +// every export should be explicitly defined + +const { fs } = require('memfs') + +module.exports = fs.promises diff --git a/__mocks__/test.netrc b/__mocks__/test.netrc new file mode 100644 index 00000000..1967a0c9 --- /dev/null +++ b/__mocks__/test.netrc @@ -0,0 +1,4 @@ +machine api.storyblok.com + login julio.iglesias@storyblok.com + password my_access_token + region eu \ No newline at end of file diff --git a/package.json b/package.json index 5c4ca294..eda1e257 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@types/inquirer": "^9.0.7", "@types/node": "^22.5.4", "eslint": "^9.10.0", + "memfs": "^4.11.2", "pathe": "^1.1.2", "typescript": "^5.6.2", "unbuild": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f430df7..33c079c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: eslint: specifier: ^9.10.0 version: 9.10.0(jiti@1.21.6) + memfs: + specifier: ^4.11.2 + version: 4.11.2 pathe: specifier: ^1.1.2 version: 1.1.2 @@ -754,6 +757,24 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jsonjoy.com/base64@1.1.2': + resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@1.1.0': + resolution: {integrity: sha512-zlQONA+msXPPwHWZMKFVS78ewFczIll5lXiVPwFPCZUsrOKdxc2AvxU1HoNBmMRhqDZUR9HkC3UOm+6pME6Xsg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@1.3.0': + resolution: {integrity: sha512-Cebt4Vk7k1xHy87kHY7KSPLT77A7Ev7IfOblyLZhtYEhrdQ6fX4EoLq3xOQ3O/DRMEh2ok5nyC180E+ABS8Wmw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1756,6 +1777,10 @@ packages: hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + hyperdyperid@1.2.0: + resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} + engines: {node: '>=10.18'} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -1969,6 +1994,10 @@ packages: mdn-data@2.0.30: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + memfs@4.11.2: + resolution: {integrity: sha512-VcR7lEtgQgv7AxGkrNNeUAimFLT+Ov8uGu1LuOfbe/iF/dKoh/QgpoaMZlhfejvLtMxtXYyeoT7Ar1jEbWdbPA==} + engines: {node: '>= 4.0.0'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2625,6 +2654,12 @@ packages: text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + thingies@1.21.0: + resolution: {integrity: sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==} + engines: {node: '>=10.18'} + peerDependencies: + tslib: ^2 + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2659,6 +2694,12 @@ packages: resolution: {integrity: sha512-khrZo4buq4qVmsGzS5yQjKe/WsFvV8fGfOjDQN0q4iy9FjRfPWRgTFrU8u1R2iu/SfWLhY9WnCi4Jhdrcbtg+g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + tree-dump@1.0.2: + resolution: {integrity: sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + ts-api-utils@1.3.0: resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} engines: {node: '>=16'} @@ -3451,6 +3492,22 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jsonjoy.com/base64@1.1.2(tslib@2.7.0)': + dependencies: + tslib: 2.7.0 + + '@jsonjoy.com/json-pack@1.1.0(tslib@2.7.0)': + dependencies: + '@jsonjoy.com/base64': 1.1.2(tslib@2.7.0) + '@jsonjoy.com/util': 1.3.0(tslib@2.7.0) + hyperdyperid: 1.2.0 + thingies: 1.21.0(tslib@2.7.0) + tslib: 2.7.0 + + '@jsonjoy.com/util@1.3.0(tslib@2.7.0)': + dependencies: + tslib: 2.7.0 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4618,6 +4675,8 @@ snapshots: hosted-git-info@2.8.9: {} + hyperdyperid@1.2.0: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -4869,6 +4928,13 @@ snapshots: mdn-data@2.0.30: {} + memfs@4.11.2: + dependencies: + '@jsonjoy.com/json-pack': 1.1.0(tslib@2.7.0) + '@jsonjoy.com/util': 1.3.0(tslib@2.7.0) + tree-dump: 1.0.2(tslib@2.7.0) + tslib: 2.7.0 + merge2@1.4.1: {} micromark-core-commonmark@2.0.1: @@ -5595,6 +5661,10 @@ snapshots: text-table@0.2.0: {} + thingies@1.21.0(tslib@2.7.0): + dependencies: + tslib: 2.7.0 + tinybench@2.9.0: {} tinyexec@0.3.0: {} @@ -5619,6 +5689,10 @@ snapshots: dependencies: eslint-visitor-keys: 3.4.3 + tree-dump@1.0.2(tslib@2.7.0): + dependencies: + tslib: 2.7.0 + ts-api-utils@1.3.0(typescript@5.6.2): dependencies: typescript: 5.6.2 diff --git a/src/commands/login/actions.ts b/src/commands/login/actions.ts index 6f3543de..21e8b60e 100644 --- a/src/commands/login/actions.ts +++ b/src/commands/login/actions.ts @@ -1,5 +1,20 @@ -export const loginWithToken = () => { - console.log('Login') +import { regions } from '../../constants' +import { addNetrcEntry, getNetrcCredentials } from '../../creds' + +export const loginWithToken = async () => { + try { + await addNetrcEntry({ + machineName: 'api.storyblok.com', + login: 'julio.iglesias@storyblok.com', + password: 'my_access_token', + region: regions.EU, + }) + const file = await getNetrcCredentials() + console.log(file) + } + catch (error) { + console.error('Error reading or parsing .netrc file:', error) + } } export const loginWithEmailAndPassword = () => { diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index a2582504..5c0fede2 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -45,12 +45,13 @@ export const loginCommand = program console.log(formatHeader(chalk.bgHex('#8556D3').bold.white(` ${commands.LOGIN} `))) const { strategy } = await inquirer.prompt(loginStrategy) - console.log(strategy) - } - try { - loginWithToken() - } - catch (error) { - handleError(error as Error) + try { + if (strategy === 'login-with-token') { + loginWithToken() + } + } + catch (error) { + handleError(error as Error) + } } }) diff --git a/src/creds.test.ts b/src/creds.test.ts new file mode 100644 index 00000000..eb08b4ad --- /dev/null +++ b/src/creds.test.ts @@ -0,0 +1,166 @@ +import { machine } from 'node:os' +import { addNetrcEntry, getNetrcCredentials, getNetrcFilePath } from './creds' +import { fs, vol } from 'memfs' +import { join } from 'pathe' +// tell vitest to use fs mock from __mocks__ folder +// this can be done in a setup file if fs should always be mocked +vi.mock('node:fs') +vi.mock('node:fs/promises') + +beforeEach(() => { + // reset the state of in-memory fs + vol.reset() +}) + +describe('creds', async () => { + describe('getNetrcFilePath', async () => { + const originalPlatform = process.platform + const originalEnv = { ...process.env } + const originalCwd = process.cwd + + beforeEach(() => { + process.env = { ...originalEnv } + }) + + afterEach(() => { + // Restore the original platform after each test + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }) + // Restore process.cwd() + process.cwd = originalCwd + }) + + it('should return the correct path on Unix-like systems when HOME is set', () => { + // Mock the platform to be Unix-like + Object.defineProperty(process, 'platform', { + value: 'linux', + }) + + // Set the HOME environment variable + process.env.HOME = '/home/testuser' + + const expectedPath = join('/home/testuser', '.netrc') + const result = getNetrcFilePath() + + expect(result).toBe(expectedPath) + }) + + it('should return the correct path on Windows systems when USERPROFILE is set', () => { + // Mock the platform to be Windows + Object.defineProperty(process, 'platform', { + value: 'win32', + }) + + // Set the USERPROFILE environment variable + process.env.USERPROFILE = 'C:/Users/TestUser' + + const expectedPath = join('C:/Users/TestUser', '.netrc') + const result = getNetrcFilePath() + + expect(result).toBe(expectedPath) + }) + + it('should use process.cwd() when home directory is not set', () => { + // Mock the platform to be Unix-like + Object.defineProperty(process, 'platform', { + value: 'linux', + }) + + // Remove HOME and USERPROFILE + delete process.env.HOME + delete process.env.USERPROFILE + + // Mock process.cwd() + process.cwd = vi.fn().mockReturnValue('/current/working/directory') + + const expectedPath = join('/current/working/directory', '.netrc') + const result = getNetrcFilePath() + + expect(result).toBe(expectedPath) + }) + + it('should use process.cwd() when HOME is empty', () => { + // Mock the platform to be Unix-like + Object.defineProperty(process, 'platform', { + value: 'linux', + }) + + // Set HOME to an empty string + process.env.HOME = '' + + // Mock process.cwd() + process.cwd = vi.fn().mockReturnValue('/current/working/directory') + + const expectedPath = join('/current/working/directory', '.netrc') + const result = getNetrcFilePath() + + expect(result).toBe(expectedPath) + }) + + it('should handle Windows platform when USERPROFILE is not set', () => { + // Mock the platform to be Windows + Object.defineProperty(process, 'platform', { + value: 'win32', + }) + + // Remove USERPROFILE + delete process.env.USERPROFILE + + // Mock process.cwd() + process.cwd = vi.fn().mockReturnValue('C:/Current/Directory') + + const expectedPath = join('C:/Current/Directory', '.netrc') + const result = getNetrcFilePath() + + expect(result).toBe(expectedPath) + }) + }) + + describe('getNetrcCredentials', () => { + it('should return empty object if .netrc file does not exist', async () => { + const creds = await getNetrcCredentials() + expect(creds).toEqual({}) + }) + it('should return the parsed content of .netrc file', async () => { + vol.fromJSON({ + 'test/.netrc': `machine api.storyblok.com + login julio.iglesias@storyblok.com + password my_access_token + region eu`, + }, '/temp') + + const credentials = await getNetrcCredentials('/temp/test/.netrc') + + expect(credentials['api.storyblok.com']).toEqual({ + login: 'julio.iglesias@storyblok.com', + password: 'my_access_token', + region: 'eu', + }) + }) + }) + + describe('addNetrcEntry', () => { + it('should add a new entry to an empty .netrc file', async () => { + vol.fromJSON({ + 'test/.netrc': '', + }, '/temp') + + await addNetrcEntry({ + filePath: '/temp/test/.netrc', + machineName: 'api.storyblok.com', + login: 'julio.iglesias@storyblok.com', + password: 'my_access_token', + region: 'eu', + }) + + const content = vol.readFileSync('/temp/test/.netrc', 'utf8') + + expect(content).toBe(`machine api.storyblok.com + login julio.iglesias@storyblok.com + password my_access_token + region eu +`) + }) + }) +}) diff --git a/src/creds.ts b/src/creds.ts new file mode 100644 index 00000000..1ab68588 --- /dev/null +++ b/src/creds.ts @@ -0,0 +1,165 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { handleError, konsola } from './utils' +import chalk from 'chalk' + +export interface NetrcMachine { + login: string + password: string + region: string +} + +export const getNetrcFilePath = () => { + const homeDirectory = process.env[ + process.platform.startsWith('win') ? 'USERPROFILE' : 'HOME' + ] || process.cwd() + + return path.join(homeDirectory, '.netrc') +} + +const readNetrcFileAsync = async (filePath: string) => { + return await fs.readFile(filePath, 'utf8') +} + +const preprocessNetrcContent = (content: string) => { + return content + .split('\n') + .map(line => line.split('#')[0].trim()) + .filter(line => line.length > 0) + .join(' ') +} + +const tokenizeNetrcContent = (content: string) => { + return content + .split(/\s+/) + .filter(token => token.length > 0) +} + +const parseNetrcTokens = (tokens: string[]) => { + const machines: Record = {} + let i = 0 + + while (i < tokens.length) { + const token = tokens[i] + + if (token === 'machine' || token === 'default') { + const machineName = token === 'default' ? 'default' : tokens[++i] + const machineData: Partial = {} + i++ + + while ( + i < tokens.length + && tokens[i] !== 'machine' + && tokens[i] !== 'default' + ) { + const key = tokens[i] as keyof NetrcMachine + const value = tokens[++i] + machineData[key] = value + i++ + } + + machines[machineName] = machineData as NetrcMachine + } + else { + i++ + } + } + + return machines +} + +const parseNetrcContent = (content: string) => { + const preprocessedContent = preprocessNetrcContent(content) + const tokens = tokenizeNetrcContent(preprocessedContent) + return parseNetrcTokens(tokens) +} + +export const getNetrcCredentials = async (filePath: string = getNetrcFilePath()) => { + try { + try { + await fs.access(filePath) + } + catch { + console.warn(`.netrc file not found at path: ${filePath}`) + return {} + } + + const content = await readNetrcFileAsync(filePath) + + const machines = parseNetrcContent(content) + return machines + } + catch (error) { + console.error('Error reading or parsing .netrc file:', error) + return {} + } +} + +export const getCredentialsForMachine = (machines: Record = {}, machineName: string) => { + if (machines[machineName]) { + return machines[machineName] + } + else if (machines.default) { + return machines.default + } + else { + return null + } +} + +// Function to serialize machines object back into .netrc format +const serializeNetrcMachines = (machines: Record = {}) => { + let content = '' + for (const [machineName, properties] of Object.entries(machines)) { + content += `machine ${machineName}\n` + for (const [key, value] of Object.entries(properties)) { + content += ` ${key} ${value}\n` + } + } + return content +} + +// Function to add or update an entry in the .netrc file asynchronously +export const addNetrcEntry = async ({ + filePath = getNetrcFilePath(), + machineName, + login, + password, + region, +}: Record) => { + try { + let machines: Record = {} + + // Check if the file exists + try { + await fs.access(filePath) + // File exists, read and parse it + const content = await fs.readFile(filePath, 'utf8') + machines = parseNetrcContent(content) + } + catch { + // File does not exist + console.warn(`.netrc file not found at path: ${filePath}. A new file will be created.`) + } + + // Add or update the machine entry + machines[machineName] = { + login, + password, + region, + } as NetrcMachine + + // Serialize machines back into .netrc format + const newContent = serializeNetrcMachines(machines) + + // Write the updated content back to the .netrc file + await fs.writeFile(filePath, newContent, { + mode: 0o600, // Set file permissions + }) + + konsola.ok(`Successfully added/updated entry for machine ${machineName} in ${chalk.hex('#45bfb9')(filePath)}`, true) + } + catch (error: any) { + handleError(new Error(`Error adding/updating entry for machine ${machineName} in .netrc file: ${error.message}`), true) + } +} diff --git a/src/utils/error.ts b/src/utils/error.ts index 5e9840ff..b5c7ca75 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -1,7 +1,7 @@ import { konsola } from '../utils' -export function handleError(error: Error): void { - konsola.error(error) +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); */ } diff --git a/src/utils/konsola.ts b/src/utils/konsola.ts index 5cea8f1b..ad899ffb 100644 --- a/src/utils/konsola.ts +++ b/src/utils/konsola.ts @@ -9,6 +9,7 @@ export const konsola = { ok: (message?: string, header: boolean = false) => { if (header) { + console.log('') // Add a line break const successHeader = chalk.bgGreen.bold.white(` Success `) console.log(formatHeader(successHeader)) } @@ -17,10 +18,12 @@ export const konsola = { }, 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(chalk.red(err.message || err)) + console.log('') // Add a line break }, } From 316ce74c8bd355e284901fb161d200ec686f6c36 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Mon, 23 Sep 2024 12:50:30 +0200 Subject: [PATCH 04/47] chore: remove unused imports on creds tests --- src/creds.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/creds.test.ts b/src/creds.test.ts index eb08b4ad..70051776 100644 --- a/src/creds.test.ts +++ b/src/creds.test.ts @@ -1,6 +1,5 @@ -import { machine } from 'node:os' import { addNetrcEntry, getNetrcCredentials, getNetrcFilePath } from './creds' -import { fs, vol } from 'memfs' +import { vol } from 'memfs' import { join } from 'pathe' // tell vitest to use fs mock from __mocks__ folder // this can be done in a setup file if fs should always be mocked From 193e1b68aca4428f4c33511683b3b73394e8da64 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Mon, 23 Sep 2024 12:58:35 +0200 Subject: [PATCH 05/47] feat: migrate to inquirer/prompts --- package.json | 2 +- pnpm-lock.yaml | 197 ++++++++++++++++-------------------- src/commands/login/index.ts | 14 ++- 3 files changed, 95 insertions(+), 118 deletions(-) diff --git a/package.json b/package.json index eda1e257..0fa2aca2 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,10 @@ "test": "vitest" }, "dependencies": { + "@inquirer/prompts": "^6.0.1", "chalk": "^5.3.0", "commander": "^12.1.0", "consola": "^3.2.3", - "inquirer": "^10.2.2", "storyblok-js-client": "^6.9.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33c079c6..9dcc0033 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@inquirer/prompts': + specifier: ^6.0.1 + version: 6.0.1 chalk: specifier: ^5.3.0 version: 5.3.0 @@ -17,9 +20,6 @@ importers: consola: specifier: ^3.2.3 version: 3.2.3 - inquirer: - specifier: ^10.2.2 - version: 10.2.2 storyblok-js-client: specifier: ^6.9.2 version: 6.9.2 @@ -683,60 +683,60 @@ packages: resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} engines: {node: '>=18.18'} - '@inquirer/checkbox@2.5.0': - resolution: {integrity: sha512-sMgdETOfi2dUHT8r7TT1BTKOwNvdDGFDXYWtQ2J69SvlYNntk9I/gJe7r5yvMwwsuKnYbuRs3pNhx4tgNck5aA==} + '@inquirer/checkbox@3.0.1': + resolution: {integrity: sha512-0hm2nrToWUdD6/UHnel/UKGdk1//ke5zGUpHIvk5ZWmaKezlGxZkOJXNSWsdxO/rEqTkbB3lNC2J6nBElV2aAQ==} engines: {node: '>=18'} - '@inquirer/confirm@3.2.0': - resolution: {integrity: sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw==} + '@inquirer/confirm@4.0.1': + resolution: {integrity: sha512-46yL28o2NJ9doViqOy0VDcoTzng7rAb6yPQKU7VDLqkmbCaH4JqK4yk4XqlzNWy9PVC5pG1ZUXPBQv+VqnYs2w==} engines: {node: '>=18'} - '@inquirer/core@9.1.0': - resolution: {integrity: sha512-RZVfH//2ytTjmaBIzeKT1zefcQZzuruwkpTwwbe/i2jTl4o9M+iML5ChULzz6iw1Ok8iUBBsRCjY2IEbD8Ft4w==} + '@inquirer/core@9.2.1': + resolution: {integrity: sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==} engines: {node: '>=18'} - '@inquirer/editor@2.2.0': - resolution: {integrity: sha512-9KHOpJ+dIL5SZli8lJ6xdaYLPPzB8xB9GZItg39MBybzhxA16vxmszmQFrRwbOA918WA2rvu8xhDEg/p6LXKbw==} + '@inquirer/editor@3.0.1': + resolution: {integrity: sha512-VA96GPFaSOVudjKFraokEEmUQg/Lub6OXvbIEZU1SDCmBzRkHGhxoFAVaF30nyiB4m5cEbDgiI2QRacXZ2hw9Q==} engines: {node: '>=18'} - '@inquirer/expand@2.3.0': - resolution: {integrity: sha512-qnJsUcOGCSG1e5DTOErmv2BPQqrtT6uzqn1vI/aYGiPKq+FgslGZmtdnXbhuI7IlT7OByDoEEqdnhUnVR2hhLw==} + '@inquirer/expand@3.0.1': + resolution: {integrity: sha512-ToG8d6RIbnVpbdPdiN7BCxZGiHOTomOX94C2FaT5KOHupV40tKEDozp12res6cMIfRKrXLJyexAZhWVHgbALSQ==} engines: {node: '>=18'} - '@inquirer/figures@1.0.5': - resolution: {integrity: sha512-79hP/VWdZ2UVc9bFGJnoQ/lQMpL74mGgzSYX1xUqCVk7/v73vJCMw1VuyWN1jGkZ9B3z7THAbySqGbCNefcjfA==} + '@inquirer/figures@1.0.6': + resolution: {integrity: sha512-yfZzps3Cso2UbM7WlxKwZQh2Hs6plrbjs1QnzQDZhK2DgyCo6D8AaHps9olkNcUFlcYERMqU3uJSp1gmy3s/qQ==} engines: {node: '>=18'} - '@inquirer/input@2.3.0': - resolution: {integrity: sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw==} + '@inquirer/input@3.0.1': + resolution: {integrity: sha512-BDuPBmpvi8eMCxqC5iacloWqv+5tQSJlUafYWUe31ow1BVXjW2a5qe3dh4X/Z25Wp22RwvcaLCc2siHobEOfzg==} engines: {node: '>=18'} - '@inquirer/number@1.1.0': - resolution: {integrity: sha512-ilUnia/GZUtfSZy3YEErXLJ2Sljo/mf9fiKc08n18DdwdmDbOzRcTv65H1jjDvlsAuvdFXf4Sa/aL7iw/NanVA==} + '@inquirer/number@2.0.1': + resolution: {integrity: sha512-QpR8jPhRjSmlr/mD2cw3IR8HRO7lSVOnqUvQa8scv1Lsr3xoAMMworcYW3J13z3ppjBFBD2ef1Ci6AE5Qn8goQ==} engines: {node: '>=18'} - '@inquirer/password@2.2.0': - resolution: {integrity: sha512-5otqIpgsPYIshqhgtEwSspBQE40etouR8VIxzpJkv9i0dVHIpyhiivbkH9/dGiMLdyamT54YRdGJLfl8TFnLHg==} + '@inquirer/password@3.0.1': + resolution: {integrity: sha512-haoeEPUisD1NeE2IanLOiFr4wcTXGWrBOyAyPZi1FfLJuXOzNmxCJPgUrGYKVh+Y8hfGJenIfz5Wb/DkE9KkMQ==} engines: {node: '>=18'} - '@inquirer/prompts@5.5.0': - resolution: {integrity: sha512-BHDeL0catgHdcHbSFFUddNzvx/imzJMft+tWDPwTm3hfu8/tApk1HrooNngB2Mb4qY+KaRWF+iZqoVUPeslEog==} + '@inquirer/prompts@6.0.1': + resolution: {integrity: sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==} engines: {node: '>=18'} - '@inquirer/rawlist@2.3.0': - resolution: {integrity: sha512-zzfNuINhFF7OLAtGHfhwOW2TlYJyli7lOUoJUXw/uyklcwalV6WRXBXtFIicN8rTRK1XTiPWB4UY+YuW8dsnLQ==} + '@inquirer/rawlist@3.0.1': + resolution: {integrity: sha512-VgRtFIwZInUzTiPLSfDXK5jLrnpkuSOh1ctfaoygKAdPqjcjKYmGh6sCY1pb0aGnCGsmhUxoqLDUAU0ud+lGXQ==} engines: {node: '>=18'} - '@inquirer/search@1.1.0': - resolution: {integrity: sha512-h+/5LSj51dx7hp5xOn4QFnUaKeARwUCLs6mIhtkJ0JYPBLmEYjdHSYh7I6GrLg9LwpJ3xeX0FZgAG1q0QdCpVQ==} + '@inquirer/search@2.0.1': + resolution: {integrity: sha512-r5hBKZk3g5MkIzLVoSgE4evypGqtOannnB3PKTG9NRZxyFRKcfzrdxXXPcoJQsxJPzvdSU2Rn7pB7lw0GCmGAg==} engines: {node: '>=18'} - '@inquirer/select@2.5.0': - resolution: {integrity: sha512-YmDobTItPP3WcEI86GvPo+T2sRHkxxOq/kXmsBjHS5BVXUgvgZ5AfJjkvQvZr03T81NnI3KrrRuMzeuYUQRFOA==} + '@inquirer/select@3.0.1': + resolution: {integrity: sha512-lUDGUxPhdWMkN/fHy1Lk7pF3nK1fh/gqeyWXmctefhxLYxlDsc7vsPBEpxrfVGDsVdyYJsiJoD4bJ1b623cV1Q==} engines: {node: '>=18'} - '@inquirer/type@1.5.3': - resolution: {integrity: sha512-xUQ14WQGR/HK5ei+2CvgcwoH9fQ4PgPGmVFSN0pc1+fVyDL3MREhyAY7nxEErSu6CkllBM3D7e3e+kOvtu+eIg==} + '@inquirer/type@2.0.0': + resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==} engines: {node: '>=18'} '@jridgewell/gen-mapping@0.3.5': @@ -961,6 +961,9 @@ packages: '@types/node@22.5.4': resolution: {integrity: sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==} + '@types/node@22.5.5': + resolution: {integrity: sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -1219,10 +1222,6 @@ packages: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -1808,10 +1807,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - inquirer@10.2.2: - resolution: {integrity: sha512-tyao/4Vo36XnUItZ7DnUXX4f1jVao2mSrleV/5IPtW/XAEA26hRVsbc68nuTEKWcr5vMP/1mVoT2O7u8H4v1Vg==} - engines: {node: '>=18'} - is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -2511,10 +2506,6 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - run-async@3.0.0: - resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} - engines: {node: '>=0.12.0'} - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -3378,28 +3369,27 @@ snapshots: '@humanwhocodes/retry@0.3.0': {} - '@inquirer/checkbox@2.5.0': + '@inquirer/checkbox@3.0.1': dependencies: - '@inquirer/core': 9.1.0 - '@inquirer/figures': 1.0.5 - '@inquirer/type': 1.5.3 + '@inquirer/core': 9.2.1 + '@inquirer/figures': 1.0.6 + '@inquirer/type': 2.0.0 ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 - '@inquirer/confirm@3.2.0': + '@inquirer/confirm@4.0.1': dependencies: - '@inquirer/core': 9.1.0 - '@inquirer/type': 1.5.3 + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 - '@inquirer/core@9.1.0': + '@inquirer/core@9.2.1': dependencies: - '@inquirer/figures': 1.0.5 - '@inquirer/type': 1.5.3 + '@inquirer/figures': 1.0.6 + '@inquirer/type': 2.0.0 '@types/mute-stream': 0.0.4 - '@types/node': 22.5.4 + '@types/node': 22.5.5 '@types/wrap-ansi': 3.0.0 ansi-escapes: 4.3.2 - cli-spinners: 2.9.2 cli-width: 4.1.0 mute-stream: 1.0.0 signal-exit: 4.1.0 @@ -3407,71 +3397,71 @@ snapshots: wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.2 - '@inquirer/editor@2.2.0': + '@inquirer/editor@3.0.1': dependencies: - '@inquirer/core': 9.1.0 - '@inquirer/type': 1.5.3 + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 external-editor: 3.1.0 - '@inquirer/expand@2.3.0': + '@inquirer/expand@3.0.1': dependencies: - '@inquirer/core': 9.1.0 - '@inquirer/type': 1.5.3 + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 yoctocolors-cjs: 2.1.2 - '@inquirer/figures@1.0.5': {} + '@inquirer/figures@1.0.6': {} - '@inquirer/input@2.3.0': + '@inquirer/input@3.0.1': dependencies: - '@inquirer/core': 9.1.0 - '@inquirer/type': 1.5.3 + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 - '@inquirer/number@1.1.0': + '@inquirer/number@2.0.1': dependencies: - '@inquirer/core': 9.1.0 - '@inquirer/type': 1.5.3 + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 - '@inquirer/password@2.2.0': + '@inquirer/password@3.0.1': dependencies: - '@inquirer/core': 9.1.0 - '@inquirer/type': 1.5.3 + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 ansi-escapes: 4.3.2 - '@inquirer/prompts@5.5.0': + '@inquirer/prompts@6.0.1': dependencies: - '@inquirer/checkbox': 2.5.0 - '@inquirer/confirm': 3.2.0 - '@inquirer/editor': 2.2.0 - '@inquirer/expand': 2.3.0 - '@inquirer/input': 2.3.0 - '@inquirer/number': 1.1.0 - '@inquirer/password': 2.2.0 - '@inquirer/rawlist': 2.3.0 - '@inquirer/search': 1.1.0 - '@inquirer/select': 2.5.0 + '@inquirer/checkbox': 3.0.1 + '@inquirer/confirm': 4.0.1 + '@inquirer/editor': 3.0.1 + '@inquirer/expand': 3.0.1 + '@inquirer/input': 3.0.1 + '@inquirer/number': 2.0.1 + '@inquirer/password': 3.0.1 + '@inquirer/rawlist': 3.0.1 + '@inquirer/search': 2.0.1 + '@inquirer/select': 3.0.1 - '@inquirer/rawlist@2.3.0': + '@inquirer/rawlist@3.0.1': dependencies: - '@inquirer/core': 9.1.0 - '@inquirer/type': 1.5.3 + '@inquirer/core': 9.2.1 + '@inquirer/type': 2.0.0 yoctocolors-cjs: 2.1.2 - '@inquirer/search@1.1.0': + '@inquirer/search@2.0.1': dependencies: - '@inquirer/core': 9.1.0 - '@inquirer/figures': 1.0.5 - '@inquirer/type': 1.5.3 + '@inquirer/core': 9.2.1 + '@inquirer/figures': 1.0.6 + '@inquirer/type': 2.0.0 yoctocolors-cjs: 2.1.2 - '@inquirer/select@2.5.0': + '@inquirer/select@3.0.1': dependencies: - '@inquirer/core': 9.1.0 - '@inquirer/figures': 1.0.5 - '@inquirer/type': 1.5.3 + '@inquirer/core': 9.2.1 + '@inquirer/figures': 1.0.6 + '@inquirer/type': 2.0.0 ansi-escapes: 4.3.2 yoctocolors-cjs: 2.1.2 - '@inquirer/type@1.5.3': + '@inquirer/type@2.0.0': dependencies: mute-stream: 1.0.0 @@ -3683,6 +3673,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/node@22.5.5': + dependencies: + undici-types: 6.19.8 + '@types/normalize-package-data@2.4.4': {} '@types/resolve@1.20.2': {} @@ -3977,8 +3971,6 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 - cli-spinners@2.9.2: {} - cli-width@4.1.0: {} cliui@8.0.1: @@ -4699,17 +4691,6 @@ snapshots: inherits@2.0.4: {} - inquirer@10.2.2: - dependencies: - '@inquirer/core': 9.1.0 - '@inquirer/prompts': 5.5.0 - '@inquirer/type': 1.5.3 - '@types/mute-stream': 0.0.4 - ansi-escapes: 4.3.2 - mute-stream: 1.0.0 - run-async: 3.0.0 - rxjs: 7.8.1 - is-arrayish@0.2.1: {} is-builtin-module@3.2.1: @@ -5535,8 +5516,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.21.3 fsevents: 2.3.3 - run-async@3.0.0: {} - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index 5c0fede2..0d7e562a 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -3,15 +3,14 @@ import { commands, regions } from '../../constants' import { getProgram } from '../../program' import { formatHeader, handleError } from '../../utils' import { loginWithToken } from './actions' -import inquirer from 'inquirer' +import { select } from '@inquirer/prompts' const program = getProgram() // Get the shared singleton instance const allRegionsText = Object.values(regions).join(', ') -const loginStrategy = [ - { - type: 'list', - name: 'strategy', + +const loginStrategy + = { message: 'How would you like to login?', choices: [ { @@ -25,8 +24,7 @@ const loginStrategy = [ short: 'Token', }, ], - }, -] + } export const loginCommand = program .command(commands.LOGIN) .description('Login to the Storyblok CLI') @@ -44,7 +42,7 @@ export const loginCommand = program else { console.log(formatHeader(chalk.bgHex('#8556D3').bold.white(` ${commands.LOGIN} `))) - const { strategy } = await inquirer.prompt(loginStrategy) + const strategy = await select(loginStrategy) try { if (strategy === 'login-with-token') { loginWithToken() From 303b24818e5c7b32965192522b428dd6906e88c8 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Mon, 23 Sep 2024 13:15:26 +0200 Subject: [PATCH 06/47] feat: handling path for package.json on tests --- build.config.ts | 1 + src/program.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/build.config.ts b/build.config.ts index 352a5c13..4d5f0091 100644 --- a/build.config.ts +++ b/build.config.ts @@ -4,4 +4,5 @@ export default defineBuildConfig({ declaration: true, entries: ['./src/index'], externals: ['consola', 'pathe'], + failOnWarn: false, }) diff --git a/src/program.ts b/src/program.ts index 5a493043..302c64b2 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, '../../package.json') +const packageJsonPath = resolve(__dirname, process.env.VITEST ? '../../package.json' : '../package.json') const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) // Declare a variable to hold the singleton instance From 7254e74514cbff7fa93e626eedb42365faf68966 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 25 Sep 2024 16:53:05 +0200 Subject: [PATCH 07/47] feat: check region on login command --- src/commands/login/index.ts | 13 +++++++++++-- src/constants.ts | 2 ++ src/utils/index.ts | 5 +++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index 0d7e562a..38958745 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -1,7 +1,7 @@ import chalk from 'chalk' import { commands, regions } from '../../constants' import { getProgram } from '../../program' -import { formatHeader, handleError } from '../../utils' +import { formatHeader, handleError, isRegion, konsola } from '../../utils' import { loginWithToken } from './actions' import { select } from '@inquirer/prompts' @@ -36,9 +36,14 @@ export const loginCommand = program ) .option('-ci', '--ci', false) .action(async (options) => { - if (options.token || options.Ci) { + const { token, Ci, region } = options + + if (token || Ci) { console.log('CI version') } + 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) + } else { console.log(formatHeader(chalk.bgHex('#8556D3').bold.white(` ${commands.LOGIN} `))) @@ -47,6 +52,10 @@ export const loginCommand = program if (strategy === 'login-with-token') { loginWithToken() } + + else { + console.log('Not implemented yet') + } } catch (error) { handleError(error as Error) diff --git a/src/constants.ts b/src/constants.ts index 5715e9d0..0decc8cf 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -6,6 +6,8 @@ export const regions = { EU: 'eu', US: 'us', CN: 'cn', + CA: 'ca', + AP: 'ap', } export const DEFAULT_AGENT = { diff --git a/src/utils/index.ts b/src/utils/index.ts index d81283c1..ebecde4c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,7 +1,12 @@ import { fileURLToPath } from 'node:url' import { dirname } from 'pathe' +import { regions } from '../constants' export * from './error' 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 { + return Object.values(regions).includes(value) +} From 56960ee87d491e2372360d9f8bc5da69dcb056f3 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Thu, 26 Sep 2024 11:39:20 +0200 Subject: [PATCH 08/47] feat: login with token and with email --- package.json | 1 + pnpm-lock.yaml | 22 +++++++++ src/commands/login/actions.ts | 41 ++++++++++++----- src/commands/login/index.ts | 87 ++++++++++++++++++++++++++++++----- src/constants.ts | 24 ++++++++++ src/program.ts | 2 +- 6 files changed, 153 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index 0fa2aca2..513e5f4c 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "chalk": "^5.3.0", "commander": "^12.1.0", "consola": "^3.2.3", + "ofetch": "^1.4.0", "storyblok-js-client": "^6.9.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9dcc0033..4804fb91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: consola: specifier: ^3.2.3 version: 3.2.3 + ofetch: + specifier: ^1.4.0 + version: 1.4.0 storyblok-js-client: specifier: ^6.9.2 version: 6.9.2 @@ -1367,6 +1370,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.3: + resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -2140,6 +2146,9 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-fetch-native@1.6.4: + resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} + node-releases@2.0.18: resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} @@ -2153,6 +2162,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + ofetch@1.4.0: + resolution: {integrity: sha512-MuHgsEhU6zGeX+EMh+8mSMrYTnsqJQQrpM00Q6QHMKNqQ0bKy0B43tk8tL1wg+CnsSTy1kg4Ir2T5Ig6rD+dfQ==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -4115,6 +4127,8 @@ snapshots: dequal@2.0.3: {} + destr@2.0.3: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -5165,6 +5179,8 @@ snapshots: natural-compare@1.4.0: {} + node-fetch-native@1.6.4: {} + node-releases@2.0.18: {} normalize-package-data@2.5.0: @@ -5180,6 +5196,12 @@ snapshots: dependencies: boolbase: 1.0.0 + ofetch@1.4.0: + dependencies: + destr: 2.0.3 + node-fetch-native: 1.6.4 + ufo: 1.5.4 + once@1.4.0: dependencies: wrappy: 1.0.2 diff --git a/src/commands/login/actions.ts b/src/commands/login/actions.ts index 21e8b60e..ef78a588 100644 --- a/src/commands/login/actions.ts +++ b/src/commands/login/actions.ts @@ -1,22 +1,39 @@ -import { regions } from '../../constants' -import { addNetrcEntry, getNetrcCredentials } from '../../creds' +import { regionsDomain } from '../../constants' +import { ofetch } from 'ofetch' -export const loginWithToken = async () => { +export const loginWithToken = async (token: string, region: string) => { try { - await addNetrcEntry({ - machineName: 'api.storyblok.com', - login: 'julio.iglesias@storyblok.com', - password: 'my_access_token', - region: regions.EU, + return await ofetch(`https://${regionsDomain[region]}/v1/users/me`, { + headers: { + Authorization: token, + }, }) - const file = await getNetrcCredentials() - console.log(file) } catch (error) { - console.error('Error reading or parsing .netrc file:', error) + throw new Error('Error logging with token', error) } } -export const loginWithEmailAndPassword = () => { +export const loginWithEmailAndPassword = async (email: string, password: string, region: string) => { + try { + return await ofetch(`https://${regionsDomain[region]}/v1/users/login`, { + method: 'POST', + body: JSON.stringify({ email, password }), + }) + } + catch (error) { + throw new Error('Error logging in with email and password', error) + } +} +export const loginWithOtp = async (email: string, password: string, otp: string, region: string) => { + try { + return await ofetch(`https://${regionsDomain[region]}/v1/users/login`, { + method: 'POST', + body: JSON.stringify({ email, password, otp_attempt: otp }), + }) + } + catch (error) { + throw new Error('Error logging in with email, password and otp', error) + } } diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index 38958745..3d487d00 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -1,14 +1,14 @@ import chalk from 'chalk' -import { commands, regions } from '../../constants' +import { input, password, select } from '@inquirer/prompts' +import { commands, regionNames, regions, regionsDomain } from '../../constants' import { getProgram } from '../../program' import { formatHeader, handleError, isRegion, konsola } from '../../utils' -import { loginWithToken } from './actions' -import { select } from '@inquirer/prompts' +import { loginWithEmailAndPassword, loginWithOtp, loginWithToken } from './actions' +import { addNetrcEntry } from '../../creds' const program = getProgram() // Get the shared singleton instance -const allRegionsText = Object.values(regions).join(', ') - +const allRegionsText = Object.values(regions).join(',') const loginStrategy = { message: 'How would you like to login?', @@ -37,24 +37,89 @@ export const loginCommand = program .option('-ci', '--ci', false) .action(async (options) => { const { token, Ci, region } = options - - if (token || Ci) { - console.log('CI version') - } 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) } + + if (token || Ci) { + try { + const { user } = await loginWithToken(token, region) + await addNetrcEntry({ + machineName: regionsDomain[region], + login: user.email, + password: token, + region, + }) + konsola.ok(`Successfully logged in with token`) + } + catch (error) { + handleError(error as Error) + } + } else { console.log(formatHeader(chalk.bgHex('#8556D3').bold.white(` ${commands.LOGIN} `))) const strategy = await select(loginStrategy) try { if (strategy === 'login-with-token') { - loginWithToken() + const userToken = await password({ + message: 'Please enter your token:', + validate: (value: string) => { + return value.length > 0 + }, + }) + + const { user } = await loginWithToken(userToken, region) + + await addNetrcEntry({ + machineName: regionsDomain[region], + login: user.email, + password: userToken, + region, + }) + + konsola.ok(`Successfully logged in with token`) } else { - console.log('Not implemented yet') + const userEmail = await input({ + message: 'Please enter your email address:', + required: true, + validate: (value: string) => { + const emailRegex = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/ + return emailRegex.test(value) + }, + }) + const userPassword = await password({ + message: 'Please enter your password:', + }) + const userRegion = await select({ + message: 'Please select the region you would like to work in:', + choices: Object.values(regions).map(region => ({ + name: regionNames[region], + value: region, + })), + default: regions.EU, + }) + const { otp_required } = await loginWithEmailAndPassword(userEmail, userPassword, userRegion as string) + + if (otp_required) { + const otp = await input({ + message: 'We sent a code to your email / phone, please insert the authentication code:', + required: true, + }) + + const { access_token } = await loginWithOtp(userEmail, userPassword, otp, userRegion as string) + + await addNetrcEntry({ + machineName: regionsDomain[userRegion], + login: userEmail, + password: access_token, + region: userRegion, + }) + + konsola.ok(`Successfully logged in with email ${chalk.hex('#45bfb9')(userEmail)}`, true) + } } } catch (error) { diff --git a/src/constants.ts b/src/constants.ts index 0decc8cf..deb11335 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,6 +10,30 @@ export const regions = { AP: 'ap', } +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', +} + +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', +} + +export const regionNames = { + eu: 'Europe', + us: 'United States', + cn: 'China', + ca: 'Canada', + ap: 'Australia', +} + export const DEFAULT_AGENT = { SB_Agent: 'SB-CLI', SB_Agent_Version: process.env.npm_package_version || '4.x', diff --git a/src/program.ts b/src/program.ts index 302c64b2..e42ce34c 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 ? '../../package.json' : '../../package.json') const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) // Declare a variable to hold the singleton instance From c908176346f8f70727f2ae9557294f72bf249894 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 2 Oct 2024 09:19:55 +0200 Subject: [PATCH 09/47] feat: improved error handling --- .vscode/launch.json | 6 +++--- src/commands/login/actions.ts | 14 +++++++++++--- src/commands/login/index.ts | 4 ++-- src/constants.ts | 12 ++++++------ src/index.ts | 10 +++++++++- src/utils/index.ts | 11 +++++++++++ src/utils/konsola.test.ts | 3 ++- src/utils/konsola.ts | 2 +- 8 files changed, 45 insertions(+), 17 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 56ba92c4..cd2fa555 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -22,9 +22,9 @@ { "type": "node", "request": "launch", - "name": "Debug pull-components", - "program": "${workspaceFolder}/dist/cli.mjs", - "args": ["push-components", "components.295017.json", "--space", "295018"], + "name": "Debug login", + "program": "${workspaceFolder}/dist/index.mjs", + "args": ["login", "--token", "295018"], "cwd": "${workspaceFolder}", "console": "integratedTerminal", "sourceMaps": true, diff --git a/src/commands/login/actions.ts b/src/commands/login/actions.ts index ef78a588..02b7ca97 100644 --- a/src/commands/login/actions.ts +++ b/src/commands/login/actions.ts @@ -1,5 +1,8 @@ +import chalk from 'chalk' 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) => { try { @@ -10,7 +13,12 @@ export const loginWithToken = async (token: string, region: string) => { }) } catch (error) { - throw new Error('Error logging with token', 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}`)} + + Please make sure you are using the correct token and try again.`) + } + throw new Error('Error logging with token', error as Error) } } @@ -22,7 +30,7 @@ export const loginWithEmailAndPassword = async (email: string, password: string, }) } catch (error) { - throw new Error('Error logging in with email and password', error) + throw new Error('Error logging in with email and password', error as Error) } } @@ -34,6 +42,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) + throw new Error('Error logging in with email, password and otp', error as Error) } } diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index 3d487d00..4bd25ce5 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -53,7 +53,7 @@ export const loginCommand = program konsola.ok(`Successfully logged in with token`) } catch (error) { - handleError(error as Error) + handleError(error as Error, true) } } else { @@ -123,7 +123,7 @@ export const loginCommand = program } } catch (error) { - handleError(error as Error) + handleError(error as Error, true) } } }) diff --git a/src/constants.ts b/src/constants.ts index deb11335..d30f772d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,8 +1,8 @@ -export const commands = { +export const commands: Record = { LOGIN: 'login', } -export const regions = { +export const regions: Record = { EU: 'eu', US: 'us', CN: 'cn', @@ -10,7 +10,7 @@ export const regions = { AP: 'ap', } -export const regionsDomain = { +export const regionsDomain: Record = { eu: 'api.storyblok.com', us: 'api-us.storyblok.com', cn: 'app.storyblokchina.cn', @@ -18,7 +18,7 @@ export const regionsDomain = { ap: 'api-ap.storyblok.com', } -export const managementApiRegions = { +export const managementApiRegions: Record = { eu: 'mapi.storyblok.com', us: 'mapi-us.storyblok.com', cn: 'mapi.storyblokchina.cn', @@ -26,7 +26,7 @@ export const managementApiRegions = { ap: 'mapi-ap.storyblok.com', } -export const regionNames = { +export const regionNames: Record = { eu: 'Europe', us: 'United States', cn: 'China', @@ -34,7 +34,7 @@ export const regionNames = { ap: 'Australia', } -export const DEFAULT_AGENT = { +export const DEFAULT_AGENT: Record = { SB_Agent: 'SB-CLI', SB_Agent_Version: process.env.npm_package_version || '4.x', } diff --git a/src/index.ts b/src/index.ts index d1e7a10c..7d912043 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import './commands/login' const program = getProgram() console.clear() const introText = chalk.bgHex('#45bfb9').bold.black(` Storyblok CLI `) -const messageText = ` Starting Blok machine... ` +const messageText = ` ` console.log(formatHeader(` ${introText} ${messageText}`)) @@ -18,6 +18,14 @@ program.on('command:*', () => { program.help() }) +/* console.log(` +${chalk.hex('#45bfb9')(' ─────╮')} +${chalk.hex('#45bfb9')('│ │')} +${chalk.hex('#45bfb9')('│')} ◠ ◡ ◠ +${chalk.hex('#45bfb9')('|_ __|')} +${chalk.hex('#45bfb9')(' |/ ')} +`) */ + try { program.parse(process.argv) } diff --git a/src/utils/index.ts b/src/utils/index.ts index ebecde4c..f8a74def 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -10,3 +10,14 @@ export const __dirname = dirname(__filename) export function isRegion(value: string): value is keyof typeof regions { return Object.values(regions).includes(value) } + +export function maskToken(token: string): string { + // Show only the first 4 characters and replace the rest with asterisks + if (token.length <= 4) { + // If the token is too short, just return it as is + return token + } + const visiblePart = token.slice(0, 4) + const maskedPart = '*'.repeat(token.length - 4) + return `${visiblePart}${maskedPart}` +} diff --git a/src/utils/konsola.test.ts b/src/utils/konsola.test.ts index d51f68b7..36d9017f 100644 --- a/src/utils/konsola.test.ts +++ b/src/utils/konsola.test.ts @@ -24,7 +24,8 @@ describe('konsola', () => { const consoleSpy = vi.spyOn(console, 'error') konsola.error(new Error('Oh gosh, this is embarrasing')) - expect(consoleSpy).toHaveBeenCalledWith(chalk.red(`Oh gosh, this is embarrasing`)) + const errorText = `${chalk.red('x')} Oh gosh, this is embarrasing` + expect(consoleSpy).toHaveBeenCalledWith(errorText) }) it('should prompt an error message with header', () => { diff --git a/src/utils/konsola.ts b/src/utils/konsola.ts index ad899ffb..a0db1ebc 100644 --- a/src/utils/konsola.ts +++ b/src/utils/konsola.ts @@ -23,7 +23,7 @@ export const konsola = { console.error(formatHeader(errorHeader)) } - console.error(chalk.red(err.message || err)) + console.error(`${chalk.red('x')} ${err.message || err}`) console.log('') // Add a line break }, } From fe4733da6b523fe9b5a4a035310a6896101eb070 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 2 Oct 2024 10:03:13 +0200 Subject: [PATCH 10/47] tests: test coverage for login actions --- .vscode/launch.json | 20 +++---- src/commands/login/actions.test.ts | 89 ++++++++++++++++++++++++++++++ src/commands/login/actions.ts | 2 +- 3 files changed, 97 insertions(+), 14 deletions(-) create mode 100644 src/commands/login/actions.test.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index cd2fa555..1eb4483b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,27 +4,21 @@ { "type": "node", "request": "launch", - "name": "Debug Jest Tests", - "runtimeArgs": [ - "--experimental-vm-modules" - ], - "args": [ - "--silent", - "--runInBand" - ], + "name": "Debug Vitest Tests", + "program": "${workspaceFolder}/node_modules/vitest/vitest.mjs", + "args": ["run"], + "autoAttachChildProcesses": true, + "smartStep": true, "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", - "program": "${workspaceFolder}/node_modules/.bin/jest", - "windows": { - "program": "${workspaceFolder}\\node_modules\\jest\\bin\\jest.js" - } + "skipFiles": ["/**"] }, { "type": "node", "request": "launch", "name": "Debug login", "program": "${workspaceFolder}/dist/index.mjs", - "args": ["login", "--token", "295018"], + "args": ["login", "--token", "invalidt-token"], "cwd": "${workspaceFolder}", "console": "integratedTerminal", "sourceMaps": true, diff --git a/src/commands/login/actions.test.ts b/src/commands/login/actions.test.ts new file mode 100644 index 00000000..c12ff7b6 --- /dev/null +++ b/src/commands/login/actions.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest' +import { loginWithEmailAndPassword, loginWithOtp, loginWithToken } from './actions' +import { ofetch } from 'ofetch' +import chalk from 'chalk' + +vi.mock('ofetch', () => ({ + ofetch: vi.fn(), +})) + +vi.mock('../../utils', () => ({ + maskToken: (token: string) => token.replace(/.(?=.{4})/g, '*'), +})) + +describe('login actions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('loginWithToken', () => { + it('should login successfully with a valid token', async () => { + const mockResponse = { data: 'user data' } + 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' }, + } + ofetch.mockRejectedValue(mockError) + + await expect(loginWithToken('invalid-token', 'eu')).rejects.toThrow( + new Error(`The token provided ${chalk.bold('*********oken')} is invalid: ${chalk.bold('401 Unauthorized')} + + Please make sure you are using the correct token and try again.`), + ) + }) + + it('should throw a generic error for other issues', async () => { + const mockError = new Error('Network error') + ofetch.mockRejectedValue(mockError) + + await expect(loginWithToken('any-token', 'eu')).rejects.toThrow( + 'Error logging with token', + ) + }) + }) + + describe('loginWithEmailAndPassword', () => { + it('should login successfully with valid email and password', async () => { + const mockResponse = { data: 'user data' } + ofetch.mockResolvedValue(mockResponse) + + const result = await loginWithEmailAndPassword('email@example.com', 'password', 'eu') + expect(result).toEqual(mockResponse) + }) + + it('should throw a generic error for login failure', async () => { + const mockError = new Error('Network error') + ofetch.mockRejectedValue(mockError) + + await expect(loginWithEmailAndPassword('email@example.com', 'password', 'eu')).rejects.toThrow( + 'Error logging in with email and password', + ) + }) + }) + + describe('loginWithOtp', () => { + it('should login successfully with valid email, password, and otp', async () => { + const mockResponse = { data: 'user data' } + ofetch.mockResolvedValue(mockResponse) + + const result = await loginWithOtp('email@example.com', 'password', '123456', 'eu') + expect(result).toEqual(mockResponse) + }) + + it('should throw a generic error for login failure', async () => { + const mockError = new Error('Network error') + 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/actions.ts b/src/commands/login/actions.ts index 02b7ca97..22a6ee86 100644 --- a/src/commands/login/actions.ts +++ b/src/commands/login/actions.ts @@ -15,7 +15,7 @@ export const loginWithToken = async (token: string, region: string) => { 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}`)} - + Please make sure you are using the correct token and try again.`) } throw new Error('Error logging with token', error as Error) From 1cbd2be4f16f40c7e0c0a39e480309e4365f4d3d Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 2 Oct 2024 10:06:36 +0200 Subject: [PATCH 11/47] test: use correct masktoken fn --- src/commands/login/actions.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/commands/login/actions.test.ts b/src/commands/login/actions.test.ts index c12ff7b6..5917f89e 100644 --- a/src/commands/login/actions.test.ts +++ b/src/commands/login/actions.test.ts @@ -7,10 +7,6 @@ vi.mock('ofetch', () => ({ ofetch: vi.fn(), })) -vi.mock('../../utils', () => ({ - maskToken: (token: string) => token.replace(/.(?=.{4})/g, '*'), -})) - describe('login actions', () => { beforeEach(() => { vi.clearAllMocks() @@ -33,7 +29,7 @@ describe('login actions', () => { ofetch.mockRejectedValue(mockError) await expect(loginWithToken('invalid-token', 'eu')).rejects.toThrow( - new Error(`The token provided ${chalk.bold('*********oken')} is invalid: ${chalk.bold('401 Unauthorized')} + 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.`), ) From c60f8302cdf805223bdfa5ada73bcfb47ce5db11 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 2 Oct 2024 11:50:41 +0200 Subject: [PATCH 12/47] tests: removed unused ci option since token acts like one --- src/commands/login/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index 4bd25ce5..5ba6488c 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -34,14 +34,13 @@ 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, ) - .option('-ci', '--ci', false) .action(async (options) => { - const { token, Ci, region } = options + 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) } - if (token || Ci) { + if (token) { try { const { user } = await loginWithToken(token, region) await addNetrcEntry({ From 974099757fc35292f9f755f10e1f3c1af16aeafe Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 2 Oct 2024 11:50:55 +0200 Subject: [PATCH 13/47] tests: login actions tests --- src/commands/login/actions.test.ts | 4 ++-- src/commands/login/actions.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/commands/login/actions.test.ts b/src/commands/login/actions.test.ts index 5917f89e..97aec97c 100644 --- a/src/commands/login/actions.test.ts +++ b/src/commands/login/actions.test.ts @@ -35,12 +35,12 @@ describe('login actions', () => { ) }) - it('should throw a generic error for other issues', async () => { + it('should throw a network error if response is empty (network)', async () => { const mockError = new Error('Network error') ofetch.mockRejectedValue(mockError) await expect(loginWithToken('any-token', 'eu')).rejects.toThrow( - 'Error logging with token', + 'No response from server, please check if you are correctly connected to internet', ) }) }) diff --git a/src/commands/login/actions.ts b/src/commands/login/actions.ts index 22a6ee86..1c5ea952 100644 --- a/src/commands/login/actions.ts +++ b/src/commands/login/actions.ts @@ -18,6 +18,9 @@ export const loginWithToken = async (token: string, region: string) => { Please make sure you are using the correct token and try again.`) } + 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) } } From 57555113cbd97f63703ab8a28eadab5d71b34158 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 2 Oct 2024 11:51:17 +0200 Subject: [PATCH 14/47] tests: login --token tests --- src/commands/login/index.test.ts | 149 +++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/commands/login/index.test.ts diff --git a/src/commands/login/index.test.ts b/src/commands/login/index.test.ts new file mode 100644 index 00000000..9ace4b5a --- /dev/null +++ b/src/commands/login/index.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import { loginWithToken } from './actions' +import { loginCommand } from './' +import { addNetrcEntry } from '../../creds' +import { handleError, konsola } from '../../utils' +import { input, password, select } from '@inquirer/prompts' +import { regions } from 'src/constants' +import chalk from 'chalk' + +vi.mock('./actions', () => ({ + loginWithEmailAndPassword: vi.fn(), + loginWithOtp: vi.fn(), + loginWithToken: vi.fn(), +})) + +vi.mock('../../creds', () => ({ + addNetrcEntry: vi.fn(), +})) + +vi.mock('../../utils', async () => { + const actualUtils = await vi.importActual('../../utils') + return { + ...actualUtils, + konsola: { + ok: vi.fn(), + error: vi.fn(), + }, + handleError: (error: Error, header = false) => { + konsola.error(error, header) + // Optionally, prevent process.exit during tests + }, + } +}) + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), + password: vi.fn(), + select: vi.fn(), +})) + +describe('loginCommand', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('if --token is provided', () => { + it('should login with a valid token', async () => { + const mockToken = 'test-token' + const mockUser = { email: 'test@example.com' } + loginWithToken.mockResolvedValue({ user: mockUser }) + + await loginCommand.parseAsync(['node', 'test', '--token', mockToken]) + + expect(loginWithToken).toHaveBeenCalledWith(mockToken, 'eu') + expect(addNetrcEntry).toHaveBeenCalledWith({ + machineName: 'api.storyblok.com', + login: mockUser.email, + password: mockToken, + region: 'eu', + }) + expect(konsola.ok).toHaveBeenCalledWith('Successfully logged in with token') + }) + + 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 }) + + await loginCommand.parseAsync(['node', 'test', '--token', mockToken, '--region', 'us']) + + expect(loginWithToken).toHaveBeenCalledWith(mockToken, 'us') + expect(addNetrcEntry).toHaveBeenCalledWith({ + machineName: 'api-us.storyblok.com', + login: mockUser.email, + password: mockToken, + region: 'us', + }) + expect(konsola.ok).toHaveBeenCalledWith('Successfully logged in with token') + }) + + it('should throw an error for an invalid token', async () => { + const mockError = 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.`) + + loginWithToken.mockRejectedValue(mockError) + + await loginCommand.parseAsync(['node', 'test', '--token', 'invalid-token']) + + // expect(handleError).toHaveBeenCalledWith(mockError, true) + expect(konsola.error).toHaveBeenCalledWith(expect.any(Error), true) + }) + }) + +/* it('should login with token when token is provided', async () => { + const mockToken = 'test-token' + const mockUser = { email: 'test@example.com' } + loginWithToken.mockResolvedValue({ user: mockUser }) + + await loginCommand.parseAsync(['node', 'test', '--token', mockToken]) + + expect(loginWithToken).toHaveBeenCalledWith(mockToken, 'eu') + expect(addNetrcEntry).toHaveBeenCalledWith({ + machineName: 'api.storyblok.com', + login: mockUser.email, + password: mockToken, + region: 'eu', + }) + expect(konsola.ok).toHaveBeenCalledWith('Successfully logged in with token') + }) + + it('should prompt for login strategy when no token is provided', async () => { + select.mockResolvedValueOnce('login-with-token') + password.mockResolvedValueOnce('test-token') + const mockUser = { email: 'test@example.com' } + loginWithToken.mockResolvedValue({ user: mockUser }) + + await loginCommand.parseAsync(['node', 'test']) + + expect(select).toHaveBeenCalledWith(expect.objectContaining({ + message: 'How would you like to login?', + })) + expect(password).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Please enter your token:', + })) + expect(loginWithToken).toHaveBeenCalledWith('test-token', 'eu') + expect(addNetrcEntry).toHaveBeenCalledWith({ + machineName: 'api.storyblok.com', + login: mockUser.email, + password: 'test-token', + region: 'eu', + }) + expect(konsola.ok).toHaveBeenCalledWith('Successfully logged in with token') + }) + + 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) + + // Access the error argument + const errorArg = 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(' | ')}` + + expect(errorArg.message).toBe(expectedMessage) + }) */ +}) From 7c83aed77bbcedb1899edd324b9c66d8ea22e880 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 2 Oct 2024 14:44:45 +0200 Subject: [PATCH 15/47] tests: login command tests with different login strategies --- src/commands/login/index.test.ts | 130 ++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 2 deletions(-) diff --git a/src/commands/login/index.test.ts b/src/commands/login/index.test.ts index 9ace4b5a..02f477c3 100644 --- a/src/commands/login/index.test.ts +++ b/src/commands/login/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { loginWithToken } from './actions' +import { loginWithEmailAndPassword, loginWithOtp, loginWithToken } from './actions' import { loginCommand } from './' import { addNetrcEntry } from '../../creds' import { handleError, konsola } from '../../utils' @@ -43,7 +43,117 @@ describe('loginCommand', () => { vi.clearAllMocks() }) - describe('if --token is provided', () => { + describe('default interactive login', () => { + it('should prompt the user for login strategy when no token is provided', async () => { + await loginCommand.parseAsync(['node', 'test']) + + expect(select).toHaveBeenCalledWith(expect.objectContaining({ + message: 'How would you like to login?', + })) + }) + + describe('login-with-email strategy', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + it('should prompt the user for email and password when login-with-email is selected', async () => { + select + .mockResolvedValueOnce('login-with-email') // For login strategy + .mockResolvedValueOnce('eu') // For region + + input + .mockResolvedValueOnce('user@example.com') // For email + .mockResolvedValueOnce('123456') // For OTP code + + password.mockResolvedValueOnce('test-password') + + await loginCommand.parseAsync(['node', 'test']) + + expect(input).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Please enter your email address:', + })) + + expect(password).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Please enter your password:', + })) + + expect(select).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Please select the region you would like to work in:', + })) + }) + + it('should login with email and password if provided using login-with-email strategy', async () => { + select + .mockResolvedValueOnce('login-with-email') // For login strategy + .mockResolvedValueOnce('eu') // For region + + input + .mockResolvedValueOnce('user@example.com') // For email + .mockResolvedValueOnce('123456') // For OTP code + + password.mockResolvedValueOnce('test-password') + + loginWithEmailAndPassword.mockResolvedValueOnce({ otp_required: true }) + loginWithOtp.mockResolvedValueOnce({ access_token: 'test-token' }) + + await loginCommand.parseAsync(['node', 'test']) + + expect(loginWithEmailAndPassword).toHaveBeenCalledWith('user@example.com', 'test-password', 'eu') + + expect(loginWithOtp).toHaveBeenCalledWith('user@example.com', 'test-password', '123456', 'eu') + }) + + it('should throw an error for invalid email and password', async () => { + select.mockResolvedValueOnce('login-with-email') + input.mockResolvedValueOnce('eu') + + const mockError = new Error('Error logging in with email and password') + loginWithEmailAndPassword.mockRejectedValueOnce(mockError) + + await loginCommand.parseAsync(['node', 'test']) + + expect(konsola.error).toHaveBeenCalledWith(mockError, true) + }) + }) + + describe('login-with-token strategy', () => { + it('should prompt the user for token when login-with-token is selected', async () => { + select.mockResolvedValueOnce('login-with-token') + password.mockResolvedValueOnce('test-token') + + await loginCommand.parseAsync(['node', 'test']) + + expect(password).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Please enter your token:', + })) + }) + + it('should login with token if token is provided using login-with-token strategy', async () => { + select.mockResolvedValueOnce('login-with-token') + password.mockResolvedValueOnce('test-token') + const mockUser = { email: 'user@example.com' } + loginWithToken.mockResolvedValue({ user: mockUser }) + + await loginCommand.parseAsync(['node', 'test']) + + expect(password).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Please enter your token:', + })) + // Verify that loginWithToken was called with the correct arguments + expect(loginWithToken).toHaveBeenCalledWith('test-token', 'eu') + + // Verify that addNetrcEntry was called with the correct arguments + expect(addNetrcEntry).toHaveBeenCalledWith({ + machineName: 'api.storyblok.com', + login: mockUser.email, + password: 'test-token', + region: 'eu', + }) + }) + }) + }) + + describe('--token', () => { it('should login with a valid token', async () => { const mockToken = 'test-token' const mockUser = { email: 'test@example.com' } @@ -88,7 +198,23 @@ describe('loginCommand', () => { await loginCommand.parseAsync(['node', 'test', '--token', 'invalid-token']) // expect(handleError).toHaveBeenCalledWith(mockError, true) + expect(konsola.error).toHaveBeenCalledWith(mockError, true) + }) + }) + + describe('--region', () => { + 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) + + // Access the error argument + const errorArg = 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(' | ')}` + + expect(errorArg.message).toBe(expectedMessage) }) }) From eda9600d6e8c8340fc9d7b15e074f5f3e915bcb0 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 2 Oct 2024 14:47:52 +0200 Subject: [PATCH 16/47] tests: coverage --- package.json | 4 +- pnpm-lock.yaml | 236 +++++++++++++++++++++++++++++++ src/commands/login/index.test.ts | 2 +- vitest.config.ts | 3 + 4 files changed, 243 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 513e5f4c..9219fe85 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "dev": "node dist/index.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", - "test": "vitest" + "test": "vitest", + "coverage": "vitest run --coverage" }, "dependencies": { "@inquirer/prompts": "^6.0.1", @@ -43,6 +44,7 @@ "@storyblok/eslint-config": "^0.2.0", "@types/inquirer": "^9.0.7", "@types/node": "^22.5.4", + "@vitest/coverage-v8": "^2.1.1", "eslint": "^9.10.0", "memfs": "^4.11.2", "pathe": "^1.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4804fb91..141f8c83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: '@types/node': specifier: ^22.5.4 version: 22.5.4 + '@vitest/coverage-v8': + specifier: ^2.1.1 + version: 2.1.1(vitest@2.1.1(@types/node@22.5.4)) eslint: specifier: ^9.10.0 version: 9.10.0(jiti@1.21.6) @@ -191,6 +194,9 @@ packages: resolution: {integrity: sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@clack/core@0.3.4': resolution: {integrity: sha512-H4hxZDXgHtWTwV3RAVenqcC4VbJZNegbBjlPvzOzCouXtS2y3sDvlO3IsbrPNWuLWPPlYVYPghQdSF64683Ldw==} @@ -742,6 +748,14 @@ packages: resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==} engines: {node: '>=18'} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} @@ -790,6 +804,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@pkgr/core@0.1.1': resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -1039,6 +1057,15 @@ packages: resolution: {integrity: sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/coverage-v8@2.1.1': + resolution: {integrity: sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==} + peerDependencies: + '@vitest/browser': 2.1.1 + vitest: 2.1.1 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/eslint-plugin@1.1.4': resolution: {integrity: sha512-kudjgefmJJ7xQ2WfbUU6pZbm7Ou4gLYRaao/8Ynide3G0QhVKHd978sDyWX4KOH0CCMH9cyrGAkFd55eGzJ48Q==} peerDependencies: @@ -1120,6 +1147,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} engines: {node: '>=4'} @@ -1128,6 +1159,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + are-docs-informative@0.0.2: resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} engines: {node: '>=14'} @@ -1397,12 +1432,18 @@ packages: domutils@3.1.0: resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + electron-to-chromium@1.5.22: resolution: {integrity: sha512-tKYm5YHPU1djz0O+CGJ+oJIvimtsCcwR2Z9w7Skh08lUdyzXY5djods3q+z2JkWdb7tCcmM//eVavSRAiaPRNg==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enhanced-resolve@5.17.1: resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} engines: {node: '>=10.13.0'} @@ -1697,6 +1738,10 @@ packages: flatted@3.3.1: resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + foreground-child@3.3.0: + resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} + engines: {node: '>=14'} + fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} @@ -1733,6 +1778,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} @@ -1782,6 +1831,9 @@ packages: hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + hyperdyperid@1.2.0: resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} engines: {node: '>=10.18'} @@ -1853,6 +1905,25 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.1.7: + resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@1.21.6: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true @@ -1947,12 +2018,22 @@ packages: loupe@3.1.1: resolution: {integrity: sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} magic-string@0.30.11: resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + markdown-table@3.0.3: resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==} @@ -2106,6 +2187,10 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + mkdist@1.5.9: resolution: {integrity: sha512-PdJimzhcgDxaHpk1SUabw56gT3BU15vBHUTHkeeus8Kl7jUkpgG7+z0PiS/y23XXgO8TiU/dKP3L1oG55qrP1g==} hasBin: true @@ -2196,6 +2281,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@0.2.0: resolution: {integrity: sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog==} @@ -2226,6 +2314,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -2607,10 +2699,18 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -2654,6 +2754,10 @@ packages: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -2872,6 +2976,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3090,6 +3198,8 @@ snapshots: '@babel/helper-validator-identifier': 7.24.7 to-fast-properties: 2.0.0 + '@bcoe/v8-coverage@0.2.3': {} + '@clack/core@0.3.4': dependencies: picocolors: 1.1.0 @@ -3477,6 +3587,17 @@ snapshots: dependencies: mute-stream: 1.0.0 + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 @@ -3522,6 +3643,9 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 + '@pkgjs/parseargs@0.11.0': + optional: true + '@pkgr/core@0.1.1': {} '@rollup/plugin-alias@5.1.0(rollup@3.29.4)': @@ -3782,6 +3906,24 @@ 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))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.3.7 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.1.7 + magic-string: 0.30.11 + magicast: 0.3.5 + std-env: 3.7.0 + test-exclude: 7.0.1 + tinyrainbow: 1.2.0 + vitest: 2.1.1(@types/node@22.5.4) + 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))': dependencies: eslint: 9.10.0(jiti@1.21.6) @@ -3881,6 +4023,8 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.1.0: {} + ansi-styles@3.2.1: dependencies: color-convert: 1.9.3 @@ -3889,6 +4033,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.1: {} + are-docs-informative@0.0.2: {} argparse@2.0.1: {} @@ -4159,10 +4305,14 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + eastasianwidth@0.2.0: {} + electron-to-chromium@1.5.22: {} emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + enhanced-resolve@5.17.1: dependencies: graceful-fs: 4.2.11 @@ -4612,6 +4762,11 @@ snapshots: flatted@3.3.1: {} + foreground-child@3.3.0: + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + fraction.js@4.3.7: {} fs.realpath@1.0.0: {} @@ -4639,6 +4794,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.4.5: + dependencies: + foreground-child: 3.3.0 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@8.1.0: dependencies: fs.realpath: 1.0.0 @@ -4681,6 +4845,8 @@ snapshots: hosted-git-info@2.8.9: {} + html-escaper@2.0.2: {} + hyperdyperid@1.2.0: {} iconv-lite@0.4.24: @@ -4735,6 +4901,33 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + debug: 4.3.7 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.1.7: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jiti@1.21.6: {} js-tokens@4.0.0: {} @@ -4808,6 +5001,8 @@ snapshots: dependencies: get-func-name: 2.0.2 + lru-cache@10.4.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -4816,6 +5011,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.25.6 + '@babel/types': 7.25.6 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.6.3 + markdown-table@3.0.3: {} mdast-util-find-and-replace@3.0.1: @@ -5142,6 +5347,8 @@ snapshots: dependencies: brace-expansion: 2.0.1 + minipass@7.1.2: {} + mkdist@1.5.9(typescript@5.6.2): dependencies: autoprefixer: 10.4.20(postcss@8.4.45) @@ -5235,6 +5442,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + package-manager-detector@0.2.0: {} parent-module@1.0.1: @@ -5261,6 +5470,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + path-type@4.0.0: {} pathe@1.1.2: {} @@ -5613,10 +5827,20 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -5660,6 +5884,12 @@ snapshots: tapable@2.2.1: {} + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.4.5 + minimatch: 9.0.5 + text-table@0.2.0: {} thingies@1.21.0(tslib@2.7.0): @@ -5895,6 +6125,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + wrappy@1.0.2: {} xml-name-validator@4.0.0: {} diff --git a/src/commands/login/index.test.ts b/src/commands/login/index.test.ts index 02f477c3..8e803bf5 100644 --- a/src/commands/login/index.test.ts +++ b/src/commands/login/index.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { loginWithEmailAndPassword, loginWithOtp, loginWithToken } from './actions' import { loginCommand } from './' import { addNetrcEntry } from '../../creds' -import { handleError, konsola } from '../../utils' +import { konsola } from '../../utils' import { input, password, select } from '@inquirer/prompts' import { regions } from 'src/constants' import chalk from 'chalk' diff --git a/vitest.config.ts b/vitest.config.ts index 7528fea2..55107ecb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,5 +5,8 @@ export default defineConfig({ test: { globals: true, // ... Specify options here. + coverage: { + reporter: ['text', 'json', 'html'], + }, }, }) From b88380593deabd4a0f1c97a8d16618ac56dc591e Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 2 Oct 2024 15:13:27 +0200 Subject: [PATCH 17/47] chore: minor test fix --- .vscode/launch.json | 2 +- src/commands/login/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 1eb4483b..2613f4d4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,7 +18,7 @@ "request": "launch", "name": "Debug login", "program": "${workspaceFolder}/dist/index.mjs", - "args": ["login", "--token", "invalidt-token"], + "args": ["login"], "cwd": "${workspaceFolder}", "console": "integratedTerminal", "sourceMaps": true, diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index 5ba6488c..c3b53c3a 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -117,7 +117,7 @@ export const loginCommand = program region: userRegion, }) - konsola.ok(`Successfully logged in with email ${chalk.hex('#45bfb9')(userEmail)}`, true) + konsola.ok(`Successfully logged in with email ${chalk.hex('#45bfb9')(userEmail)}`) } } } From ba7e913f23b9ace2ed227e6ee00f43079f92ba01 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 2 Oct 2024 15:13:59 +0200 Subject: [PATCH 18/47] tests: remove commented code --- src/commands/login/index.test.ts | 55 -------------------------------- 1 file changed, 55 deletions(-) diff --git a/src/commands/login/index.test.ts b/src/commands/login/index.test.ts index 8e803bf5..20b942c5 100644 --- a/src/commands/login/index.test.ts +++ b/src/commands/login/index.test.ts @@ -217,59 +217,4 @@ describe('loginCommand', () => { expect(errorArg.message).toBe(expectedMessage) }) }) - -/* it('should login with token when token is provided', async () => { - const mockToken = 'test-token' - const mockUser = { email: 'test@example.com' } - loginWithToken.mockResolvedValue({ user: mockUser }) - - await loginCommand.parseAsync(['node', 'test', '--token', mockToken]) - - expect(loginWithToken).toHaveBeenCalledWith(mockToken, 'eu') - expect(addNetrcEntry).toHaveBeenCalledWith({ - machineName: 'api.storyblok.com', - login: mockUser.email, - password: mockToken, - region: 'eu', - }) - expect(konsola.ok).toHaveBeenCalledWith('Successfully logged in with token') - }) - - it('should prompt for login strategy when no token is provided', async () => { - select.mockResolvedValueOnce('login-with-token') - password.mockResolvedValueOnce('test-token') - const mockUser = { email: 'test@example.com' } - loginWithToken.mockResolvedValue({ user: mockUser }) - - await loginCommand.parseAsync(['node', 'test']) - - expect(select).toHaveBeenCalledWith(expect.objectContaining({ - message: 'How would you like to login?', - })) - expect(password).toHaveBeenCalledWith(expect.objectContaining({ - message: 'Please enter your token:', - })) - expect(loginWithToken).toHaveBeenCalledWith('test-token', 'eu') - expect(addNetrcEntry).toHaveBeenCalledWith({ - machineName: 'api.storyblok.com', - login: mockUser.email, - password: 'test-token', - region: 'eu', - }) - expect(konsola.ok).toHaveBeenCalledWith('Successfully logged in with token') - }) - - 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) - - // Access the error argument - const errorArg = 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(' | ')}` - - expect(errorArg.message).toBe(expectedMessage) - }) */ }) From 33ec33a3791413aa972d1b332bae83dea4f31bd2 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 2 Oct 2024 16:07:22 +0200 Subject: [PATCH 19/47] feat: isAutorized --- src/commands/login/index.test.ts | 1 + src/commands/login/index.ts | 8 +++++++- src/creds.test.ts | 35 +++++++++++++++++++++++++++++++- src/creds.ts | 21 +++++++++++++++++-- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/commands/login/index.test.ts b/src/commands/login/index.test.ts index 20b942c5..cfef57ed 100644 --- a/src/commands/login/index.test.ts +++ b/src/commands/login/index.test.ts @@ -15,6 +15,7 @@ vi.mock('./actions', () => ({ vi.mock('../../creds', () => ({ addNetrcEntry: vi.fn(), + isAuthorized: vi.fn(), })) vi.mock('../../utils', async () => { diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index c3b53c3a..db55a5e4 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -4,7 +4,7 @@ import { commands, regionNames, regions, regionsDomain } from '../../constants' import { getProgram } from '../../program' import { formatHeader, handleError, isRegion, konsola } from '../../utils' import { loginWithEmailAndPassword, loginWithOtp, loginWithToken } from './actions' -import { addNetrcEntry } from '../../creds' +import { addNetrcEntry, isAuthorized } from '../../creds' const program = getProgram() // Get the shared singleton instance @@ -40,6 +40,12 @@ export const loginCommand = program konsola.error(new Error(`The provided region: ${region} is not valid. Please use one of the following values: ${Object.values(regions).join(' | ')}`), true) } + if (await isAuthorized()) { + konsola.ok(`You are already logged in. If you want to login with a different account, please logout first. +`) + return + } + if (token) { try { const { user } = await loginWithToken(token, region) diff --git a/src/creds.test.ts b/src/creds.test.ts index 70051776..ef17f8f4 100644 --- a/src/creds.test.ts +++ b/src/creds.test.ts @@ -1,4 +1,4 @@ -import { addNetrcEntry, getNetrcCredentials, getNetrcFilePath } from './creds' +import { addNetrcEntry, getNetrcCredentials, getNetrcFilePath, isAuthorized } from './creds' import { vol } from 'memfs' import { join } from 'pathe' // tell vitest to use fs mock from __mocks__ folder @@ -162,4 +162,37 @@ describe('creds', async () => { `) }) }) + + describe('isAuthorized', () => { + beforeEach(() => { + vol.reset() + process.env.HOME = '/temp' // Ensure getNetrcFilePath points to /temp/.netrc + + vol.fromJSON({ + '/temp/.netrc': `machine api.storyblok.com + login julio.iglesias@storyblok.com + password my_access_token + region eu`, + }) + }) + it('should return true if .netrc file contains an entry', async () => { + vi.doMock('./creds', () => { + return { + getNetrcCredentials: async () => { + return { + 'api.storyblok.com': { + login: 'julio.iglesias@storyblok.co,m', + password: 'my_access', + region: 'eu', + }, + } + }, + } + }) + + const result = await isAuthorized() + + expect(result).toBe(true) + }) + }) }) diff --git a/src/creds.ts b/src/creds.ts index 1ab68588..8696c650 100644 --- a/src/creds.ts +++ b/src/creds.ts @@ -159,7 +159,24 @@ export const addNetrcEntry = async ({ konsola.ok(`Successfully added/updated entry for machine ${machineName} in ${chalk.hex('#45bfb9')(filePath)}`, true) } - catch (error: any) { - handleError(new Error(`Error adding/updating entry for machine ${machineName} in .netrc file: ${error.message}`), true) + catch (error: unknown) { + handleError(new Error(`Error adding/updating entry for machine ${machineName} in .netrc file: ${(error as Error).message}`), true) + } +} + +export async function isAuthorized(): Promise { + try { + const machines = await getNetrcCredentials() + // Check if there is any machine with a valid email and token + for (const machine of Object.values(machines)) { + if (machine.login && machine.password) { + return true + } + } + return false + } + catch (error: unknown) { + handleError(new Error(`Error checking authorization in .netrc file: ${(error as Error).message}`), true) + return false } } From 9f493852c157a6635c47b9b7cced6644493f9512 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Thu, 3 Oct 2024 09:46:10 +0200 Subject: [PATCH 20/47] feat: logout command --- .vscode/launch.json | 11 ++++++++ src/commands/logout/index.test.ts | 27 +++++++++++++++++++ src/commands/logout/index.ts | 26 ++++++++++++++++++ src/constants.ts | 1 + src/creds.test.ts | 19 ++++++++++++- src/creds.ts | 45 +++++++++++++++++++++++++++++++ src/index.ts | 1 + 7 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 src/commands/logout/index.test.ts create mode 100644 src/commands/logout/index.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 2613f4d4..fe0403e8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,6 +23,17 @@ "console": "integratedTerminal", "sourceMaps": true, "outFiles": ["${workspaceFolder}/dist/**/*.js"] + }, + { + "type": "node", + "request": "launch", + "name": "Debug logout", + "program": "${workspaceFolder}/dist/index.mjs", + "args": ["logout"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "outFiles": ["${workspaceFolder}/dist/**/*.js"] } ] } diff --git a/src/commands/logout/index.test.ts b/src/commands/logout/index.test.ts new file mode 100644 index 00000000..8985f1d3 --- /dev/null +++ b/src/commands/logout/index.test.ts @@ -0,0 +1,27 @@ +import { isAuthorized, removeNetrcEntry } from '../../creds' +import { logoutCommand } from './' + +vi.mock('../../creds', () => ({ + isAuthorized: vi.fn(), + removeNetrcEntry: vi.fn(), +})) + +describe('logoutCommand', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.clearAllMocks() + }) + + it('should log out the user if has previously login', async () => { + 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) + await logoutCommand.parseAsync(['node', 'test']) + expect(removeNetrcEntry).not.toHaveBeenCalled() + }) +}) diff --git a/src/commands/logout/index.ts b/src/commands/logout/index.ts new file mode 100644 index 00000000..27e1ce56 --- /dev/null +++ b/src/commands/logout/index.ts @@ -0,0 +1,26 @@ +import { isAuthorized, removeNetrcEntry } from '../../creds' +import { commands } from '../../constants' +import { getProgram } from '../../program' +import { handleError, konsola } from '../../utils' + +const program = getProgram() // Get the shared singleton instance + +export const logoutCommand = program + .command(commands.LOGOUT) + .description('Logout from the Storyblok CLI') + .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. + `) + return + } + try { + await removeNetrcEntry() + + konsola.ok(`Successfully logged out`) + } + catch (error) { + handleError(error as Error, true) + } + }) diff --git a/src/constants.ts b/src/constants.ts index d30f772d..baacee34 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,6 @@ export const commands: Record = { LOGIN: 'login', + LOGOUT: 'logout', } export const regions: Record = { diff --git a/src/creds.test.ts b/src/creds.test.ts index ef17f8f4..225cceed 100644 --- a/src/creds.test.ts +++ b/src/creds.test.ts @@ -1,4 +1,4 @@ -import { addNetrcEntry, getNetrcCredentials, getNetrcFilePath, isAuthorized } from './creds' +import { addNetrcEntry, getNetrcCredentials, getNetrcFilePath, isAuthorized, removeNetrcEntry } from './creds' import { vol } from 'memfs' import { join } from 'pathe' // tell vitest to use fs mock from __mocks__ folder @@ -163,6 +163,23 @@ describe('creds', async () => { }) }) + describe('removeNetrcEntry', () => { + it('should remove an entry 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 removeNetrcEntry('/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 8696c650..757077dd 100644 --- a/src/creds.ts +++ b/src/creds.ts @@ -164,6 +164,51 @@ export const addNetrcEntry = async ({ } } +// Function to remove an entry from the .netrc file asynchronously +export const removeNetrcEntry = async ( + filePath = getNetrcFilePath(), + machineName?: string, +) => { + try { + let machines: Record = {} + + // Check if the file exists + try { + await fs.access(filePath) + // File exists, read and parse it + const content = await fs.readFile(filePath, 'utf8') + machines = parseNetrcContent(content) + } + catch { + // File does not exist + console.warn(`.netrc file not found at path: ${filePath}. No action taken.`) + return + } + + if (machineName) { + // Remove the machine entry + delete machines[machineName] + } + else { + // Remove all machine entries + machines = {} + } + + // Serialize machines back into .netrc format + const newContent = serializeNetrcMachines(machines) + + // Write the updated content back to the .netrc file + await fs.writeFile(filePath, newContent, { + mode: 0o600, // Set file permissions + }) + + 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) + } +} + export async function isAuthorized(): Promise { try { const machines = await getNetrcCredentials() diff --git a/src/index.ts b/src/index.ts index 7d912043..99156661 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import chalk from 'chalk' import { formatHeader, handleError } from './utils' import { getProgram } from './program' import './commands/login' +import './commands/logout' const program = getProgram() console.clear() From 09899488b976bc4b5c5e8a95fc82be2b0414a295 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Thu, 3 Oct 2024 12:37:23 +0200 Subject: [PATCH 21/47] feat: session credentials handler and updated tests --- package.json | 1 + pnpm-lock.yaml | 9 +++ src/api.test.ts | 29 ++++++++- src/api.ts | 9 ++- src/commands/login/index.test.ts | 51 +++++++++------ src/commands/login/index.ts | 36 +++++------ src/creds.ts | 35 ++++++++--- src/index.ts | 12 ++++ src/session.test.ts | 40 ++++++++++++ src/session.ts | 104 +++++++++++++++++++++++++++++++ 10 files changed, 275 insertions(+), 51 deletions(-) create mode 100644 src/session.test.ts create mode 100644 src/session.ts diff --git a/package.json b/package.json index 9219fe85..6d65734a 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "chalk": "^5.3.0", "commander": "^12.1.0", "consola": "^3.2.3", + "dotenv": "^16.4.5", "ofetch": "^1.4.0", "storyblok-js-client": "^6.9.2" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 141f8c83..92a8655e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: consola: specifier: ^3.2.3 version: 3.2.3 + dotenv: + specifier: ^16.4.5 + version: 16.4.5 ofetch: specifier: ^1.4.0 version: 1.4.0 @@ -1432,6 +1435,10 @@ packages: domutils@3.1.0: resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -4305,6 +4312,8 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dotenv@16.4.5: {} + eastasianwidth@0.2.0: {} electron-to-chromium@1.5.22: {} diff --git a/src/api.test.ts b/src/api.test.ts index c129e8a3..b26a56ea 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -14,10 +14,37 @@ vi.mock('storyblok-js-client', () => { } }) +// Mocking the session module +const sessionMock = vi.fn() +// Mocking the session module +vi.mock('./session', () => { + let _cache + const session = () => { + if (!_cache) { + _cache = { + state: { + isLoggedIn: true, + password: 'test-token', + region: 'eu', + }, + updateSession: vi.fn(), + persistCredentials: vi.fn(), + initializeSession: vi.fn(), + } + } + return _cache + } + + return { + session, + } +}) + describe('storyblok API Client', () => { - beforeEach(() => { + beforeEach(async () => { // Reset the module state before each test to ensure test isolation vi.resetModules() + vi.clearAllMocks() }) it('should have a default region of "eu"', () => { diff --git a/src/api.ts b/src/api.ts index 4887c755..724c765d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,4 +1,5 @@ import StoryblokClient from 'storyblok-js-client' +import { session } from './session' export interface ApiClientState { region: string @@ -18,9 +19,13 @@ export function apiClient() { } function createClient() { + const userSession = session() + if (!userSession.state.isLoggedIn) { + throw new Error('User is not logged in') + } state.client = new StoryblokClient({ - accessToken: state.accessToken, - region: state.region, + accessToken: userSession.state.password!, + region: userSession.state.region!, }) } diff --git a/src/commands/login/index.test.ts b/src/commands/login/index.test.ts index cfef57ed..d6216649 100644 --- a/src/commands/login/index.test.ts +++ b/src/commands/login/index.test.ts @@ -4,8 +4,9 @@ import { loginCommand } from './' import { addNetrcEntry } from '../../creds' import { konsola } from '../../utils' import { input, password, select } from '@inquirer/prompts' -import { regions } from 'src/constants' +import { regions } from '../../constants' import chalk from 'chalk' +import { session } from '../../session' // Import as module to mock properly vi.mock('./actions', () => ({ loginWithEmailAndPassword: vi.fn(), @@ -16,8 +17,32 @@ vi.mock('./actions', () => ({ vi.mock('../../creds', () => ({ addNetrcEntry: vi.fn(), isAuthorized: vi.fn(), + getNetrcCredentials: vi.fn(), + getCredentialsForMachine: vi.fn(), })) +// Mocking the session module +vi.mock('../../session', () => { + let _cache + const session = () => { + if (!_cache) { + _cache = { + state: { + isLoggedIn: false, + }, + updateSession: vi.fn(), + persistCredentials: vi.fn(), + initializeSession: vi.fn(), + } + } + return _cache + } + + return { + session, + } +}) + vi.mock('../../utils', async () => { const actualUtils = await vi.importActual('../../utils') return { @@ -41,6 +66,7 @@ vi.mock('@inquirer/prompts', () => ({ describe('loginCommand', () => { beforeEach(() => { + vi.resetAllMocks() vi.clearAllMocks() }) @@ -143,13 +169,8 @@ describe('loginCommand', () => { // Verify that loginWithToken was called with the correct arguments expect(loginWithToken).toHaveBeenCalledWith('test-token', 'eu') - // Verify that addNetrcEntry was called with the correct arguments - expect(addNetrcEntry).toHaveBeenCalledWith({ - machineName: 'api.storyblok.com', - login: mockUser.email, - password: 'test-token', - region: 'eu', - }) + // Verify that updateSession was called with the correct arguments + expect(session().updateSession).toHaveBeenCalledWith(mockUser.email, 'test-token', 'eu') }) }) }) @@ -163,12 +184,7 @@ describe('loginCommand', () => { await loginCommand.parseAsync(['node', 'test', '--token', mockToken]) expect(loginWithToken).toHaveBeenCalledWith(mockToken, 'eu') - expect(addNetrcEntry).toHaveBeenCalledWith({ - machineName: 'api.storyblok.com', - login: mockUser.email, - password: mockToken, - region: 'eu', - }) + expect(konsola.ok).toHaveBeenCalledWith('Successfully logged in with token') }) @@ -180,12 +196,7 @@ describe('loginCommand', () => { await loginCommand.parseAsync(['node', 'test', '--token', mockToken, '--region', 'us']) expect(loginWithToken).toHaveBeenCalledWith(mockToken, 'us') - expect(addNetrcEntry).toHaveBeenCalledWith({ - machineName: 'api-us.storyblok.com', - login: mockUser.email, - password: mockToken, - region: 'us', - }) + expect(konsola.ok).toHaveBeenCalledWith('Successfully logged in with token') }) diff --git a/src/commands/login/index.ts b/src/commands/login/index.ts index db55a5e4..8da0e0a7 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -4,7 +4,8 @@ import { commands, regionNames, regions, regionsDomain } from '../../constants' import { getProgram } from '../../program' import { formatHeader, handleError, isRegion, konsola } from '../../utils' import { loginWithEmailAndPassword, loginWithOtp, loginWithToken } from './actions' -import { addNetrcEntry, isAuthorized } from '../../creds' + +import { session } from '../../session' const program = getProgram() // Get the shared singleton instance @@ -25,6 +26,7 @@ const loginStrategy }, ], } + export const loginCommand = program .command(commands.LOGIN) .description('Login to the Storyblok CLI') @@ -40,7 +42,11 @@ export const loginCommand = program konsola.error(new Error(`The provided region: ${region} is not valid. Please use one of the following values: ${Object.values(regions).join(' | ')}`), true) } - if (await isAuthorized()) { + const { state, updateSession, persistCredentials, initializeSession } = session() + + await initializeSession() + + if (state.isLoggedIn) { konsola.ok(`You are already logged in. If you want to login with a different account, please logout first. `) return @@ -49,12 +55,9 @@ export const loginCommand = program if (token) { try { const { user } = await loginWithToken(token, region) - await addNetrcEntry({ - machineName: regionsDomain[region], - login: user.email, - password: token, - region, - }) + updateSession(user.email, token, region) + await persistCredentials(regionsDomain[region]) + konsola.ok(`Successfully logged in with token`) } catch (error) { @@ -76,12 +79,8 @@ export const loginCommand = program const { user } = await loginWithToken(userToken, region) - await addNetrcEntry({ - machineName: regionsDomain[region], - login: user.email, - password: userToken, - region, - }) + updateSession(user.email, userToken, region) + await persistCredentials(regionsDomain[region]) konsola.ok(`Successfully logged in with token`) } @@ -115,13 +114,8 @@ export const loginCommand = program }) const { access_token } = await loginWithOtp(userEmail, userPassword, otp, userRegion as string) - - await addNetrcEntry({ - machineName: regionsDomain[userRegion], - login: userEmail, - password: access_token, - region: userRegion, - }) + updateSession(userEmail, access_token, userRegion) + await persistCredentials(regionsDomain[userRegion]) konsola.ok(`Successfully logged in with email ${chalk.hex('#45bfb9')(userEmail)}`) } diff --git a/src/creds.ts b/src/creds.ts index 757077dd..903dd56d 100644 --- a/src/creds.ts +++ b/src/creds.ts @@ -95,15 +95,36 @@ export const getNetrcCredentials = async (filePath: string = getNetrcFilePath()) } } -export const getCredentialsForMachine = (machines: Record = {}, machineName: string) => { - if (machines[machineName]) { - return machines[machineName] - } - else if (machines.default) { - return machines.default +export const getCredentialsForMachine = ( + machines: Record = {}, + machineName?: string, +) => { + if (machineName) { + // Machine name provided + if (machines[machineName]) { + return machines[machineName] + } + else if (machines.default) { + return machines.default + } + else { + return null + } } else { - return null + // No machine name provided + if (machines.default) { + return machines.default + } + else { + const machineNames = Object.keys(machines) + if (machineNames.length > 0) { + return machines[machineNames[0]] + } + else { + return null + } + } } } diff --git a/src/index.ts b/src/index.ts index 99156661..36a2d904 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,14 @@ #!/usr/bin/env node import chalk from 'chalk' +import dotenv from 'dotenv' + import { formatHeader, handleError } 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() console.clear() const introText = chalk.bgHex('#45bfb9').bold.black(` Storyblok CLI `) @@ -19,6 +23,14 @@ program.on('command:*', () => { program.help() }) +program.command('test').action(async () => { + const { state, initializeSession } = session() + + await initializeSession() + + console.log(state) +}) + /* console.log(` ${chalk.hex('#45bfb9')(' ─────╮')} ${chalk.hex('#45bfb9')('│ │')} diff --git a/src/session.test.ts b/src/session.test.ts new file mode 100644 index 00000000..878df2c7 --- /dev/null +++ b/src/session.test.ts @@ -0,0 +1,40 @@ +// session.test.ts +import { session } from './session' + +describe('session initialization with environment variables', () => { + beforeEach(() => { + // Clear environment variables before each test + delete process.env.STORYBLOK_LOGIN + delete process.env.STORYBLOK_TOKEN + delete process.env.STORYBLOK_REGION + delete process.env.TRAVIS_STORYBLOK_LOGIN + delete process.env.TRAVIS_STORYBLOK_TOKEN + delete process.env.TRAVIS_STORYBLOK_REGION + }) + + it('should initialize session from STORYBLOK_ environment variables', async () => { + process.env.STORYBLOK_LOGIN = 'test_login' + process.env.STORYBLOK_TOKEN = 'test_token' + process.env.STORYBLOK_REGION = 'test_region' + + const userSession = session() + await userSession.initializeSession() + expect(userSession.state.isLoggedIn).toBe(true) + expect(userSession.state.login).toBe('test_login') + expect(userSession.state.password).toBe('test_token') + expect(userSession.state.region).toBe('test_region') + }) + + it('should initialize session from TRAVIS_STORYBLOK_ environment variables', async () => { + process.env.TRAVIS_STORYBLOK_LOGIN = 'test_login' + process.env.TRAVIS_STORYBLOK_TOKEN = 'test_token' + process.env.TRAVIS_STORYBLOK_REGION = 'test_region' + + const userSession = session() + await userSession.initializeSession() + expect(userSession.state.isLoggedIn).toBe(true) + expect(userSession.state.login).toBe('test_login') + expect(userSession.state.password).toBe('test_token') + expect(userSession.state.region).toBe('test_region') + }) +}) diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 00000000..1d486152 --- /dev/null +++ b/src/session.ts @@ -0,0 +1,104 @@ +// session.ts +import { addNetrcEntry, getCredentialsForMachine, getNetrcCredentials } from './creds' + +interface SessionState { + isLoggedIn: boolean + login?: string + password?: string + region?: string +} + +let sessionInstance: ReturnType | null = null + +function createSession() { + const state: SessionState = { + isLoggedIn: false, + } + + async function initializeSession(machineName?: string) { + // First, check for environment variables + const envCredentials = getEnvCredentials() + if (envCredentials) { + state.isLoggedIn = true + state.login = envCredentials.login + state.password = envCredentials.password + state.region = envCredentials.region + return + } + + // If no environment variables, fall back to netrc + const machines = await getNetrcCredentials() + const creds = getCredentialsForMachine(machines, machineName) + if (creds) { + state.isLoggedIn = true + state.login = creds.login + state.password = creds.password + state.region = creds.region + } + else { + // No credentials found; set state to logged out + state.isLoggedIn = false + state.login = undefined + state.password = undefined + state.region = undefined + } + } + + function getEnvCredentials() { + const envLogin = process.env.STORYBLOK_LOGIN || process.env.TRAVIS_STORYBLOK_LOGIN + const envPassword = process.env.STORYBLOK_TOKEN || process.env.TRAVIS_STORYBLOK_TOKEN + const envRegion = process.env.STORYBLOK_REGION || process.env.TRAVIS_STORYBLOK_REGION + + if (envLogin && envPassword && envRegion) { + return { + login: envLogin, + password: envPassword, + region: envRegion, + } + } + return null + } + + async function persistCredentials(machineName: string) { + if (state.isLoggedIn && state.login && state.password && state.region) { + await addNetrcEntry({ + machineName, + login: state.login, + password: state.password, + region: state.region, + }) + } + else { + throw new Error('No credentials to save.') + } + } + + function updateSession(login: string, password: string, region: string) { + state.isLoggedIn = true + state.login = login + state.password = password + state.region = region + } + + function logout() { + state.isLoggedIn = false + state.login = undefined + state.password = undefined + state.region = undefined + } + + return { + state, + initializeSession, + updateSession, + persistCredentials, + logout, + } +} + +export function session() { + if (!sessionInstance) { + sessionInstance = createSession() + } + return sessionInstance +} From 32ff580f43d7b4fe1d3d920caf26fb018ae575d3 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Thu, 3 Oct 2024 14:56:08 +0200 Subject: [PATCH 22/47] chore: fix lint --- src/api.test.ts | 2 -- src/commands/login/index.test.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/src/api.test.ts b/src/api.test.ts index b26a56ea..b42b5bc6 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -14,8 +14,6 @@ vi.mock('storyblok-js-client', () => { } }) -// Mocking the session module -const sessionMock = vi.fn() // Mocking the session module vi.mock('./session', () => { let _cache diff --git a/src/commands/login/index.test.ts b/src/commands/login/index.test.ts index d6216649..276cd791 100644 --- a/src/commands/login/index.test.ts +++ b/src/commands/login/index.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { loginWithEmailAndPassword, loginWithOtp, loginWithToken } from './actions' import { loginCommand } from './' -import { addNetrcEntry } from '../../creds' import { konsola } from '../../utils' import { input, password, select } from '@inquirer/prompts' import { regions } from '../../constants' From fb0aa164beb94a66743f2c333c1868fecb7ea35b Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Fri, 4 Oct 2024 09:14:35 +0200 Subject: [PATCH 23/47] chore: improve auth code login message --- 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 8da0e0a7..a3a5f622 100644 --- a/src/commands/login/index.ts +++ b/src/commands/login/index.ts @@ -109,7 +109,7 @@ export const loginCommand = program if (otp_required) { const otp = await input({ - message: 'We sent a code to your email / phone, please insert the authentication code:', + message: 'Add the code from your Authenticator app, or the one we sent to your e-mail / phone:', required: true, }) From 54df30ec34002d3d5e8f6d9fff3d697731847724 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Fri, 4 Oct 2024 09:15:09 +0200 Subject: [PATCH 24/47] chore: increase session coverage --- src/session.test.ts | 138 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 108 insertions(+), 30 deletions(-) diff --git a/src/session.test.ts b/src/session.test.ts index 878df2c7..dbd0ad2f 100644 --- a/src/session.test.ts +++ b/src/session.test.ts @@ -1,40 +1,118 @@ // session.test.ts import { session } from './session' -describe('session initialization with environment variables', () => { +import { getCredentialsForMachine } from './creds' + +vi.mock('./creds', () => ({ + getNetrcCredentials: vi.fn(), + getCredentialsForMachine: vi.fn(), +})) + +describe('session', () => { beforeEach(() => { - // Clear environment variables before each test - delete process.env.STORYBLOK_LOGIN - delete process.env.STORYBLOK_TOKEN - delete process.env.STORYBLOK_REGION - delete process.env.TRAVIS_STORYBLOK_LOGIN - delete process.env.TRAVIS_STORYBLOK_TOKEN - delete process.env.TRAVIS_STORYBLOK_REGION + vi.resetAllMocks() + vi.clearAllMocks() }) + describe('session initialization with netrc', () => { + it('should initialize session with netrc credentials', async () => { + getCredentialsForMachine.mockReturnValue({ + login: 'test_login', + password: 'test_token', + region: 'test_region', + }) + const userSession = session() + await userSession.initializeSession() + expect(userSession.state.isLoggedIn).toBe(true) + expect(userSession.state.login).toBe('test_login') + expect(userSession.state.password).toBe('test_token') + expect(userSession.state.region).toBe('test_region') + }) + it('should initialize session with netrc credentials for a specific machine', async () => { + getCredentialsForMachine.mockReturnValue({ + login: 'test_login', + password: 'test_token', + region: 'test_region', + }) + const userSession = session() + await userSession.initializeSession('test-machine') + expect(userSession.state.isLoggedIn).toBe(true) + expect(userSession.state.login).toBe('test_login') + expect(userSession.state.password).toBe('test_token') + expect(userSession.state.region).toBe('test_region') + }) + + it('should initialize session with netrc credentials for a specific machine when no matching machine is present', async () => { + getCredentialsForMachine.mockReturnValue(undefined) + const userSession = session() + await userSession.initializeSession('nonexistent-machine') + expect(userSession.state.isLoggedIn).toBe(false) + expect(userSession.state.login).toBe(undefined) + expect(userSession.state.password).toBe(undefined) + expect(userSession.state.region).toBe(undefined) + }) + /* + it('should initialize session with netrc credentials for a specific machine', async () => { + const userSession = session() + await userSession.initializeSession('test-machine') + expect(userSession.state.isLoggedIn).toBe(true) + expect(userSession.state.login).toBe('test_login') + expect(userSession.state.password).toBe('test_token') + expect(userSession.state.region).toBe('test_region') + }) + + it('should initialize session with netrc credentials for a specific machine when multiple machines are present', async () => { + const userSession = session() + await userSession.initializeSession('test-machine-2') + expect(userSession.state.isLoggedIn).toBe(true) + expect(userSession.state.login).toBe('test_login_2') + expect(userSession.state.password).toBe('test_token_2') + expect(userSession.state.region).toBe('test_region_2') + }) - it('should initialize session from STORYBLOK_ environment variables', async () => { - process.env.STORYBLOK_LOGIN = 'test_login' - process.env.STORYBLOK_TOKEN = 'test_token' - process.env.STORYBLOK_REGION = 'test_region' - - const userSession = session() - await userSession.initializeSession() - expect(userSession.state.isLoggedIn).toBe(true) - expect(userSession.state.login).toBe('test_login') - expect(userSession.state.password).toBe('test_token') - expect(userSession.state.region).toBe('test_region') + it('should initialize session with netrc credentials for a specific machine when no matching machine is present', async () => { + const userSession = session() + await userSession.initializeSession('nonexistent-machine') + expect(userSession.state.isLoggedIn).toBe(false) + expect(userSession.state.login).toBe(undefined) + expect(userSession.state.password).toBe(undefined) + expect(userSession.state.region).toBe(undefined) + }) */ }) + describe('session initialization with environment variables', () => { + beforeEach(() => { + // Clear environment variables before each test + delete process.env.STORYBLOK_LOGIN + delete process.env.STORYBLOK_TOKEN + delete process.env.STORYBLOK_REGION + delete process.env.TRAVIS_STORYBLOK_LOGIN + delete process.env.TRAVIS_STORYBLOK_TOKEN + delete process.env.TRAVIS_STORYBLOK_REGION + }) + + it('should initialize session from STORYBLOK_ environment variables', async () => { + process.env.STORYBLOK_LOGIN = 'test_login' + process.env.STORYBLOK_TOKEN = 'test_token' + process.env.STORYBLOK_REGION = 'test_region' + + const userSession = session() + await userSession.initializeSession() + expect(userSession.state.isLoggedIn).toBe(true) + expect(userSession.state.login).toBe('test_login') + expect(userSession.state.password).toBe('test_token') + expect(userSession.state.region).toBe('test_region') + }) + + it('should initialize session from TRAVIS_STORYBLOK_ environment variables', async () => { + process.env.TRAVIS_STORYBLOK_LOGIN = 'test_login' + process.env.TRAVIS_STORYBLOK_TOKEN = 'test_token' + process.env.TRAVIS_STORYBLOK_REGION = 'test_region' - it('should initialize session from TRAVIS_STORYBLOK_ environment variables', async () => { - process.env.TRAVIS_STORYBLOK_LOGIN = 'test_login' - process.env.TRAVIS_STORYBLOK_TOKEN = 'test_token' - process.env.TRAVIS_STORYBLOK_REGION = 'test_region' - - const userSession = session() - await userSession.initializeSession() - expect(userSession.state.isLoggedIn).toBe(true) - expect(userSession.state.login).toBe('test_login') - expect(userSession.state.password).toBe('test_token') - expect(userSession.state.region).toBe('test_region') + const userSession = session() + await userSession.initializeSession() + expect(userSession.state.isLoggedIn).toBe(true) + expect(userSession.state.login).toBe('test_login') + expect(userSession.state.password).toBe('test_token') + expect(userSession.state.region).toBe('test_region') + }) }) }) From 7e448ae0fe73e4d2b6cd8d4f5dd3658abf751112 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Wed, 9 Oct 2024 17:44:00 +0200 Subject: [PATCH 25/47] 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 26/47] 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 27/47] 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 28/47] 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 29/47] 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 30/47] 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 31/47] 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 32/47] 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 33/47] 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 7449f5f83abf47e653e2758d53c8c700fd964041 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Tue, 15 Oct 2024 16:28:42 +0200 Subject: [PATCH 34/47] 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 35/47] 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 36/47] 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 37/47] 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 38/47] 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 39/47] 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 40/47] 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 41/47] 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 42/47] 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 43/47] 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 44/47] 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 45/47] 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 46/47] 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 47/47] 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,