-
Notifications
You must be signed in to change notification settings - Fork 29
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
1 parent
2b51a74
commit ea8a047
Showing
5 changed files
with
240 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>} | ||
</> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/); | ||
}); | ||
}); |