Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: env management commands #37

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
node_modules
dist
coverage
.tsimp
.tsimp

.idea
.vscode
36 changes: 22 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@
],
"dependencies": {
"clipboardy": "^4.0.0",
"delay": "^6.0.0",
"fuse.js": "^7.0.0",
"ink": "^5.0.1",
"ink": "^5.1.0",
"ink-ascii": "^0.0.4",
"ink-big-text": "^2.0.0",
"ink-gradient": "^3.0.0",
Expand Down
270 changes: 270 additions & 0 deletions source/commands/env/copy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import React, { useEffect, useState } from 'react';
import { Text } from 'ink';
import { option } from 'pastel';
import { TextInput } from '@inkjs/ui';
import { TokenType, tokenType } from '../../lib/auth.js';
import zod from 'zod';
import { type infer as zInfer } from 'zod';
import { useApiKeyApi } from '../../hooks/useApiKeyApi.js';
import { useEnvironmentApi } from '../../hooks/useEnvironmentApi.js';
import EnvironmentSelection, {
ActiveState,
} from '../../components/EnvironmentSelection.js';
import SelectInput from 'ink-select-input';

export const options = zod.object({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The structure of the inputs needs to make true sense for consistent flow.

Here are the following variables that should be supported:

  • key(string) - API Key to be used for the environment copying (should be at least a project level key)
  • from(string) - Optional: set the environment ID to copy from. In case not set, the CLI lets you select one.
  • name(string) - Optional: The environment name to copy to. In case not set, the CLI will ask you for one.
  • description(string) - Optional: The new environment description. In case not set, the CLI will ask you for it.
  • to(string) - Optional: copy the environment to an existing environment. In case this variable is set, the 'name' and 'description' variables will be ignored'
  • conflictStrategy(enum | "fail" "overwrite") - Optional: Set the environment conflict strategy. In case not set, will use "fail"

The logic should be:

  • If all variables are provided (name/description or existing), just perform the copy
  • If any of the variables are provided, skip the step of configuring it
  • If the existing provided together with name/description, show an error
  • If the existing id is provided and it's not exist, show an error
  • Two steps now are redundant:
    • Choose existing environment (should be provided as variable, if not its mean the wizard should copy the new one)
    • Choose conflict strategy (default should be fail, if someone wants to change it, they need to pass it as a variable)

key: zod.string().describe(
option({
description:
'API Key to be used for the environment copying (should be least an project level key)',
}),
),
existing: zod
.boolean()
.optional()
.describe(
option({
description:
'Provide this API Key if you want to copy to an existing account',
}),
),
envName: zod
.string()
.optional()
.describe(
option({
description: 'Name for the new environment to copy to',
}),
),
envDescription: zod
.string()
.optional()
.describe(
option({
description: 'Description for the new environment to copy to',
}),
),
conflictStrategy: zod
.string()
.optional()
.describe(
option({
description: 'Conflict Strategy to use.',
}),
),
});

type Props = {
readonly options: zInfer<typeof options>;
};

interface EnvCopyBody {
existingEnvId?: string | null;
newEnvKey?: string | null;
newEnvName?: string | null;
newEnvDescription?: string | null;
conflictStrategy?: string | null;
}

export default function Copy({
options: { key: apiKey, existing, envName, envDescription, conflictStrategy },
}: Props) {
const [error, setError] = React.useState<string | null>(null);
const [authToken, setAuthToken] = React.useState<string | null>(null);
const [state, setState] = useState<
| 'loading'
| 'selecting-id'
| 'selecting-name'
| 'selecting-description'
| 'selecting-strategy'
| 'copying'
| 'done'
>('loading');
const [projectFrom, setProjectFrom] = useState<string | null>(null);
const [envToId, setEnvToId] = useState<string | null>(null);
const [envToName, setEnvToName] = useState<string | undefined>(envName);
const [envFrom, setEnvFrom] = useState<string | null>(null);
const [envToDescription, setEnvToDescription] = useState<string | undefined>(
envDescription,
);
const [envToConflictStrategy, setEnvToConflictStrategy] = useState<
string | undefined
>(conflictStrategy);

const { getApiKeyScope } = useApiKeyApi();
const { copyEnvironment } = useEnvironmentApi();

useEffect(() => {
if (error || state === 'done') {
process.exit(1);
}
}, [error, state]);

useEffect(() => {
const handleEnvCopy = async (envCopyBody: EnvCopyBody) => {
let body = {};
if (envCopyBody.existingEnvId) {
body = {
target_env: { existing: envCopyBody.existingEnvId },
};
} else if (envCopyBody.newEnvKey && envCopyBody.newEnvName) {
body = {
target_env: {
new: {
key: envCopyBody.newEnvKey,
35C4n0r marked this conversation as resolved.
Show resolved Hide resolved
name: envCopyBody.newEnvName,
description: envCopyBody.newEnvDescription ?? '',
},
},
};
}
if (envToConflictStrategy) {
body = {
...body,
conflict_strategy: envCopyBody.conflictStrategy ?? 'fail',
};
}
const { error } = await copyEnvironment(
projectFrom ?? '',
envFrom ?? '',
apiKey,
null,
body,
);
if (error) {
setError(`Error while copying Environment: ${error}`);
return;
}
setState('done');
};

if (
((envToName && envToDescription && envToConflictStrategy) ||
envName ||
envToId) &&
envFrom
) {
setState('copying');
handleEnvCopy({
newEnvKey: envToName,
newEnvName: envToName,
newEnvDescription: envToDescription,
existingEnvId: envToId,
conflictStrategy: envToConflictStrategy,
});
}
}, [
envToId,
existing,
envToName,
envFrom,
envToDescription,
envToConflictStrategy,
envName,
copyEnvironment,
projectFrom,
apiKey,
]);

useEffect(() => {
// Step 1, we use the API Key provided by the user &
// checks if the api_key scope >= project_level &
// sets the apiKey and sets the projectFrom

const validateApiKeyScope = async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hook should be external and be shared with other project-level commands such as select

const { response: scope, error } = await getApiKeyScope(apiKey);
if (error) setError(error);
if (scope.environment_id) {
setError('Please provide a Project level token or above');
return;
} else {
setProjectFrom(scope.project_id);
setAuthToken(apiKey);
}
};

if (apiKey && tokenType(apiKey) === TokenType.APIToken) {
validateApiKeyScope();
} else {
setError('Invalid API Key. Please provide a valid API Key.');
return;
}
}, [apiKey, getApiKeyScope]);

const handleEnvFromSelection = (
_organisation_id: ActiveState,
_project_id: ActiveState,
environment_id: ActiveState,
) => {
setEnvFrom(environment_id.value);
if (existing) {
setState('selecting-id');
} else if (!envName) {
setState('selecting-name');
}
};

return (
<>
{state === 'loading' && authToken && (
<>
<Text>Select an existing Environment to copy from.</Text>
<EnvironmentSelection
accessToken={authToken}
cookie={''}
onComplete={handleEnvFromSelection}
onError={setError}
/>
</>
)}
{authToken && state === 'selecting-id' && envFrom && (
<>
<Text>Input the existing EnvironmentId to copy to.</Text>
<TextInput onSubmit={setEnvToId} placeholder={'Enter Id here...'} />
</>
)}
{authToken && state === 'selecting-name' && envFrom && (
<>
<Text>Input the new Environment name to copy to.</Text>
<TextInput
onSubmit={name => {
setEnvToName(name);
setState('selecting-description');
}}
placeholder={'Enter name here...'}
/>
</>
)}
{authToken && state === 'selecting-description' && (
<>
<Text>Input the new Environment Description.</Text>
<TextInput
onSubmit={description => {
setEnvToDescription(description);
setState('selecting-strategy');
}}
placeholder={'Enter description here...'}
/>
</>
)}
{authToken && state === 'selecting-strategy' && (
<>
<Text>Select the conflict strategy</Text>
<SelectInput
onSelect={strategy => {
setEnvToConflictStrategy(strategy.value);
setState('copying');
}}
items={[
{ label: 'fail', value: 'fail' },
{ label: 'overwrite', value: 'overwrite' },
]}
/>
</>
)}

{state === 'done' && <Text>Environment copied successfully</Text>}
{error && <Text>{error}</Text>}
</>
);
}
Loading
Loading