fix: use env keys (#2258)
Co-authored-by: Zaki Ali <zaki@squareup.com> Co-authored-by: Kalvin C <kalvinnchau@users.noreply.github.com>
This commit is contained in:
@@ -77,6 +77,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/config/backup": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"super::routes::config_management"
|
||||
],
|
||||
"operationId": "backup_config",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Config file backed up",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/config/extensions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -466,6 +489,12 @@
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"env_keys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"envs": {
|
||||
"$ref": "#/components/schemas/Envs"
|
||||
},
|
||||
@@ -518,6 +547,12 @@
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"env_keys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"envs": {
|
||||
"$ref": "#/components/schemas/Envs"
|
||||
},
|
||||
|
||||
+23
-7
@@ -34,7 +34,7 @@ import { addExtension as addExtensionDirect, FullExtensionConfig } from './exten
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useConfig, MalformedConfigError } from './components/ConfigContext';
|
||||
import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './components/settings_v2/extensions';
|
||||
import { initConfig } from './api/sdk.gen';
|
||||
import { backupConfig, initConfig, readAllConfig } from './api/sdk.gen';
|
||||
import PermissionSettingsView from './components/settings_v2/permission/PermissionSetting';
|
||||
|
||||
// Views and their options
|
||||
@@ -240,7 +240,7 @@ export default function App() {
|
||||
console.log('Finished enabling bot config extensions');
|
||||
};
|
||||
|
||||
const enableRecipeConfigExtensionsV2 = useCallback(
|
||||
const _enableRecipeConfigExtensionsV2 = useCallback(
|
||||
async (extensions: FullExtensionConfig[]) => {
|
||||
if (!extensions?.length) {
|
||||
console.log('No extensions to enable from bot config');
|
||||
@@ -299,9 +299,25 @@ export default function App() {
|
||||
|
||||
const initializeApp = async () => {
|
||||
try {
|
||||
// Initialize config first
|
||||
// checks if there is a config, and if not creates it
|
||||
await initConfig();
|
||||
|
||||
// now try to read config, if we fail and are migrating backup, then re-init config
|
||||
try {
|
||||
await readAllConfig({ throwOnError: true });
|
||||
} catch (error) {
|
||||
// NOTE: we do this check here and in providerUtils.ts, be sure to clean up both in the future
|
||||
const configVersion = localStorage.getItem('configVersion');
|
||||
const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3;
|
||||
if (shouldMigrateExtensions) {
|
||||
await backupConfig({ throwOnError: true });
|
||||
await initConfig();
|
||||
} else {
|
||||
// if we've migrated throw this back up
|
||||
throw new Error('Unable to read config file, it may be malformed');
|
||||
}
|
||||
}
|
||||
|
||||
// note: if in a non recipe session, recipeConfig is undefined, otherwise null if error
|
||||
if (recipeConfig === null) {
|
||||
setFatalError('Cannot read recipe config. Please check the deeplink and try again.');
|
||||
@@ -309,10 +325,10 @@ export default function App() {
|
||||
}
|
||||
|
||||
// Handle bot config extensions first
|
||||
if (recipeConfig?.extensions?.length > 0 && viewType != 'recipeEditor') {
|
||||
console.log('Found extensions in bot config:', recipeConfig.extensions);
|
||||
await enableRecipeConfigExtensionsV2(recipeConfig.extensions);
|
||||
}
|
||||
// if (recipeConfig?.extensions?.length > 0 && viewType != 'recipeEditor') {
|
||||
// console.log('Found extensions in bot config:', recipeConfig.extensions);
|
||||
// await enableRecipeConfigExtensionsV2(recipeConfig.extensions);
|
||||
// }
|
||||
|
||||
const config = window.electron.getConfig();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
|
||||
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData } from './types.gen';
|
||||
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData } from './types.gen';
|
||||
import { client as _heyApiClient } from './client.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
|
||||
@@ -32,6 +32,13 @@ export const readAllConfig = <ThrowOnError extends boolean = false>(options?: Op
|
||||
});
|
||||
};
|
||||
|
||||
export const backupConfig = <ThrowOnError extends boolean = false>(options?: Options<BackupConfigData, ThrowOnError>) => {
|
||||
return (options?.client ?? _heyApiClient).post<BackupConfigResponse, unknown, ThrowOnError>({
|
||||
url: '/config/backup',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const getExtensions = <ThrowOnError extends boolean = false>(options?: Options<GetExtensionsData, ThrowOnError>) => {
|
||||
return (options?.client ?? _heyApiClient).get<GetExtensionsResponse, unknown, ThrowOnError>({
|
||||
url: '/config/extensions',
|
||||
|
||||
@@ -29,6 +29,7 @@ export type ExtensionConfig = {
|
||||
*/
|
||||
bundled?: boolean | null;
|
||||
description?: string | null;
|
||||
env_keys?: Array<string>;
|
||||
envs?: Envs;
|
||||
/**
|
||||
* The name used to identify this extension
|
||||
@@ -45,6 +46,7 @@ export type ExtensionConfig = {
|
||||
bundled?: boolean | null;
|
||||
cmd: string;
|
||||
description?: string | null;
|
||||
env_keys?: Array<string>;
|
||||
envs?: Envs;
|
||||
/**
|
||||
* The name used to identify this extension
|
||||
@@ -327,6 +329,29 @@ export type ReadAllConfigResponses = {
|
||||
|
||||
export type ReadAllConfigResponse = ReadAllConfigResponses[keyof ReadAllConfigResponses];
|
||||
|
||||
export type BackupConfigData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/config/backup';
|
||||
};
|
||||
|
||||
export type BackupConfigErrors = {
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type BackupConfigResponses = {
|
||||
/**
|
||||
* Config file backed up
|
||||
*/
|
||||
200: string;
|
||||
};
|
||||
|
||||
export type BackupConfigResponse = BackupConfigResponses[keyof BackupConfigResponses];
|
||||
|
||||
export type GetExtensionsData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -7,10 +7,10 @@ import Back from './icons/Back';
|
||||
import { Bars } from './icons/Bars';
|
||||
import { Geese } from './icons/Geese';
|
||||
import Copy from './icons/Copy';
|
||||
import { Check } from 'lucide-react';
|
||||
import { useConfig } from './ConfigContext';
|
||||
import { FixedExtensionEntry } from './ConfigContext';
|
||||
import ExtensionList from './settings_v2/extensions/subcomponents/ExtensionList';
|
||||
import { Check } from 'lucide-react';
|
||||
// import ExtensionList from './settings_v2/extensions/subcomponents/ExtensionList';
|
||||
|
||||
interface RecipeEditorProps {
|
||||
config?: Recipe;
|
||||
@@ -30,11 +30,11 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
const [instructions, setInstructions] = useState(config?.instructions || '');
|
||||
const [activities, setActivities] = useState<string[]>(config?.activities || []);
|
||||
const [extensionOptions, setExtensionOptions] = useState<FixedExtensionEntry[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [extensionsLoaded, setExtensionsLoaded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Initialize selected extensions for the recipe from config or localStorage
|
||||
const [recipeExtensions, setRecipeExtensions] = useState<string[]>(() => {
|
||||
const [recipeExtensions] = useState<string[]>(() => {
|
||||
// First try to get from localStorage
|
||||
const stored = localStorage.getItem('recipe_editor_extensions');
|
||||
if (stored) {
|
||||
@@ -95,20 +95,20 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [recipeExtensions, extensionsLoaded]);
|
||||
|
||||
const handleExtensionToggle = (extension: FixedExtensionEntry) => {
|
||||
console.log('Toggling extension:', extension.name);
|
||||
setRecipeExtensions((prev) => {
|
||||
const isSelected = prev.includes(extension.name);
|
||||
const newState = isSelected
|
||||
? prev.filter((extName) => extName !== extension.name)
|
||||
: [...prev, extension.name];
|
||||
// const handleExtensionToggle = (extension: FixedExtensionEntry) => {
|
||||
// console.log('Toggling extension:', extension.name);
|
||||
// setRecipeExtensions((prev) => {
|
||||
// const isSelected = prev.includes(extension.name);
|
||||
// const newState = isSelected
|
||||
// ? prev.filter((extName) => extName !== extension.name)
|
||||
// : [...prev, extension.name];
|
||||
|
||||
// Persist to localStorage
|
||||
localStorage.setItem('recipe_editor_extensions', JSON.stringify(newState));
|
||||
// // Persist to localStorage
|
||||
// localStorage.setItem('recipe_editor_extensions', JSON.stringify(newState));
|
||||
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
// return newState;
|
||||
// });
|
||||
// };
|
||||
|
||||
const handleAddActivity = () => {
|
||||
if (newActivity.trim()) {
|
||||
@@ -143,14 +143,9 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
// Create a clean copy of the extension configuration
|
||||
const cleanExtension = { ...extension };
|
||||
delete cleanExtension.enabled;
|
||||
|
||||
// If the extension has env_keys, preserve keys but clear values
|
||||
if (cleanExtension.env_keys) {
|
||||
cleanExtension.env_keys = Object.fromEntries(
|
||||
Object.keys(cleanExtension.env_keys).map((key) => [key, ''])
|
||||
);
|
||||
}
|
||||
|
||||
// Remove legacy envs which could potentially include secrets
|
||||
// env_keys will work but rely on the end user having setup those keys themselves
|
||||
delete cleanExtension.envs;
|
||||
return cleanExtension;
|
||||
})
|
||||
.filter(Boolean) as FullExtensionConfig[],
|
||||
@@ -173,31 +168,13 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleOpenAgent = () => {
|
||||
if (validateForm()) {
|
||||
const updatedConfig = getCurrentConfig();
|
||||
// Clear stored extensions when submitting
|
||||
localStorage.removeItem('recipe_editor_extensions');
|
||||
window.electron.createChatWindow(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
updatedConfig,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const deeplink = generateDeepLink(getCurrentConfig());
|
||||
|
||||
const handleCopy = () => {
|
||||
// Copy the text to the clipboard
|
||||
navigator.clipboard
|
||||
.writeText(deeplink)
|
||||
.then(() => {
|
||||
setCopied(true); // Show the check mark
|
||||
// Reset to normal after 2 seconds (2000 milliseconds)
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -293,30 +270,30 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'extensions':
|
||||
return (
|
||||
<div className="p-6 pt-10">
|
||||
<button onClick={() => setActiveSection('none')} className="mb-6">
|
||||
<Back className="w-6 h-6 text-iconProminent" />
|
||||
</button>
|
||||
<div className="py-2">
|
||||
<Bars className="w-6 h-6 text-iconSubtle" />
|
||||
</div>
|
||||
<div className="mb-8 mt-6">
|
||||
<h2 className="text-2xl font-medium mb-2 text-textProminent">Extensions</h2>
|
||||
<p className="text-textSubtle">Select extensions to bundle in the recipe</p>
|
||||
</div>
|
||||
{extensionsLoaded ? (
|
||||
<ExtensionList
|
||||
extensions={extensionOptions}
|
||||
onToggle={handleExtensionToggle}
|
||||
isStatic={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8 text-textSubtle">Loading extensions...</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// case 'extensions':
|
||||
// return (
|
||||
// <div className="p-6 pt-10">
|
||||
// <button onClick={() => setActiveSection('none')} className="mb-6">
|
||||
// <Back className="w-6 h-6 text-iconProminent" />
|
||||
// </button>
|
||||
// <div className="py-2">
|
||||
// <Bars className="w-6 h-6 text-iconSubtle" />
|
||||
// </div>
|
||||
// <div className="mb-8 mt-6">
|
||||
// <h2 className="text-2xl font-medium mb-2 text-textProminent">Extensions</h2>
|
||||
// <p className="text-textSubtle">Select extensions to bundle in the recipe</p>
|
||||
// </div>
|
||||
// {extensionsLoaded ? (
|
||||
// <ExtensionList
|
||||
// extensions={extensionOptions}
|
||||
// onToggle={handleExtensionToggle}
|
||||
// isStatic={true}
|
||||
// />
|
||||
// ) : (
|
||||
// <div className="text-center py-8 text-textSubtle">Loading extensions...</div>
|
||||
// )}
|
||||
// </div>
|
||||
// );
|
||||
|
||||
default:
|
||||
return (
|
||||
@@ -385,7 +362,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
{/* <button
|
||||
onClick={() => setActiveSection('extensions')}
|
||||
className="w-full flex items-start justify-between p-4 border border-borderSubtle rounded-lg bg-bgApp hover:bg-bgSubtle"
|
||||
>
|
||||
@@ -396,33 +373,48 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 mt-1 text-iconSubtle" />
|
||||
</button>
|
||||
</button> */}
|
||||
|
||||
{/* Deep Link Display */}
|
||||
<div className="w-full p-4 bg-bgSubtle rounded-lg flex items-center justify-between">
|
||||
<code className="text-sm text-textSubtle truncate">{deeplink}</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="ml-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!title.trim() || !description.trim()}
|
||||
<div className="w-full p-4 bg-bgSubtle rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-textSubtle text-xs text-textSubtle mt-2">
|
||||
Copy this link to share with friends or paste directly in Chrome to open
|
||||
</div>
|
||||
<button
|
||||
onClick={() => validateForm() && handleCopy()}
|
||||
className="ml-4 p-2 hover:bg-bgApp rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
title={
|
||||
!title.trim() || !description.trim()
|
||||
? 'Fill in required fields first'
|
||||
: 'Copy link'
|
||||
}
|
||||
disabled={!title.trim() || !description.trim()}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-iconSubtle" />
|
||||
)}
|
||||
<span className="ml-1 text-sm text-textSubtle">
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm truncate font-mono ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`}
|
||||
title={
|
||||
!title.trim() || !description.trim()
|
||||
? 'Fill in required fields to generate link'
|
||||
: deeplink
|
||||
}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-5 h-5 text-iconSubtle" />
|
||||
)}
|
||||
</button>
|
||||
{deeplink}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col space-y-2 pt-1">
|
||||
<button
|
||||
onClick={handleOpenAgent}
|
||||
className="w-full p-3 bg-bgAppInverse text-textProminentInverse rounded-lg hover:bg-bgStandardInverse disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!title.trim() || !description.trim()}
|
||||
>
|
||||
Open agent
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem('recipe_editor_extensions');
|
||||
|
||||
@@ -2,8 +2,6 @@ import { ExtensionConfig } from '../../../api/types.gen';
|
||||
import { getApiUrl, getSecretKey } from '../../../config';
|
||||
import { toastService, ToastServiceOptions } from '../../../toasts';
|
||||
import { replaceWithShims } from './utils';
|
||||
import { saveEnvVarsToKeyring } from './extension-manager';
|
||||
import type { AgentExtensionConfig } from './extension-manager';
|
||||
|
||||
interface ApiResponse {
|
||||
error?: boolean;
|
||||
@@ -143,17 +141,13 @@ export async function addToAgent(
|
||||
options: ToastServiceOptions = {}
|
||||
): Promise<Response> {
|
||||
try {
|
||||
await saveEnvVarsToKeyring(extension);
|
||||
|
||||
const ext = toAgentExtensionConfig(extension);
|
||||
|
||||
if (ext.type === 'stdio') {
|
||||
ext.cmd = await replaceWithShims(ext.cmd);
|
||||
if (extension.type === 'stdio') {
|
||||
extension.cmd = await replaceWithShims(extension.cmd);
|
||||
}
|
||||
|
||||
ext.name = sanitizeName(ext.name);
|
||||
extension.name = sanitizeName(extension.name);
|
||||
|
||||
return await extensionApiCall('/extensions/add', ext, options);
|
||||
return await extensionApiCall('/extensions/add', extension, options);
|
||||
} catch (error) {
|
||||
// Check if this is a 428 error and make the message more descriptive
|
||||
if (error.message && error.message.includes('428')) {
|
||||
@@ -185,32 +179,3 @@ export async function removeFromAgent(
|
||||
function sanitizeName(name: string) {
|
||||
return name.toLowerCase().replace(/-/g, '').replace(/_/g, '').replace(/\s/g, '');
|
||||
}
|
||||
|
||||
export function toAgentExtensionConfig(config: ExtensionConfig): AgentExtensionConfig {
|
||||
// Use type narrowing to handle different variants of the union type
|
||||
if ('type' in config) {
|
||||
switch (config.type) {
|
||||
case 'sse': {
|
||||
const { envs, ...rest } = config;
|
||||
return {
|
||||
...rest,
|
||||
env_keys: envs ? Object.keys(envs) : undefined,
|
||||
};
|
||||
}
|
||||
case 'stdio': {
|
||||
const { envs, ...rest } = config;
|
||||
return {
|
||||
...rest,
|
||||
env_keys: envs ? Object.keys(envs) : undefined,
|
||||
};
|
||||
}
|
||||
case 'builtin':
|
||||
case 'frontend':
|
||||
// These types don't have envs field, so just return as is
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
// This should never happen due to the union type constraint
|
||||
throw new Error('Invalid extension configuration type');
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import type { ExtensionConfig } from '../../../api/types.gen';
|
||||
import { toastService, ToastServiceOptions } from '../../../toasts';
|
||||
import { addToAgent, removeFromAgent } from './agent-api';
|
||||
import { upsertConfig } from '../../../api';
|
||||
|
||||
// TODO: unify config.yaml and the agent /extensions/add API's notion of env vars
|
||||
export type AgentExtensionConfig = ExtensionConfig & {
|
||||
env_keys?: string[];
|
||||
};
|
||||
|
||||
interface ActivateExtensionProps {
|
||||
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
|
||||
@@ -289,11 +283,3 @@ export async function deleteExtension({ name, removeFromConfig }: DeleteExtensio
|
||||
throw agentRemoveError;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveEnvVarsToKeyring(extension: ExtensionConfig) {
|
||||
if (extension.type === 'stdio' || extension.type === 'sse') {
|
||||
for (const [key, value] of Object.entries(extension.envs || {})) {
|
||||
await upsertConfig({ body: { key, value, is_secret: true } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Button } from '../../../ui/button';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Plus, X, Edit } from 'lucide-react';
|
||||
import { Input } from '../../../ui/input';
|
||||
import { cn } from '../../../../utils';
|
||||
|
||||
interface EnvVarsSectionProps {
|
||||
envVars: { key: string; value: string }[];
|
||||
envVars: { key: string; value: string; isEdited?: boolean }[];
|
||||
onAdd: (key: string, value: string) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onChange: (index: number, field: 'key' | 'value', value: string) => void;
|
||||
@@ -68,6 +68,21 @@ export default function EnvVarsSection({
|
||||
return value === '';
|
||||
};
|
||||
|
||||
const handleEdit = (index: number) => {
|
||||
// Mark this env var as edited
|
||||
onChange(index, 'value', envVars[index].value === '••••••••' ? '' : envVars[index].value);
|
||||
|
||||
// Mark as edited in the parent component
|
||||
const updatedEnvVar = {
|
||||
...envVars[index],
|
||||
isEdited: true,
|
||||
};
|
||||
|
||||
// Update the envVars array with the edited flag
|
||||
const newEnvVars = [...envVars];
|
||||
newEnvVars[index] = updatedEnvVar;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative mb-2">
|
||||
@@ -76,10 +91,10 @@ export default function EnvVarsSection({
|
||||
</label>
|
||||
<p className="text-xs text-textSubtle mb-4">
|
||||
Add key-value pairs for environment variables. Click the "+" button to add after filling
|
||||
both fields.
|
||||
both fields. For existing secret values, click the edit button to modify.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center">
|
||||
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
|
||||
{/* Existing environment variables */}
|
||||
{envVars.map((envVar, index) => (
|
||||
<React.Fragment key={index}>
|
||||
@@ -97,18 +112,39 @@ export default function EnvVarsSection({
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={envVar.value}
|
||||
onChange={(e) => onChange(index, 'value', e.target.value)}
|
||||
readOnly={envVar.value === '••••••••' && !envVar.isEdited}
|
||||
onChange={(e) => {
|
||||
// If this is the first edit of a placeholder value, clear it
|
||||
const newValue =
|
||||
envVar.value === '••••••••' && !envVar.isEdited ? '' : e.target.value;
|
||||
onChange(index, 'value', newValue);
|
||||
}}
|
||||
placeholder="Value"
|
||||
className={cn(
|
||||
'w-full text-textStandard border-borderSubtle hover:border-borderStandard',
|
||||
'w-full border-borderSubtle',
|
||||
envVar.value === '••••••••' && !envVar.isEdited
|
||||
? 'text-textSubtle opacity-60 cursor-not-allowed hover:border-borderSubtle'
|
||||
: 'text-textStandard hover:border-borderStandard',
|
||||
isFieldInvalid(index, 'value') && 'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{envVar.value === '••••••••' && !envVar.isEdited && (
|
||||
<Button
|
||||
onClick={() => handleEdit(index)}
|
||||
variant="ghost"
|
||||
className="group p-2 h-auto text-iconSubtle hover:bg-transparent"
|
||||
>
|
||||
<Edit className="h-3 w-3 text-gray-400 group-hover:text-white group-hover:drop-shadow-sm transition-all" />
|
||||
</Button>
|
||||
)}
|
||||
{(envVar.value !== '••••••••' || envVar.isEdited) && (
|
||||
<div className="w-8 h-8"></div> /* Empty div to maintain grid spacing */
|
||||
)}
|
||||
<Button
|
||||
onClick={() => onRemove(index)}
|
||||
variant="ghost"
|
||||
className="group p-2 h-auto text-iconSubtle hover:bg-transparent min-w-[60px] flex justify-start"
|
||||
className="group p-2 h-auto text-iconSubtle hover:bg-transparent"
|
||||
>
|
||||
<X className="h-3 w-3 text-gray-400 group-hover:text-white group-hover:drop-shadow-sm transition-all" />
|
||||
</Button>
|
||||
@@ -140,13 +176,15 @@ export default function EnvVarsSection({
|
||||
invalidFields.value && 'border-red-500 focus:border-red-500'
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
variant="ghost"
|
||||
className="flex items-center justify-start gap-1 px-2 pr-4 text-sm rounded-full text-textStandard bg-bgApp border border-borderSubtle hover:border-borderStandard transition-colors min-w-[60px] h-9 [&>svg]:!size-4"
|
||||
>
|
||||
<Plus /> Add
|
||||
</Button>
|
||||
<div className="col-span-2">
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
variant="ghost"
|
||||
className="flex items-center justify-start gap-1 px-2 pr-4 text-sm rounded-full text-textStandard bg-bgApp border border-borderSubtle hover:border-borderStandard transition-colors min-w-[60px] h-9 [&>svg]:!size-4"
|
||||
>
|
||||
<Plus /> Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{validationError && <div className="mt-2 text-red-500 text-sm">{validationError}</div>}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import ExtensionConfigFields from './ExtensionConfigFields';
|
||||
import { PlusIcon, Edit, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import ExtensionInfoFields from './ExtensionInfoFields';
|
||||
import ExtensionTimeoutField from './ExtensionTimeoutField';
|
||||
import { upsertConfig } from '../../../../api/sdk.gen';
|
||||
|
||||
interface ExtensionModalProps {
|
||||
title: string;
|
||||
@@ -34,7 +35,7 @@ export default function ExtensionModal({
|
||||
const handleAddEnvVar = (key: string, value: string) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
envVars: [...formData.envVars, { key, value }],
|
||||
envVars: [...formData.envVars, { key, value, isEdited: true }],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -50,12 +51,35 @@ export default function ExtensionModal({
|
||||
const handleEnvVarChange = (index: number, field: 'key' | 'value', value: string) => {
|
||||
const newEnvVars = [...formData.envVars];
|
||||
newEnvVars[index][field] = value;
|
||||
|
||||
// Mark as edited if it's a value change
|
||||
if (field === 'value') {
|
||||
newEnvVars[index].isEdited = true;
|
||||
}
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
envVars: newEnvVars,
|
||||
});
|
||||
};
|
||||
|
||||
// Function to store a secret value
|
||||
const storeSecret = async (key: string, value: string) => {
|
||||
try {
|
||||
await upsertConfig({
|
||||
body: {
|
||||
is_secret: true,
|
||||
key: key,
|
||||
value: value,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to store secret:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to determine which icon to display with proper styling
|
||||
const getModalIcon = () => {
|
||||
if (showDeleteConfirmation) {
|
||||
@@ -104,23 +128,36 @@ export default function ExtensionModal({
|
||||
return isNameValid() && isConfigValid() && isEnvVarsValid() && isTimeoutValid();
|
||||
};
|
||||
|
||||
// Handle submit with validation
|
||||
const handleSubmit = () => {
|
||||
// Handle submit with validation and secret storage
|
||||
const handleSubmit = async () => {
|
||||
setSubmitAttempted(true);
|
||||
|
||||
if (isFormValid()) {
|
||||
const dataToSubmit = { ...formData };
|
||||
// Only store env vars that have been edited (which includes new)
|
||||
const secretPromises = formData.envVars
|
||||
.filter((envVar) => envVar.isEdited)
|
||||
.map(({ key, value }) => storeSecret(key, value));
|
||||
|
||||
// Convert the timeout to a number if it's a string
|
||||
if (typeof dataToSubmit.timeout === 'string') {
|
||||
dataToSubmit.timeout = Number(dataToSubmit.timeout);
|
||||
try {
|
||||
// Wait for all secrets to be stored
|
||||
const results = await Promise.all(secretPromises);
|
||||
|
||||
if (results.every((success) => success)) {
|
||||
// Convert timeout to number if needed
|
||||
const dataToSubmit = {
|
||||
...formData,
|
||||
timeout:
|
||||
typeof formData.timeout === 'string' ? Number(formData.timeout) : formData.timeout,
|
||||
};
|
||||
onSubmit(dataToSubmit);
|
||||
onClose();
|
||||
} else {
|
||||
console.error('Failed to store one or more secrets');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during submission:', error);
|
||||
}
|
||||
|
||||
// Submit the data with converted timeout
|
||||
onSubmit(dataToSubmit);
|
||||
onClose(); // Only close the modal if the form is valid
|
||||
} else {
|
||||
// Optional: Add some feedback that validation failed (like a toast notification)
|
||||
console.log('Form validation failed');
|
||||
}
|
||||
};
|
||||
@@ -241,7 +278,7 @@ export default function ExtensionModal({
|
||||
envVars={formData.envVars}
|
||||
onAdd={handleAddEnvVar}
|
||||
onRemove={handleRemoveEnvVar}
|
||||
onChange={Object.assign(handleEnvVarChange, { setSubmitAttempted })}
|
||||
onChange={handleEnvVarChange}
|
||||
submitAttempted={submitAttempted}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,11 @@ export interface ExtensionFormData {
|
||||
endpoint?: string;
|
||||
enabled: boolean;
|
||||
timeout?: number;
|
||||
envVars: { key: string; value: string }[];
|
||||
envVars: {
|
||||
key: string;
|
||||
value: string;
|
||||
isEdited?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function getDefaultFormData(): ExtensionFormData {
|
||||
@@ -46,13 +50,30 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo
|
||||
// Type guard: Check if 'envs' property exists for this variant
|
||||
const hasEnvs = extension.type === 'sse' || extension.type === 'stdio';
|
||||
|
||||
const envVars =
|
||||
hasEnvs && extension.envs
|
||||
? Object.entries(extension.envs).map(([key, value]) => ({
|
||||
key,
|
||||
value: value as string,
|
||||
}))
|
||||
: [];
|
||||
// Handle both envs (legacy) and env_keys (new secrets)
|
||||
let envVars = [];
|
||||
|
||||
// Add legacy envs with their values
|
||||
if (hasEnvs && extension.envs) {
|
||||
envVars.push(
|
||||
...Object.entries(extension.envs).map(([key, value]) => ({
|
||||
key,
|
||||
value: value as string,
|
||||
isEdited: true, // We want to submit legacy values as secrets to migrate forward
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Add env_keys with placeholder values
|
||||
if (hasEnvs && extension.env_keys) {
|
||||
envVars.push(
|
||||
...extension.env_keys.map((key) => ({
|
||||
key,
|
||||
value: '••••••••', // Placeholder for secret values
|
||||
isEdited: false, // Mark as not edited initially
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: extension.name,
|
||||
@@ -68,15 +89,8 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo
|
||||
}
|
||||
|
||||
export function createExtensionConfig(formData: ExtensionFormData): ExtensionConfig {
|
||||
const envs = formData.envVars.reduce(
|
||||
(acc, { key, value }) => {
|
||||
if (key) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
// Extract just the keys from env vars
|
||||
const env_keys = formData.envVars.map(({ key }) => key).filter((key) => key.length > 0);
|
||||
|
||||
if (formData.type === 'stdio') {
|
||||
// we put the cmd + args all in the form cmd field but need to split out into cmd + args
|
||||
@@ -89,7 +103,7 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon
|
||||
cmd: cmd,
|
||||
args: args,
|
||||
timeout: formData.timeout,
|
||||
...(Object.keys(envs).length > 0 ? { envs } : {}),
|
||||
...(env_keys.length > 0 ? { env_keys } : {}),
|
||||
};
|
||||
} else if (formData.type === 'sse') {
|
||||
return {
|
||||
@@ -97,8 +111,8 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
timeout: formData.timeout,
|
||||
uri: formData.endpoint, // Assuming endpoint maps to uri for SSE type
|
||||
...(Object.keys(envs).length > 0 ? { envs } : {}),
|
||||
uri: formData.endpoint,
|
||||
...(env_keys.length > 0 ? { env_keys } : {}),
|
||||
};
|
||||
} else {
|
||||
// For other types
|
||||
|
||||
@@ -94,8 +94,8 @@ There may be (but not always) some tools mentioned in the instructions which you
|
||||
*
|
||||
* @param addExtension Function to add extension to config.yaml
|
||||
*/
|
||||
export const migrateExtensionsToSettingsV2 = async () => {
|
||||
console.log('need to perform extension migration');
|
||||
export const migrateExtensionsToSettingsV3 = async () => {
|
||||
console.log('need to perform extension migration v3');
|
||||
|
||||
const userSettingsStr = localStorage.getItem('user_settings');
|
||||
let localStorageExtensions: FullExtensionConfig[] = [];
|
||||
@@ -140,8 +140,8 @@ export const migrateExtensionsToSettingsV2 = async () => {
|
||||
}
|
||||
|
||||
if (migrationErrors.length === 0) {
|
||||
localStorage.setItem('configVersion', '2');
|
||||
console.log('Extension migration complete. Config version set to 2.');
|
||||
localStorage.setItem('configVersion', '3');
|
||||
console.log('Extension migration complete. Config version set to 3.');
|
||||
} else {
|
||||
const errorSummaryStr = migrationErrors
|
||||
.map(({ name, error }) => `- ${name}: ${JSON.stringify(error)}`)
|
||||
@@ -209,11 +209,11 @@ export const initializeSystem = async (
|
||||
// NOTE: remove when we want to stop migration logic
|
||||
// Check if we need to migrate extensions from localStorage to config.yaml
|
||||
const configVersion = localStorage.getItem('configVersion');
|
||||
const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 2;
|
||||
const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3;
|
||||
|
||||
console.log(`shouldMigrateExtensions is ${shouldMigrateExtensions}`);
|
||||
if (shouldMigrateExtensions) {
|
||||
await migrateExtensionsToSettingsV2();
|
||||
await migrateExtensionsToSettingsV3();
|
||||
}
|
||||
|
||||
/* NOTE:
|
||||
|
||||
Reference in New Issue
Block a user