mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
[extensions] Add extensions update command (#6878)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import { CommandModule } from 'yargs';
|
|||||||
import { installCommand } from './extensions/install.js';
|
import { installCommand } from './extensions/install.js';
|
||||||
import { uninstallCommand } from './extensions/uninstall.js';
|
import { uninstallCommand } from './extensions/uninstall.js';
|
||||||
import { listCommand } from './extensions/list.js';
|
import { listCommand } from './extensions/list.js';
|
||||||
|
import { updateCommand } from './extensions/update.js';
|
||||||
|
|
||||||
export const extensionsCommand: CommandModule = {
|
export const extensionsCommand: CommandModule = {
|
||||||
command: 'extensions <command>',
|
command: 'extensions <command>',
|
||||||
@@ -17,6 +18,7 @@ export const extensionsCommand: CommandModule = {
|
|||||||
.command(installCommand)
|
.command(installCommand)
|
||||||
.command(uninstallCommand)
|
.command(uninstallCommand)
|
||||||
.command(listCommand)
|
.command(listCommand)
|
||||||
|
.command(updateCommand)
|
||||||
.demandCommand(1, 'You need at least one command before continuing.')
|
.demandCommand(1, 'You need at least one command before continuing.')
|
||||||
.version(false),
|
.version(false),
|
||||||
handler: () => {
|
handler: () => {
|
||||||
|
|||||||
46
packages/cli/src/commands/extensions/update.ts
Normal file
46
packages/cli/src/commands/extensions/update.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CommandModule } from 'yargs';
|
||||||
|
import { updateExtension } from '../../config/extension.js';
|
||||||
|
|
||||||
|
interface UpdateArgs {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdate(args: UpdateArgs) {
|
||||||
|
try {
|
||||||
|
// TODO(chrstnb): we should list extensions if the requested extension is not installed.
|
||||||
|
const updatedExtensionInfo = await updateExtension(args.name);
|
||||||
|
if (!updatedExtensionInfo) {
|
||||||
|
console.log(`Extension "${args.name}" failed to update.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`Extension "${args.name}" successfully updated: ${updatedExtensionInfo.originalVersion} → ${updatedExtensionInfo.updatedVersion}.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error((error as Error).message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateCommand: CommandModule = {
|
||||||
|
command: 'update <name>',
|
||||||
|
describe: 'Updates an extension.',
|
||||||
|
builder: (yargs) =>
|
||||||
|
yargs
|
||||||
|
.positional('name', {
|
||||||
|
describe: 'The name of the extension to update.',
|
||||||
|
type: 'string',
|
||||||
|
})
|
||||||
|
.check((_argv) => true),
|
||||||
|
handler: async (argv) => {
|
||||||
|
await handleUpdate({
|
||||||
|
name: argv['name'] as string,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
installExtension,
|
installExtension,
|
||||||
loadExtensions,
|
loadExtensions,
|
||||||
uninstallExtension,
|
uninstallExtension,
|
||||||
|
updateExtension,
|
||||||
} from './extension.js';
|
} from './extension.js';
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import { SimpleGit, simpleGit } from 'simple-git';
|
import { SimpleGit, simpleGit } from 'simple-git';
|
||||||
@@ -231,7 +232,7 @@ describe('installExtension', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
installExtension({ source: sourceExtDir, type: 'local' }),
|
installExtension({ source: sourceExtDir, type: 'local' }),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
'Error: Extension "my-local-extension" is already installed. Please uninstall it first.',
|
'Extension "my-local-extension" is already installed. Please uninstall it first.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -335,7 +336,7 @@ describe('uninstallExtension', () => {
|
|||||||
|
|
||||||
it('should throw an error if the extension does not exist', async () => {
|
it('should throw an error if the extension does not exist', async () => {
|
||||||
await expect(uninstallExtension('nonexistent-extension')).rejects.toThrow(
|
await expect(uninstallExtension('nonexistent-extension')).rejects.toThrow(
|
||||||
'Error: Extension "nonexistent-extension" not found.',
|
'Extension "nonexistent-extension" not found.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -363,3 +364,77 @@ function createExtension(
|
|||||||
}
|
}
|
||||||
return extDir;
|
return extDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('updateExtension', () => {
|
||||||
|
let tempHomeDir: string;
|
||||||
|
let userExtensionsDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempHomeDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||||
|
);
|
||||||
|
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
|
||||||
|
userExtensionsDir = path.join(tempHomeDir, '.gemini', 'extensions');
|
||||||
|
// Clean up before each test
|
||||||
|
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||||
|
|
||||||
|
vi.mocked(execSync).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update a git-installed extension', async () => {
|
||||||
|
// 1. "Install" an extension
|
||||||
|
const gitUrl = 'https://github.com/google/gemini-extensions.git';
|
||||||
|
const extensionName = 'gemini-extensions';
|
||||||
|
const targetExtDir = path.join(userExtensionsDir, extensionName);
|
||||||
|
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||||
|
|
||||||
|
// Create the "installed" extension directory and files
|
||||||
|
fs.mkdirSync(targetExtDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
|
||||||
|
JSON.stringify({ name: extensionName, version: '1.0.0' }),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
metadataPath,
|
||||||
|
JSON.stringify({ source: gitUrl, type: 'git' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Mock the git clone for the update
|
||||||
|
const clone = vi.fn().mockImplementation(async (_, destination) => {
|
||||||
|
fs.mkdirSync(destination, { recursive: true });
|
||||||
|
// This is the "updated" version
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(destination, EXTENSIONS_CONFIG_FILENAME),
|
||||||
|
JSON.stringify({ name: extensionName, version: '1.1.0' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockedSimpleGit = simpleGit as vi.MockedFunction<typeof simpleGit>;
|
||||||
|
mockedSimpleGit.mockReturnValue({
|
||||||
|
clone,
|
||||||
|
} as unknown as SimpleGit);
|
||||||
|
|
||||||
|
// 3. Call updateExtension
|
||||||
|
const updateInfo = await updateExtension(extensionName);
|
||||||
|
|
||||||
|
// 4. Assertions
|
||||||
|
expect(updateInfo).toEqual({
|
||||||
|
originalVersion: '1.0.0',
|
||||||
|
updatedVersion: '1.1.0',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that the config file reflects the new version
|
||||||
|
const updatedConfig = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
|
||||||
|
'utf-8',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(updatedConfig.version).toBe('1.1.0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ export interface ExtensionInstallMetadata {
|
|||||||
type: 'git' | 'local';
|
type: 'git' | 'local';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExtensionUpdateInfo {
|
||||||
|
originalVersion: string;
|
||||||
|
updatedVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ExtensionStorage {
|
export class ExtensionStorage {
|
||||||
private readonly extensionName: string;
|
private readonly extensionName: string;
|
||||||
|
|
||||||
@@ -321,7 +326,7 @@ export async function installExtension(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Error: Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
|
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,7 +351,7 @@ export async function uninstallExtension(extensionName: string): Promise<void> {
|
|||||||
(installed) => installed.config.name === extensionName,
|
(installed) => installed.config.name === extensionName,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw new Error(`Error: Extension "${extensionName}" not found.`);
|
throw new Error(`Extension "${extensionName}" not found.`);
|
||||||
}
|
}
|
||||||
const storage = new ExtensionStorage(extensionName);
|
const storage = new ExtensionStorage(extensionName);
|
||||||
return await fs.promises.rm(storage.getExtensionDir(), {
|
return await fs.promises.rm(storage.getExtensionDir(), {
|
||||||
@@ -381,3 +386,45 @@ export function toOutputString(extension: Extension): string {
|
|||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateExtension(
|
||||||
|
extensionName: string,
|
||||||
|
): Promise<ExtensionUpdateInfo | undefined> {
|
||||||
|
const installedExtensions = loadUserExtensions();
|
||||||
|
const extension = installedExtensions.find(
|
||||||
|
(installed) => installed.config.name === extensionName,
|
||||||
|
);
|
||||||
|
if (!extension) {
|
||||||
|
throw new Error(
|
||||||
|
`Extension "${extensionName}" not found. Run gemini extensions list to see available extensions.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!extension.installMetadata) {
|
||||||
|
throw new Error(
|
||||||
|
`Extension cannot be updated because it is missing the .gemini-extension.install.json file. To update manually, uninstall and then reinstall the updated version.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const originalVersion = extension.config.version;
|
||||||
|
const tempDir = await ExtensionStorage.createTmpDir();
|
||||||
|
try {
|
||||||
|
await copyExtension(extension.path, tempDir);
|
||||||
|
await uninstallExtension(extensionName);
|
||||||
|
await installExtension(extension.installMetadata);
|
||||||
|
|
||||||
|
const updatedExtension = loadExtension(extension.path);
|
||||||
|
if (!updatedExtension) {
|
||||||
|
throw new Error('Updated extension not found after installation.');
|
||||||
|
}
|
||||||
|
const updatedVersion = updatedExtension.config.version;
|
||||||
|
return {
|
||||||
|
originalVersion,
|
||||||
|
updatedVersion,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error updating extension, rolling back. ${e}`);
|
||||||
|
await copyExtension(tempDir, extension.path);
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user