feature: handle removed extensions (#8797)
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
||||
removeExtension as apiRemoveExtension,
|
||||
providers,
|
||||
} from '../api';
|
||||
import { syncBundledExtensions } from './settings/extensions';
|
||||
import { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './settings/extensions';
|
||||
import type {
|
||||
ConfigResponse,
|
||||
UpsertConfigQuery,
|
||||
@@ -88,16 +88,19 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
[reloadConfig]
|
||||
);
|
||||
|
||||
const read = useCallback(async (key: string, is_secret: boolean = false, options?: { throwOnError?: boolean }) => {
|
||||
const query: ConfigKeyQuery = { key: key, is_secret: is_secret };
|
||||
const response = await readConfig({
|
||||
body: query,
|
||||
});
|
||||
if (options?.throwOnError && response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
return response.data;
|
||||
}, []);
|
||||
const read = useCallback(
|
||||
async (key: string, is_secret: boolean = false, options?: { throwOnError?: boolean }) => {
|
||||
const query: ConfigKeyQuery = { key: key, is_secret: is_secret };
|
||||
const response = await readConfig({
|
||||
body: query,
|
||||
});
|
||||
if (options?.throwOnError && response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (key: string, is_secret: boolean) => {
|
||||
@@ -226,6 +229,10 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
|
||||
const query: ExtensionQuery = { name, config, enabled };
|
||||
await apiAddExtension({ body: query });
|
||||
};
|
||||
const removeExtensionForSync = async (name: string) => {
|
||||
await apiRemoveExtension({ path: { name } });
|
||||
};
|
||||
extensions = await pruneDeprecatedBundledExtensions(extensions, removeExtensionForSync);
|
||||
await syncBundledExtensions(extensions, addExtensionForSync);
|
||||
// Reload extensions after sync
|
||||
const refreshedResponse = await apiGetExtensions();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Switch } from '../ui/switch';
|
||||
import { FixedExtensionEntry, useConfig } from '../ConfigContext';
|
||||
import { toastService } from '../../toasts';
|
||||
import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList';
|
||||
import { nameToKey } from '../settings/extensions/utils';
|
||||
import { ExtensionConfig, getSessionExtensions } from '../../api';
|
||||
import { addToAgent, removeFromAgent } from '../settings/extensions/agent-api';
|
||||
import {
|
||||
@@ -230,15 +231,29 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
);
|
||||
}
|
||||
|
||||
const sessionExtensionNames = new Set(sessionExtensions.map((ext) => ext.name));
|
||||
const sessionExtensionKeys = new Set(sessionExtensions.map((ext) => nameToKey(ext.name)));
|
||||
const globalExtensionKeys = new Set(allExtensions.map((ext) => nameToKey(ext.name)));
|
||||
|
||||
return allExtensions.map(
|
||||
const mergedExtensions = allExtensions.map(
|
||||
(ext) =>
|
||||
({
|
||||
...ext,
|
||||
enabled: sessionExtensionNames.has(ext.name),
|
||||
enabled: sessionExtensionKeys.has(nameToKey(ext.name)),
|
||||
}) as FixedExtensionEntry
|
||||
);
|
||||
|
||||
for (const sessionExtension of sessionExtensions) {
|
||||
if (globalExtensionKeys.has(nameToKey(sessionExtension.name))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mergedExtensions.push({
|
||||
...sessionExtension,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return mergedExtensions;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allExtensions, sessionExtensions, isHubView, hubUpdateTrigger]);
|
||||
|
||||
@@ -266,6 +281,9 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
return extensionsList.filter((ext) => ext.enabled).length;
|
||||
}, [extensionsList]);
|
||||
|
||||
const shouldHideTrigger =
|
||||
extensionsList.length === 0 || (!isHubView && !isSessionExtensionsLoaded);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={isOpen}
|
||||
@@ -284,7 +302,7 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={`flex items-center [&_svg]:size-4 text-text-primary/70 hover:text-text-primary hover:scale-100 hover:bg-transparent text-xs cursor-pointer ${allExtensions.length === 0 || (!isHubView && !isSessionExtensionsLoaded) ? 'invisible' : ''}`}
|
||||
className={`flex items-center [&_svg]:size-4 text-text-primary/70 hover:text-text-primary hover:scale-100 hover:bg-transparent text-xs cursor-pointer ${shouldHideTrigger ? 'invisible' : ''}`}
|
||||
title={intl.formatMessage(i18n.manageExtensions)}
|
||||
>
|
||||
<Puzzle className="mr-1 h-4 w-4" />
|
||||
@@ -309,7 +327,9 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-text-primary/60 mt-1.5">
|
||||
{intl.formatMessage(isHubView ? i18n.extensionsForNewChats : i18n.extensionsForThisSession)}
|
||||
{intl.formatMessage(
|
||||
isHubView ? i18n.extensionsForNewChats : i18n.extensionsForThisSession
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -319,7 +339,9 @@ export const BottomMenuExtensionSelection = ({ sessionId }: BottomMenuExtensionS
|
||||
>
|
||||
{sortedExtensions.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-sm text-text-primary/70">
|
||||
{intl.formatMessage(searchQuery ? i18n.noExtensionsFound : i18n.noExtensionsAvailable)}
|
||||
{intl.formatMessage(
|
||||
searchQuery ? i18n.noExtensionsFound : i18n.noExtensionsAvailable
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
sortedExtensions.map((ext) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { isDeprecatedGoogleDriveExtension, syncBundledExtensions } from './bundled-extensions';
|
||||
import { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './bundled-extensions';
|
||||
import type { FixedExtensionEntry } from '../../ConfigContext';
|
||||
|
||||
vi.mock('./bundled-extensions.json', () => ({
|
||||
@@ -28,143 +28,11 @@ vi.mock('./bundled-extensions.json', () => ({
|
||||
],
|
||||
}));
|
||||
|
||||
describe('isDeprecatedGoogleDriveExtension', () => {
|
||||
it('returns true for builtin googledrive', () => {
|
||||
const ext = {
|
||||
name: 'Google Drive',
|
||||
type: 'builtin',
|
||||
description: 'Google Drive extension',
|
||||
enabled: true,
|
||||
bundled: true,
|
||||
} as FixedExtensionEntry;
|
||||
expect(isDeprecatedGoogleDriveExtension(ext)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for builtin google_drive', () => {
|
||||
const ext = {
|
||||
name: 'google_drive',
|
||||
type: 'builtin',
|
||||
description: 'Google Drive extension',
|
||||
enabled: true,
|
||||
bundled: true,
|
||||
} as FixedExtensionEntry;
|
||||
expect(isDeprecatedGoogleDriveExtension(ext)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for stdio googledrive with GOOGLE_DRIVE_CREDENTIALS_PATH', () => {
|
||||
const ext = {
|
||||
name: 'Google Drive',
|
||||
type: 'stdio',
|
||||
description: 'Google Drive extension',
|
||||
cmd: 'some-cmd',
|
||||
args: [],
|
||||
env_keys: ['GOOGLE_DRIVE_CREDENTIALS_PATH'],
|
||||
enabled: true,
|
||||
bundled: true,
|
||||
} as FixedExtensionEntry;
|
||||
expect(isDeprecatedGoogleDriveExtension(ext)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for stdio googledrive with GOOGLE_DRIVE_OAUTH_PATH', () => {
|
||||
const ext = {
|
||||
name: 'Google Drive',
|
||||
type: 'stdio',
|
||||
description: 'Google Drive extension',
|
||||
cmd: 'some-cmd',
|
||||
args: [],
|
||||
env_keys: ['GOOGLE_DRIVE_OAUTH_PATH'],
|
||||
enabled: true,
|
||||
bundled: true,
|
||||
} as FixedExtensionEntry;
|
||||
expect(isDeprecatedGoogleDriveExtension(ext)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for stdio googledrive without deprecated env keys', () => {
|
||||
const ext = {
|
||||
name: 'Google Drive',
|
||||
type: 'stdio',
|
||||
description: 'Google Drive extension',
|
||||
cmd: 'some-cmd',
|
||||
args: [],
|
||||
env_keys: [],
|
||||
enabled: true,
|
||||
bundled: true,
|
||||
} as FixedExtensionEntry;
|
||||
expect(isDeprecatedGoogleDriveExtension(ext)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-googledrive extensions', () => {
|
||||
const ext = {
|
||||
name: 'developer',
|
||||
type: 'builtin',
|
||||
description: 'Developer tools',
|
||||
enabled: true,
|
||||
bundled: true,
|
||||
} as FixedExtensionEntry;
|
||||
expect(isDeprecatedGoogleDriveExtension(ext)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-googledrive stdio with those env keys', () => {
|
||||
const ext = {
|
||||
name: 'some-other-ext',
|
||||
type: 'stdio',
|
||||
description: 'Other extension',
|
||||
cmd: 'some-cmd',
|
||||
args: [],
|
||||
env_keys: ['GOOGLE_DRIVE_CREDENTIALS_PATH'],
|
||||
enabled: true,
|
||||
bundled: true,
|
||||
} as FixedExtensionEntry;
|
||||
expect(isDeprecatedGoogleDriveExtension(ext)).toBe(false);
|
||||
});
|
||||
});
|
||||
vi.mock('./deprecated-bundled-extensions.json', () => ({
|
||||
default: [{ id: 'googledrive' }, { id: 'old-bundled-extension' }],
|
||||
}));
|
||||
|
||||
describe('syncBundledExtensions', () => {
|
||||
it('overwrites deprecated builtin googledrive extension', async () => {
|
||||
const addExtensionFn = vi.fn().mockResolvedValue(undefined);
|
||||
const existingExtensions = [
|
||||
{
|
||||
name: 'googledrive',
|
||||
type: 'builtin',
|
||||
description: 'Google Drive',
|
||||
enabled: true,
|
||||
bundled: true,
|
||||
},
|
||||
] as FixedExtensionEntry[];
|
||||
|
||||
await syncBundledExtensions(existingExtensions, addExtensionFn);
|
||||
|
||||
expect(addExtensionFn).toHaveBeenCalledWith(
|
||||
'googledrive',
|
||||
expect.objectContaining({ type: 'stdio', bundled: true }),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('overwrites stdio googledrive with deprecated env keys', async () => {
|
||||
const addExtensionFn = vi.fn().mockResolvedValue(undefined);
|
||||
const existingExtensions = [
|
||||
{
|
||||
name: 'googledrive',
|
||||
type: 'stdio',
|
||||
description: 'Google Drive',
|
||||
cmd: 'some-cmd',
|
||||
args: [],
|
||||
env_keys: ['GOOGLE_DRIVE_CREDENTIALS_PATH'],
|
||||
enabled: true,
|
||||
bundled: true,
|
||||
},
|
||||
] as FixedExtensionEntry[];
|
||||
|
||||
await syncBundledExtensions(existingExtensions, addExtensionFn);
|
||||
|
||||
expect(addExtensionFn).toHaveBeenCalledWith(
|
||||
'googledrive',
|
||||
expect.objectContaining({ type: 'stdio', bundled: true, env_keys: [] }),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('skips already bundled non-deprecated extensions', async () => {
|
||||
const addExtensionFn = vi.fn().mockResolvedValue(undefined);
|
||||
const existingExtensions = [
|
||||
@@ -187,3 +55,82 @@ describe('syncBundledExtensions', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneDeprecatedBundledExtensions', () => {
|
||||
it('removes deprecated bundled extensions', async () => {
|
||||
const removeExtensionFn = vi.fn().mockResolvedValue(undefined);
|
||||
const existingExtensions = [
|
||||
{
|
||||
name: 'old-bundled-extension',
|
||||
type: 'builtin',
|
||||
description: 'Old bundled extension',
|
||||
enabled: true,
|
||||
bundled: true,
|
||||
},
|
||||
] as FixedExtensionEntry[];
|
||||
|
||||
const remainingExtensions = await pruneDeprecatedBundledExtensions(
|
||||
existingExtensions,
|
||||
removeExtensionFn
|
||||
);
|
||||
|
||||
expect(removeExtensionFn).toHaveBeenCalledWith('old-bundled-extension');
|
||||
expect(remainingExtensions).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not remove non-bundled deprecated extensions', async () => {
|
||||
const removeExtensionFn = vi.fn().mockResolvedValue(undefined);
|
||||
const existingExtensions = [
|
||||
{
|
||||
name: 'old-bundled-extension',
|
||||
type: 'builtin',
|
||||
description: 'Old bundled extension',
|
||||
enabled: true,
|
||||
bundled: false,
|
||||
},
|
||||
] as FixedExtensionEntry[];
|
||||
|
||||
const remainingExtensions = await pruneDeprecatedBundledExtensions(
|
||||
existingExtensions,
|
||||
removeExtensionFn
|
||||
);
|
||||
|
||||
expect(removeExtensionFn).not.toHaveBeenCalled();
|
||||
expect(remainingExtensions).toEqual(existingExtensions);
|
||||
});
|
||||
|
||||
it('allows same-id bundled extensions to be re-added after prune', async () => {
|
||||
const removeExtensionFn = vi.fn().mockResolvedValue(undefined);
|
||||
const addExtensionFn = vi.fn().mockResolvedValue(undefined);
|
||||
const existingExtensions = [
|
||||
{
|
||||
name: 'Google Drive',
|
||||
type: 'stdio',
|
||||
description: 'Google Drive extension',
|
||||
cmd: 'some-cmd',
|
||||
args: [],
|
||||
env_keys: [],
|
||||
enabled: true,
|
||||
bundled: true,
|
||||
},
|
||||
] as FixedExtensionEntry[];
|
||||
|
||||
const remainingExtensions = await pruneDeprecatedBundledExtensions(
|
||||
existingExtensions,
|
||||
removeExtensionFn
|
||||
);
|
||||
|
||||
await syncBundledExtensions(remainingExtensions, addExtensionFn);
|
||||
|
||||
expect(removeExtensionFn).toHaveBeenCalledWith('googledrive');
|
||||
expect(addExtensionFn).toHaveBeenCalledWith(
|
||||
'googledrive',
|
||||
expect.objectContaining({
|
||||
type: 'stdio',
|
||||
name: 'googledrive',
|
||||
bundled: true,
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ExtensionConfig } from '../../../api/types.gen';
|
||||
import { FixedExtensionEntry } from '../../ConfigContext';
|
||||
import bundledExtensionsData from './bundled-extensions.json';
|
||||
import deprecatedBundledExtensionsData from './deprecated-bundled-extensions.json';
|
||||
import { nameToKey } from './utils';
|
||||
|
||||
// Type definition for built-in extensions from JSON
|
||||
@@ -20,28 +21,40 @@ type BundledExtension = {
|
||||
allow_configure?: boolean;
|
||||
};
|
||||
|
||||
const DEPRECATED_GOOGLE_DRIVE_IDS = ['googledrive', 'google_drive'];
|
||||
const DEPRECATED_GOOGLE_DRIVE_ENV_KEYS = [
|
||||
'GOOGLE_DRIVE_CREDENTIALS_PATH',
|
||||
'GOOGLE_DRIVE_OAUTH_PATH',
|
||||
];
|
||||
type DeprecatedBundledExtension = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function isDeprecatedGoogleDriveExtension(ext: FixedExtensionEntry): boolean {
|
||||
if (!DEPRECATED_GOOGLE_DRIVE_IDS.includes(nameToKey(ext.name))) {
|
||||
return false;
|
||||
export function getDeprecatedBundledExtensions(): DeprecatedBundledExtension[] {
|
||||
return deprecatedBundledExtensionsData as DeprecatedBundledExtension[];
|
||||
}
|
||||
|
||||
function isBundledExtension(extension: FixedExtensionEntry): boolean {
|
||||
return 'bundled' in extension && extension.bundled === true;
|
||||
}
|
||||
|
||||
export async function pruneDeprecatedBundledExtensions(
|
||||
existingExtensions: FixedExtensionEntry[],
|
||||
removeExtensionFn: (id: string) => Promise<void>
|
||||
): Promise<FixedExtensionEntry[]> {
|
||||
const deprecatedExtensionIds = new Set(getDeprecatedBundledExtensions().map((ext) => ext.id));
|
||||
const remainingExtensions: FixedExtensionEntry[] = [];
|
||||
|
||||
for (const existingExt of existingExtensions) {
|
||||
if (!isBundledExtension(existingExt)) {
|
||||
remainingExtensions.push(existingExt);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!deprecatedExtensionIds.has(nameToKey(existingExt.name))) {
|
||||
remainingExtensions.push(existingExt);
|
||||
continue;
|
||||
}
|
||||
|
||||
await removeExtensionFn(nameToKey(existingExt.name));
|
||||
}
|
||||
if (ext.type === 'builtin') {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
ext.type === 'stdio' &&
|
||||
'env_keys' in ext &&
|
||||
Array.isArray(ext.env_keys) &&
|
||||
ext.env_keys.some((key: string) => DEPRECATED_GOOGLE_DRIVE_ENV_KEYS.includes(key))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
return remainingExtensions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,14 +79,7 @@ export async function syncBundledExtensions(
|
||||
// Find if this extension already exists
|
||||
const existingExt = existingExtensions.find((ext) => nameToKey(ext.name) === bundledExt.id);
|
||||
|
||||
// Skip if extension exists and is already marked as bundled, except when
|
||||
// we must migrate deprecated extensions.
|
||||
if (
|
||||
existingExt &&
|
||||
'bundled' in existingExt &&
|
||||
existingExt.bundled &&
|
||||
!isDeprecatedGoogleDriveExtension(existingExt)
|
||||
) {
|
||||
if (existingExt && isBundledExtension(existingExt)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -123,14 +129,3 @@ export async function syncBundledExtensions(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to initialize all built-in extensions for a first-time user.
|
||||
* This can be called when the application is first installed.
|
||||
*/
|
||||
export async function initializeBundledExtensions(
|
||||
addExtensionFn: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>
|
||||
): Promise<void> {
|
||||
// Call with an empty list to ensure all built-ins are added
|
||||
await syncBundledExtensions([], addExtensionFn);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -6,7 +6,7 @@ export {
|
||||
deleteExtension,
|
||||
} from './extension-manager';
|
||||
|
||||
export { syncBundledExtensions, initializeBundledExtensions } from './bundled-extensions';
|
||||
export { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './bundled-extensions';
|
||||
|
||||
export { addExtensionFromDeepLink } from './deeplink';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user