Skip to content

Commit

Permalink
Add environment management commands
Browse files Browse the repository at this point in the history
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.

Signed-off-by: Vishwanath Martur <[email protected]>
  • Loading branch information
vishwamartur committed Nov 15, 2024
1 parent 2b51a74 commit f347edb
Show file tree
Hide file tree
Showing 5 changed files with 240 additions and 0 deletions.
24 changes: 24 additions & 0 deletions e2e/env.e2e.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
108 changes: 108 additions & 0 deletions source/commands/env.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof args>;
options: z.infer<typeof options>;
};

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<any | undefined>(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' && (
<Text>
<Spinner type="dots" /> Loading Environments...
</Text>
)}
{state === 'select' && (
<>
<Text>Select an environment</Text>
<SelectInput items={environments} onSelect={handleEnvSelect} />
</>
)}
{state === 'copy' && selectedEnv && (
<>
<Text>Copying environment...</Text>
{handleCopyEnv()}
</>
)}
{state === 'member' && (
<>
<Text>Adding member...</Text>
{handleAddMember()}
</>
)}
{state === 'done' && <Text>Operation completed successfully.</Text>}
</>
);
}
34 changes: 34 additions & 0 deletions source/hooks/useMemberApi.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(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,
};
};
30 changes: 30 additions & 0 deletions source/hooks/useProjectToken.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(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,
};
};
44 changes: 44 additions & 0 deletions test/env.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Env
args={['select']}
options={{}}
/>,
);
const res = lastFrame();
assert.match(res, /Select an environment/);
});
});

test('env copy', t => {
t.test('Should display copying environment', async () => {
const { lastFrame } = render(
<Env
args={['copy']}
options={{ target: 'new-env', conflictStrategy: 'overwrite', scope: 'all' }}
/>,
);
const res = lastFrame();
assert.match(res, /Copying environment/);
});
});

test('env member', t => {
t.test('Should display adding member', async () => {
const { lastFrame } = render(
<Env
args={['member']}
options={{ memberEmail: '[email protected]', memberRole: 'admin' }}
/>,
);
const res = lastFrame();
assert.match(res, /Adding member/);
});
});

0 comments on commit f347edb

Please sign in to comment.