From ea8a047105c022e0fac8cbe528a0c2d2454fb1a6 Mon Sep 17 00:00:00 2001 From: Vishwanath Martur <64204611+vishwamartur@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:23:22 +0530 Subject: [PATCH] Add environment management commands Related to #18 Implement environment management commands for Permit CLI. * Add `env.tsx` in `source/commands` directory to implement the required commands: - `permit env select` uses the existing component from #28. - `permit env copy` takes user input via a component and flags: `--target`, `--conflictStrategy`, and `--scope`. - `permit env member` uses a new `useMemberApi` hook to add users with roles. * Create a hook to get a project-level access token with write permissions for `permit env copy`. * Add `useMemberApi.ts` to create a new hook to add users with roles interactively or via flags. * Add `useProjectToken.ts` to create a hook to get a project-level access token with write permissions. * Add unit tests in `env.test.tsx` for `permit env select`, `permit env copy`, and `permit env member` commands. * Add e2e tests in `env.e2e.tsx` for the basic flow of each command to validate their functionality. --- e2e/env.e2e.tsx | 24 +++++++ source/commands/env.tsx | 108 ++++++++++++++++++++++++++++++++ source/hooks/useMemberApi.ts | 34 ++++++++++ source/hooks/useProjectToken.ts | 30 +++++++++ test/env.test.tsx | 44 +++++++++++++ 5 files changed, 240 insertions(+) create mode 100644 e2e/env.e2e.tsx create mode 100644 source/commands/env.tsx create mode 100644 source/hooks/useMemberApi.ts create mode 100644 source/hooks/useProjectToken.ts create mode 100644 test/env.test.tsx diff --git a/e2e/env.e2e.tsx b/e2e/env.e2e.tsx new file mode 100644 index 0000000..9d32f1c --- /dev/null +++ b/e2e/env.e2e.tsx @@ -0,0 +1,24 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Environment Management Commands', () => { + test('permit env select', async ({ page }) => { + await page.goto('http://localhost:3000'); + await page.click('text=permit env select'); + await page.waitForSelector('text=Select an environment'); + expect(await page.textContent('text=Select an environment')).toBeTruthy(); + }); + + test('permit env copy', async ({ page }) => { + await page.goto('http://localhost:3000'); + await page.click('text=permit env copy'); + await page.waitForSelector('text=Copying environment...'); + expect(await page.textContent('text=Copying environment...')).toBeTruthy(); + }); + + test('permit env member', async ({ page }) => { + await page.goto('http://localhost:3000'); + await page.click('text=permit env member'); + await page.waitForSelector('text=Adding member...'); + expect(await page.textContent('text=Adding member...')).toBeTruthy(); + }); +}); diff --git a/source/commands/env.tsx b/source/commands/env.tsx new file mode 100644 index 0000000..3a3a8da --- /dev/null +++ b/source/commands/env.tsx @@ -0,0 +1,108 @@ +import React, { useState, useEffect } from 'react'; +import { Text, Box } from 'ink'; +import SelectInput from 'ink-select-input'; +import Spinner from 'ink-spinner'; +import { z } from 'zod'; +import { apiCall } from '../lib/api.js'; +import { useAuth } from '../components/AuthProvider.js'; +import { useMemberApi } from '../hooks/useMemberApi.js'; +import { useProjectToken } from '../hooks/useProjectToken.js'; + +export const args = z.tuple([ + z.enum(['select', 'copy', 'member']).describe('Environment management command'), +]); + +export const options = z.object({ + target: z.string().optional().describe('Target environment for copy command'), + conflictStrategy: z + .enum(['overwrite', 'skip']) + .optional() + .describe('Conflict strategy for copy command'), + scope: z.string().optional().describe('Scope for copy command'), + memberEmail: z.string().optional().describe('Email of the member to add'), + memberRole: z.string().optional().describe('Role of the member to add'), +}); + +type Props = { + args: z.infer; + options: z.infer; +}; + +export default function Env({ args, options }: Props) { + const command = args[0]; + const { authToken } = useAuth(); + const { addMember } = useMemberApi(); + const { getProjectToken } = useProjectToken(); + const [state, setState] = useState< + 'select' | 'copy' | 'member' | 'loading' | 'done' + >('loading'); + const [environments, setEnvironments] = useState<[]>([]); + const [selectedEnv, setSelectedEnv] = useState(null); + + useEffect(() => { + const fetchEnvironments = async () => { + const { response: envs } = await apiCall('v2/envs', authToken ?? ''); + setEnvironments(envs.map((env: any) => ({ label: env.name, value: env.id }))); + setState(command); + }; + + if (authToken) { + fetchEnvironments(); + } + }, [authToken, command]); + + const handleEnvSelect = async (env: any) => { + setSelectedEnv(env); + setState('done'); + }; + + const handleCopyEnv = async () => { + const projectToken = await getProjectToken(); + await apiCall( + `v2/envs/${selectedEnv.value}/copy`, + projectToken, + undefined, + 'POST', + JSON.stringify({ + target: options.target, + conflictStrategy: options.conflictStrategy, + scope: options.scope, + }), + ); + setState('done'); + }; + + const handleAddMember = async () => { + await addMember(options.memberEmail, options.memberRole); + setState('done'); + }; + + return ( + <> + {state === 'loading' && ( + + Loading Environments... + + )} + {state === 'select' && ( + <> + Select an environment + + + )} + {state === 'copy' && selectedEnv && ( + <> + Copying environment... + {handleCopyEnv()} + + )} + {state === 'member' && ( + <> + Adding member... + {handleAddMember()} + + )} + {state === 'done' && Operation completed successfully.} + + ); +} diff --git a/source/hooks/useMemberApi.ts b/source/hooks/useMemberApi.ts new file mode 100644 index 0000000..e047c7d --- /dev/null +++ b/source/hooks/useMemberApi.ts @@ -0,0 +1,34 @@ +import { useState } from 'react'; +import { apiCall } from '../lib/api.js'; +import { useAuth } from '../components/AuthProvider.js'; + +export const useMemberApi = () => { + const { authToken } = useAuth(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const addMember = async (email: string, role: string) => { + setLoading(true); + setError(null); + + try { + await apiCall( + 'v2/members', + authToken ?? '', + undefined, + 'POST', + JSON.stringify({ email, role }) + ); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }; + + return { + addMember, + loading, + error, + }; +}; diff --git a/source/hooks/useProjectToken.ts b/source/hooks/useProjectToken.ts new file mode 100644 index 0000000..c32deda --- /dev/null +++ b/source/hooks/useProjectToken.ts @@ -0,0 +1,30 @@ +import { useState } from 'react'; +import { apiCall } from '../lib/api.js'; +import { useAuth } from '../components/AuthProvider.js'; + +export const useProjectToken = () => { + const { authToken } = useAuth(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const getProjectToken = async () => { + setLoading(true); + setError(null); + + try { + const { response } = await apiCall('v2/projects/token', authToken ?? ''); + return response.token; + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return null; + } finally { + setLoading(false); + } + }; + + return { + getProjectToken, + loading, + error, + }; +}; diff --git a/test/env.test.tsx b/test/env.test.tsx new file mode 100644 index 0000000..c2e969f --- /dev/null +++ b/test/env.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { test } from 'node:test'; +import { render } from 'ink-testing-library'; +import assert from 'node:assert'; +import Env from '../source/commands/env.js'; + +test('env select', t => { + t.test('Should display environment selection', async () => { + const { lastFrame } = render( + , + ); + const res = lastFrame(); + assert.match(res, /Select an environment/); + }); +}); + +test('env copy', t => { + t.test('Should display copying environment', async () => { + const { lastFrame } = render( + , + ); + const res = lastFrame(); + assert.match(res, /Copying environment/); + }); +}); + +test('env member', t => { + t.test('Should display adding member', async () => { + const { lastFrame } = render( + , + ); + const res = lastFrame(); + assert.match(res, /Adding member/); + }); +});