Skip to content

Commit 7a65c1e

Browse files
authored
Add support for linking in the extension registry (#23153)
1 parent 5a3c715 commit 7a65c1e

5 files changed

Lines changed: 131 additions & 13 deletions

File tree

packages/cli/src/ui/commands/extensionsCommand.test.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -710,10 +710,14 @@ describe('extensionsCommand', () => {
710710
size: 100,
711711
} as Stats);
712712
await linkAction!(mockContext, packageName);
713-
expect(mockInstallExtension).toHaveBeenCalledWith({
714-
source: packageName,
715-
type: 'link',
716-
});
713+
expect(mockInstallExtension).toHaveBeenCalledWith(
714+
{
715+
source: packageName,
716+
type: 'link',
717+
},
718+
undefined,
719+
undefined,
720+
);
717721
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
718722
type: MessageType.INFO,
719723
text: `Linking extension from "${packageName}"...`,
@@ -733,10 +737,14 @@ describe('extensionsCommand', () => {
733737
} as Stats);
734738

735739
await linkAction!(mockContext, packageName);
736-
expect(mockInstallExtension).toHaveBeenCalledWith({
737-
source: packageName,
738-
type: 'link',
739-
});
740+
expect(mockInstallExtension).toHaveBeenCalledWith(
741+
{
742+
source: packageName,
743+
type: 'link',
744+
},
745+
undefined,
746+
undefined,
747+
);
740748
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
741749
type: MessageType.ERROR,
742750
text: `Failed to link extension from "${packageName}": ${errorMessage}`,

packages/cli/src/ui/commands/extensionsCommand.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,11 @@ async function exploreAction(
286286
await installAction(context, extension.url, requestConsentOverride);
287287
context.ui.removeComponent();
288288
},
289+
onLink: async (extension, requestConsentOverride) => {
290+
debugLogger.log(`Linking extension: ${extension.extensionName}`);
291+
await linkAction(context, extension.url, requestConsentOverride);
292+
context.ui.removeComponent();
293+
},
289294
onClose: () => context.ui.removeComponent(),
290295
extensionManager,
291296
}),
@@ -533,7 +538,11 @@ async function installAction(
533538
}
534539
}
535540

536-
async function linkAction(context: CommandContext, args: string) {
541+
async function linkAction(
542+
context: CommandContext,
543+
args: string,
544+
requestConsentOverride?: (consent: string) => Promise<boolean>,
545+
) {
537546
const extensionLoader =
538547
context.services.agentContext?.config.getExtensionLoader();
539548
if (!(extensionLoader instanceof ExtensionManager)) {
@@ -582,8 +591,11 @@ async function linkAction(context: CommandContext, args: string) {
582591
source: sourceFilepath,
583592
type: 'link',
584593
};
585-
const extension =
586-
await extensionLoader.installOrUpdateExtension(installMetadata);
594+
const extension = await extensionLoader.installOrUpdateExtension(
595+
installMetadata,
596+
undefined,
597+
requestConsentOverride,
598+
);
587599
context.ui.addItem({
588600
type: MessageType.INFO,
589601
text: `Extension "${extension.name}" linked successfully.`,

packages/cli/src/ui/components/views/ExtensionDetails.test.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,20 @@ const mockExtension: RegistryExtension = {
3232
licenseKey: 'Apache-2.0',
3333
};
3434

35+
const linkableExtension: RegistryExtension = {
36+
...mockExtension,
37+
url: '/local/path/to/extension',
38+
};
39+
3540
describe('ExtensionDetails', () => {
3641
let mockOnBack: ReturnType<typeof vi.fn>;
3742
let mockOnInstall: ReturnType<typeof vi.fn>;
43+
let mockOnLink: ReturnType<typeof vi.fn>;
3844

3945
beforeEach(() => {
4046
mockOnBack = vi.fn();
4147
mockOnInstall = vi.fn();
48+
mockOnLink = vi.fn();
4249
});
4350

4451
const renderDetails = async (isInstalled = false) =>
@@ -47,6 +54,7 @@ describe('ExtensionDetails', () => {
4754
extension={mockExtension}
4855
onBack={mockOnBack}
4956
onInstall={mockOnInstall}
57+
onLink={mockOnLink}
5058
isInstalled={isInstalled}
5159
/>,
5260
);
@@ -117,4 +125,47 @@ describe('ExtensionDetails', () => {
117125
expect(mockOnInstall).not.toHaveBeenCalled();
118126
vi.useRealTimers();
119127
});
128+
129+
it('should call onLink when "l" is pressed and is linkable', async () => {
130+
const { stdin, waitUntilReady } = await renderWithProviders(
131+
<ExtensionDetails
132+
extension={linkableExtension}
133+
onBack={mockOnBack}
134+
onInstall={mockOnInstall}
135+
onLink={mockOnLink}
136+
isInstalled={false}
137+
/>,
138+
);
139+
await waitUntilReady();
140+
await React.act(async () => {
141+
stdin.write('l');
142+
});
143+
await waitFor(() => {
144+
expect(mockOnLink).toHaveBeenCalled();
145+
});
146+
});
147+
148+
it('should NOT show "Link" button for GitHub extensions', async () => {
149+
const { lastFrame, waitUntilReady } = await renderDetails(false);
150+
await waitUntilReady();
151+
await waitFor(() => {
152+
expect(lastFrame()).not.toContain('[L] Link');
153+
});
154+
});
155+
156+
it('should show "Link" button for local extensions', async () => {
157+
const { lastFrame, waitUntilReady } = await renderWithProviders(
158+
<ExtensionDetails
159+
extension={linkableExtension}
160+
onBack={mockOnBack}
161+
onInstall={mockOnInstall}
162+
onLink={mockOnLink}
163+
isInstalled={false}
164+
/>,
165+
);
166+
await waitUntilReady();
167+
await waitFor(() => {
168+
expect(lastFrame()).toContain('[L] Link');
169+
});
170+
});
120171
});

packages/cli/src/ui/components/views/ExtensionDetails.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,17 @@ export interface ExtensionDetailsProps {
1919
onInstall: (
2020
requestConsentOverride: (consent: string) => Promise<boolean>,
2121
) => void | Promise<void>;
22+
onLink: (
23+
requestConsentOverride: (consent: string) => Promise<boolean>,
24+
) => void | Promise<void>;
2225
isInstalled: boolean;
2326
}
2427

2528
export function ExtensionDetails({
2629
extension,
2730
onBack,
2831
onInstall,
32+
onLink,
2933
isInstalled,
3034
}: ExtensionDetailsProps): React.JSX.Element {
3135
const keyMatchers = useKeyMatchers();
@@ -35,6 +39,11 @@ export function ExtensionDetails({
3539
} | null>(null);
3640
const [isInstalling, setIsInstalling] = useState(false);
3741

42+
const isLinkable =
43+
!extension.url.startsWith('http') &&
44+
!extension.url.startsWith('git@') &&
45+
!extension.url.startsWith('sso://');
46+
3847
useKeypress(
3948
(key) => {
4049
if (consentRequest) {
@@ -56,6 +65,7 @@ export function ExtensionDetails({
5665
onBack();
5766
return true;
5867
}
68+
5969
if (keyMatchers[Command.RETURN](key) && !isInstalled && !isInstalling) {
6070
setIsInstalling(true);
6171
void onInstall(
@@ -66,6 +76,16 @@ export function ExtensionDetails({
6676
);
6777
return true;
6878
}
79+
if (key.name === 'l' && isLinkable && !isInstalled && !isInstalling) {
80+
setIsInstalling(true);
81+
void onLink(
82+
(prompt: string) =>
83+
new Promise((resolve) => {
84+
setConsentRequest({ prompt, resolve });
85+
}),
86+
);
87+
return true;
88+
}
6989
return false;
7090
},
7191
{ isActive: true, priority: true },
@@ -230,8 +250,11 @@ export function ExtensionDetails({
230250
understand the permissions it requires and the actions it may
231251
perform.
232252
</Text>
233-
<Box marginTop={1}>
234-
<Text color={theme.text.primary}>[{'Enter'}] Install</Text>
253+
<Box marginTop={1} flexDirection="row">
254+
<Box marginRight={2}>
255+
<Text color={theme.text.primary}>[{'Enter'}] Install</Text>
256+
</Box>
257+
{isLinkable && <Text color={theme.text.primary}>[L] Link</Text>}
235258
</Box>
236259
</Box>
237260
)}

packages/cli/src/ui/components/views/ExtensionRegistryView.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ export interface ExtensionRegistryViewProps {
2929
extension: RegistryExtension,
3030
requestConsentOverride?: (consent: string) => Promise<boolean>,
3131
) => void | Promise<void>;
32+
onLink?: (
33+
extension: RegistryExtension,
34+
requestConsentOverride?: (consent: string) => Promise<boolean>,
35+
) => void | Promise<void>;
3236
onClose?: () => void;
3337
extensionManager: ExtensionManager;
3438
}
@@ -39,6 +43,7 @@ interface ExtensionItem extends GenericListItem {
3943

4044
export function ExtensionRegistryView({
4145
onSelect,
46+
onLink,
4247
onClose,
4348
extensionManager,
4449
}: ExtensionRegistryViewProps): React.JSX.Element {
@@ -96,6 +101,22 @@ export function ExtensionRegistryView({
96101
[onSelect, extensionManager],
97102
);
98103

104+
const handleLink = useCallback(
105+
async (
106+
extension: RegistryExtension,
107+
requestConsentOverride?: (consent: string) => Promise<boolean>,
108+
) => {
109+
await onLink?.(extension, requestConsentOverride);
110+
111+
// Refresh installed extensions list
112+
setInstalledExtensions(extensionManager.getExtensions());
113+
114+
// Go back to the search page (list view)
115+
setSelectedExtension(null);
116+
},
117+
[onLink, extensionManager],
118+
);
119+
99120
const renderItem = useCallback(
100121
(item: ExtensionItem, isActive: boolean, _labelWidth: number) => {
101122
const isInstalled = installedExtensions.some(
@@ -260,6 +281,9 @@ export function ExtensionRegistryView({
260281
onInstall={async (requestConsentOverride) => {
261282
await handleInstall(selectedExtension, requestConsentOverride);
262283
}}
284+
onLink={async (requestConsentOverride) => {
285+
await handleLink(selectedExtension, requestConsentOverride);
286+
}}
263287
isInstalled={installedExtensions.some(
264288
(e) => e.name === selectedExtension.extensionName,
265289
)}

0 commit comments

Comments
 (0)