From 81047b7ead371922c73738edf6eacb2b6d8c4610 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Mon, 23 Sep 2024 12:48:30 +0200 Subject: [PATCH] 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 }, }