mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
Add gemini extensions link command (#7241)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
This commit is contained in:
@@ -11,6 +11,8 @@ import { listCommand } from './extensions/list.js';
|
||||
import { updateCommand } from './extensions/update.js';
|
||||
import { disableCommand } from './extensions/disable.js';
|
||||
import { enableCommand } from './extensions/enable.js';
|
||||
import { linkCommand } from './extensions/link.js';
|
||||
import { newCommand } from './extensions/new.js';
|
||||
|
||||
export const extensionsCommand: CommandModule = {
|
||||
command: 'extensions <command>',
|
||||
@@ -23,6 +25,8 @@ export const extensionsCommand: CommandModule = {
|
||||
.command(updateCommand)
|
||||
.command(disableCommand)
|
||||
.command(enableCommand)
|
||||
.command(linkCommand)
|
||||
.command(newCommand)
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Ink Library Screen Reader Guidance
|
||||
|
||||
When building custom components, it's important to keep accessibility in mind. While Ink provides the building blocks, ensuring your components are accessible will make your CLIs usable by a wider audience.
|
||||
|
||||
## General Principles
|
||||
|
||||
Provide screen reader-friendly output: Use the useIsScreenReaderEnabled hook to detect if a screen reader is active. You can then render a more descriptive output for screen reader users.
|
||||
Leverage ARIA props: For components that have a specific role (e.g., a checkbox or a button), use the aria-role, aria-state, and aria-label props on <Box> and <Text> to provide semantic meaning to screen readers.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "context-example",
|
||||
"version": "1.0.0",
|
||||
"contextFileName": "GEMINI.md"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
prompt = """
|
||||
Please summarize the findings for the pattern `{{args}}`.
|
||||
|
||||
Search Results:
|
||||
!{grep -r {{args}} .}
|
||||
"""
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "custom-commands",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "excludeTools",
|
||||
"version": "1.0.0",
|
||||
"excludeTools": ["run_shell_command(rm -rf)"]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'prompt-server',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
server.registerTool(
|
||||
'fetch_posts',
|
||||
{
|
||||
description: 'Fetches a list of posts from a public API.',
|
||||
inputSchema: z.object({}).shape,
|
||||
},
|
||||
async () => {
|
||||
const apiResponse = await fetch(
|
||||
'https://jsonplaceholder.typicode.com/posts',
|
||||
);
|
||||
const posts = await apiResponse.json();
|
||||
const response = { posts: posts.slice(0, 5) };
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerPrompt(
|
||||
'poem-writer',
|
||||
{
|
||||
title: 'Poem Writer',
|
||||
description: 'Write a nice haiku',
|
||||
argsSchema: { title: z.string(), mood: z.string().optional() },
|
||||
},
|
||||
({ title, mood }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `Write a haiku${mood ? ` with the mood ${mood}` : ''} called ${title}. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables `,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "mcp-server",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": {
|
||||
"nodeServer": {
|
||||
"command": "node",
|
||||
"args": ["${extensionPath}${/}example.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
51
packages/cli/src/commands/extensions/link.ts
Normal file
51
packages/cli/src/commands/extensions/link.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import {
|
||||
installExtension,
|
||||
type ExtensionInstallMetadata,
|
||||
} from '../../config/extension.js';
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
interface InstallArgs {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function handleLink(args: InstallArgs) {
|
||||
try {
|
||||
const installMetadata: ExtensionInstallMetadata = {
|
||||
source: args.path,
|
||||
type: 'link',
|
||||
};
|
||||
const extensionName = await installExtension(installMetadata);
|
||||
console.log(
|
||||
`Extension "${extensionName}" linked successfully and enabled.`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export const linkCommand: CommandModule = {
|
||||
command: 'link <path>',
|
||||
describe:
|
||||
'Links an extension from a local path. Updates made to the local path will always be reflected.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('path', {
|
||||
describe: 'The name of the extension to link.',
|
||||
type: 'string',
|
||||
})
|
||||
.check((_) => true),
|
||||
handler: async (argv) => {
|
||||
await handleLink({
|
||||
path: argv['path'] as string,
|
||||
});
|
||||
},
|
||||
};
|
||||
73
packages/cli/src/commands/extensions/new.test.ts
Normal file
73
packages/cli/src/commands/extensions/new.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { newCommand } from './new.js';
|
||||
import yargs from 'yargs';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
const mockedFs = vi.mocked(fsPromises);
|
||||
|
||||
describe('extensions new command', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
const fakeFiles = [
|
||||
{ name: 'context', isDirectory: () => true },
|
||||
{ name: 'custom-commands', isDirectory: () => true },
|
||||
{ name: 'mcp-server', isDirectory: () => true },
|
||||
];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockedFs.readdir.mockResolvedValue(fakeFiles as any);
|
||||
});
|
||||
|
||||
it('should fail if no path is provided', async () => {
|
||||
const parser = yargs([]).command(newCommand).fail(false);
|
||||
await expect(parser.parseAsync('new')).rejects.toThrow(
|
||||
'Not enough non-option arguments: got 0, need at least 2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if no template is provided', async () => {
|
||||
const parser = yargs([]).command(newCommand).fail(false);
|
||||
await expect(parser.parseAsync('new /some/path')).rejects.toThrow(
|
||||
'Not enough non-option arguments: got 1, need at least 2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create directory and copy files when path does not exist', async () => {
|
||||
mockedFs.access.mockRejectedValue(new Error('ENOENT'));
|
||||
mockedFs.mkdir.mockResolvedValue(undefined);
|
||||
mockedFs.cp.mockResolvedValue(undefined);
|
||||
|
||||
const parser = yargs([]).command(newCommand).fail(false);
|
||||
|
||||
await parser.parseAsync('new /some/path context');
|
||||
|
||||
expect(mockedFs.mkdir).toHaveBeenCalledWith('/some/path', {
|
||||
recursive: true,
|
||||
});
|
||||
expect(mockedFs.cp).toHaveBeenCalledWith(
|
||||
expect.stringContaining('context'),
|
||||
'/some/path',
|
||||
{ recursive: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the path already exists', async () => {
|
||||
mockedFs.access.mockResolvedValue(undefined);
|
||||
const parser = yargs([]).command(newCommand).fail(false);
|
||||
|
||||
await expect(parser.parseAsync('new /some/path context')).rejects.toThrow(
|
||||
'Path already exists: /some/path',
|
||||
);
|
||||
|
||||
expect(mockedFs.mkdir).not.toHaveBeenCalled();
|
||||
expect(mockedFs.cp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
86
packages/cli/src/commands/extensions/new.ts
Normal file
86
packages/cli/src/commands/extensions/new.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { access, cp, mkdir, readdir } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
interface NewArgs {
|
||||
path: string;
|
||||
template: string;
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const EXAMPLES_PATH = join(__dirname, 'examples');
|
||||
|
||||
async function pathExists(path: string) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDirectory(template: string, path: string) {
|
||||
if (await pathExists(path)) {
|
||||
throw new Error(`Path already exists: ${path}`);
|
||||
}
|
||||
|
||||
const examplePath = join(EXAMPLES_PATH, template);
|
||||
await mkdir(path, { recursive: true });
|
||||
await cp(examplePath, path, { recursive: true });
|
||||
}
|
||||
|
||||
async function handleNew(args: NewArgs) {
|
||||
try {
|
||||
await copyDirectory(args.template, args.path);
|
||||
console.log(
|
||||
`Successfully created new extension from template "${args.template}" at ${args.path}.`,
|
||||
);
|
||||
console.log(
|
||||
`You can install this using "gemini extensions link ${args.path}" to test it out.`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(getErrorMessage(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getBoilerplateChoices() {
|
||||
const entries = await readdir(EXAMPLES_PATH, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
}
|
||||
|
||||
export const newCommand: CommandModule = {
|
||||
command: 'new <path> <template>',
|
||||
describe: 'Create a new extension from a boilerplate example.',
|
||||
builder: async (yargs) => {
|
||||
const choices = await getBoilerplateChoices();
|
||||
return yargs
|
||||
.positional('path', {
|
||||
describe: 'The path to create the extension in.',
|
||||
type: 'string',
|
||||
})
|
||||
.positional('template', {
|
||||
describe: 'The boilerplate template to use.',
|
||||
type: 'string',
|
||||
choices,
|
||||
});
|
||||
},
|
||||
handler: async (args) => {
|
||||
await handleNew({
|
||||
path: args['path'] as string,
|
||||
template: args['template'] as string,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -32,7 +32,6 @@ export async function handleUpdate(args: UpdateArgs) {
|
||||
} catch (error) {
|
||||
console.error(getErrorMessage(error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (args.name)
|
||||
try {
|
||||
|
||||
@@ -217,6 +217,36 @@ describe('loadExtensions', () => {
|
||||
expect(loadedConfig.mcpServers?.['test-server'].cwd).toBe(expectedCwd);
|
||||
});
|
||||
|
||||
it('should load a linked extension correctly', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
name: 'my-linked-extension',
|
||||
version: '1.0.0',
|
||||
contextFileName: 'context.md',
|
||||
});
|
||||
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
|
||||
|
||||
const extensionName = await installExtension({
|
||||
source: sourceExtDir,
|
||||
type: 'link',
|
||||
});
|
||||
expect(extensionName).toEqual('my-linked-extension');
|
||||
const extensions = loadExtensions(tempHomeDir);
|
||||
expect(extensions).toHaveLength(1);
|
||||
|
||||
const linkedExt = extensions[0];
|
||||
expect(linkedExt.config.name).toBe('my-linked-extension');
|
||||
|
||||
expect(linkedExt.path).toBe(sourceExtDir);
|
||||
expect(linkedExt.installMetadata).toEqual({
|
||||
source: sourceExtDir,
|
||||
type: 'link',
|
||||
});
|
||||
expect(linkedExt.contextFiles).toEqual([
|
||||
path.join(sourceExtDir, 'context.md'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should resolve environment variables in extension configuration', () => {
|
||||
process.env.TEST_API_KEY = 'test-api-key-123';
|
||||
process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb';
|
||||
@@ -402,12 +432,12 @@ describe('installExtension', () => {
|
||||
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
|
||||
|
||||
vi.mocked(execSync).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should install an extension from a local path', async () => {
|
||||
@@ -487,6 +517,31 @@ describe('installExtension', () => {
|
||||
});
|
||||
fs.rmSync(targetExtDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should install a linked extension', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-linked-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension');
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
|
||||
await installExtension({ source: sourceExtDir, type: 'link' });
|
||||
|
||||
expect(fs.existsSync(targetExtDir)).toBe(true);
|
||||
expect(fs.existsSync(metadataPath)).toBe(true);
|
||||
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
|
||||
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
||||
expect(metadata).toEqual({
|
||||
source: sourceExtDir,
|
||||
type: 'link',
|
||||
});
|
||||
fs.rmSync(targetExtDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstallExtension', () => {
|
||||
@@ -701,7 +756,9 @@ function createExtension({
|
||||
}
|
||||
|
||||
if (contextFileName) {
|
||||
fs.writeFileSync(path.join(extDir, contextFileName), 'context');
|
||||
const contextPath = path.join(extDir, contextFileName);
|
||||
fs.mkdirSync(path.dirname(contextPath), { recursive: true });
|
||||
fs.writeFileSync(contextPath, 'context');
|
||||
}
|
||||
return extDir;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface ExtensionConfig {
|
||||
|
||||
export interface ExtensionInstallMetadata {
|
||||
source: string;
|
||||
type: 'git' | 'local';
|
||||
type: 'git' | 'local' | 'link';
|
||||
}
|
||||
|
||||
export interface ExtensionUpdateInfo {
|
||||
@@ -175,10 +175,20 @@ export function loadExtension(extensionDir: string): Extension | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
const installMetadata = loadInstallMetadata(extensionDir);
|
||||
let effectiveExtensionPath = extensionDir;
|
||||
|
||||
if (installMetadata?.type === 'link') {
|
||||
effectiveExtensionPath = installMetadata.source;
|
||||
}
|
||||
|
||||
const configFilePath = path.join(
|
||||
effectiveExtensionPath,
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
);
|
||||
if (!fs.existsSync(configFilePath)) {
|
||||
console.error(
|
||||
`Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`,
|
||||
`Warning: extension directory ${effectiveExtensionPath} does not contain a config file ${configFilePath}.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -200,14 +210,16 @@ export function loadExtension(extensionDir: string): Extension | null {
|
||||
config = resolveEnvVarsInObject(config);
|
||||
|
||||
const contextFiles = getContextFileNames(config)
|
||||
.map((contextFileName) => path.join(extensionDir, contextFileName))
|
||||
.map((contextFileName) =>
|
||||
path.join(effectiveExtensionPath, contextFileName),
|
||||
)
|
||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||
|
||||
return {
|
||||
path: extensionDir,
|
||||
path: effectiveExtensionPath,
|
||||
config,
|
||||
contextFiles,
|
||||
installMetadata: loadInstallMetadata(extensionDir),
|
||||
installMetadata,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(
|
||||
@@ -343,32 +355,38 @@ export async function installExtension(
|
||||
|
||||
// Convert relative paths to absolute paths for the metadata file.
|
||||
if (
|
||||
installMetadata.type === 'local' &&
|
||||
!path.isAbsolute(installMetadata.source)
|
||||
!path.isAbsolute(installMetadata.source) &&
|
||||
(installMetadata.type === 'local' || installMetadata.type === 'link')
|
||||
) {
|
||||
installMetadata.source = path.resolve(cwd, installMetadata.source);
|
||||
}
|
||||
|
||||
let localSourcePath: string;
|
||||
let tempDir: string | undefined;
|
||||
let newExtensionName: string | undefined;
|
||||
|
||||
if (installMetadata.type === 'git') {
|
||||
tempDir = await ExtensionStorage.createTmpDir();
|
||||
await cloneFromGit(installMetadata.source, tempDir);
|
||||
localSourcePath = tempDir;
|
||||
} else {
|
||||
} else if (
|
||||
installMetadata.type === 'local' ||
|
||||
installMetadata.type === 'link'
|
||||
) {
|
||||
localSourcePath = installMetadata.source;
|
||||
} else {
|
||||
throw new Error(`Unsupported install type: ${installMetadata.type}`);
|
||||
}
|
||||
let newExtensionName: string | undefined;
|
||||
|
||||
try {
|
||||
const newExtension = loadExtension(localSourcePath);
|
||||
if (!newExtension) {
|
||||
const newExtensionConfig = await loadExtensionConfig(localSourcePath);
|
||||
if (!newExtensionConfig) {
|
||||
throw new Error(
|
||||
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ~/.gemini/extensions/{ExtensionConfig.name}.
|
||||
newExtensionName = newExtension.config.name;
|
||||
newExtensionName = newExtensionConfig.name;
|
||||
const extensionStorage = new ExtensionStorage(newExtensionName);
|
||||
const destinationPath = extensionStorage.getExtensionDir();
|
||||
|
||||
@@ -383,7 +401,11 @@ export async function installExtension(
|
||||
);
|
||||
}
|
||||
|
||||
await copyExtension(localSourcePath, destinationPath);
|
||||
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||
|
||||
if (installMetadata.type === 'local' || installMetadata.type === 'git') {
|
||||
await copyExtension(localSourcePath, destinationPath);
|
||||
}
|
||||
|
||||
const metadataString = JSON.stringify(installMetadata, null, 2);
|
||||
const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME);
|
||||
@@ -397,6 +419,29 @@ export async function installExtension(
|
||||
return newExtensionName;
|
||||
}
|
||||
|
||||
async function loadExtensionConfig(
|
||||
extensionDir: string,
|
||||
): Promise<ExtensionConfig | null> {
|
||||
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
if (!fs.existsSync(configFilePath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const configContent = fs.readFileSync(configFilePath, 'utf-8');
|
||||
const config = recursivelyHydrateStrings(JSON.parse(configContent), {
|
||||
extensionPath: extensionDir,
|
||||
'/': path.sep,
|
||||
pathSeparator: path.sep,
|
||||
}) as unknown as ExtensionConfig;
|
||||
if (!config.name || !config.version) {
|
||||
return null;
|
||||
}
|
||||
return config;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uninstallExtension(
|
||||
extensionName: string,
|
||||
cwd: string = process.cwd(),
|
||||
@@ -425,7 +470,7 @@ export function toOutputString(extension: Extension): string {
|
||||
let output = `${extension.config.name} (${extension.config.version})`;
|
||||
output += `\n Path: ${extension.path}`;
|
||||
if (extension.installMetadata) {
|
||||
output += `\n Source: ${extension.installMetadata.source}`;
|
||||
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
|
||||
}
|
||||
if (extension.contextFiles.length > 0) {
|
||||
output += `\n Context files:`;
|
||||
@@ -471,14 +516,21 @@ export async function updateExtension(
|
||||
if (!extension.installMetadata) {
|
||||
throw new Error(`Extension ${extension.config.name} cannot be updated.`);
|
||||
}
|
||||
if (extension.installMetadata.type === 'link') {
|
||||
throw new Error(`Extension is linked so does not need to be updated`);
|
||||
}
|
||||
const originalVersion = extension.config.version;
|
||||
|
||||
const tempDir = await ExtensionStorage.createTmpDir();
|
||||
try {
|
||||
await copyExtension(extension.path, tempDir);
|
||||
await uninstallExtension(extension.config.name, cwd);
|
||||
await installExtension(extension.installMetadata, cwd);
|
||||
|
||||
const updatedExtension = loadExtension(extension.path);
|
||||
const updatedExtensionStorage = new ExtensionStorage(extension.config.name);
|
||||
const updatedExtension = loadExtension(
|
||||
updatedExtensionStorage.getExtensionDir(),
|
||||
);
|
||||
if (!updatedExtension) {
|
||||
throw new Error('Updated extension not found after installation.');
|
||||
}
|
||||
|
||||
@@ -53,4 +53,25 @@ if (!fs.existsSync(sourceDir)) {
|
||||
}
|
||||
|
||||
copyFilesRecursive(sourceDir, targetDir);
|
||||
|
||||
// Copy example extensions into the bundle.
|
||||
const packageName = path.basename(process.cwd());
|
||||
if (packageName === 'cli') {
|
||||
const examplesSource = path.join(
|
||||
sourceDir,
|
||||
'commands',
|
||||
'extensions',
|
||||
'examples',
|
||||
);
|
||||
const examplesTarget = path.join(
|
||||
targetDir,
|
||||
'commands',
|
||||
'extensions',
|
||||
'examples',
|
||||
);
|
||||
if (fs.existsSync(examplesSource)) {
|
||||
fs.cpSync(examplesSource, examplesTarget, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Successfully copied files.');
|
||||
|
||||
Reference in New Issue
Block a user