diff --git a/packages/cli/src/commands/extensions.tsx b/packages/cli/src/commands/extensions.tsx index be4d7160cc..42516dcea3 100644 --- a/packages/cli/src/commands/extensions.tsx +++ b/packages/cli/src/commands/extensions.tsx @@ -13,6 +13,7 @@ import { disableCommand } from './extensions/disable.js'; import { enableCommand } from './extensions/enable.js'; import { linkCommand } from './extensions/link.js'; import { newCommand } from './extensions/new.js'; +import { validateCommand } from './extensions/validate.js'; export const extensionsCommand: CommandModule = { command: 'extensions ', @@ -28,6 +29,7 @@ export const extensionsCommand: CommandModule = { .command(enableCommand) .command(linkCommand) .command(newCommand) + .command(validateCommand) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { diff --git a/packages/cli/src/commands/extensions/validate.test.ts b/packages/cli/src/commands/extensions/validate.test.ts new file mode 100644 index 0000000000..758295213d --- /dev/null +++ b/packages/cli/src/commands/extensions/validate.test.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import { describe, it, expect, vi, type MockInstance } from 'vitest'; +import { handleValidate, validateCommand } from './validate.js'; +import yargs from 'yargs'; +import { createExtension } from '../../test-utils/createExtension.js'; +import path from 'node:path'; +import * as os from 'node:os'; +import { debugLogger } from '@google/gemini-cli-core'; + +describe('extensions validate command', () => { + it('should fail if no path is provided', () => { + const validationParser = yargs([]).command(validateCommand).fail(false); + expect(() => validationParser.parse('validate')).toThrow( + 'Not enough non-option arguments: got 0, need at least 1', + ); + }); +}); + +describe('handleValidate', () => { + let debugLoggerLogSpy: MockInstance; + let debugLoggerWarnSpy: MockInstance; + let debugLoggerErrorSpy: MockInstance; + let processSpy: MockInstance; + let tempHomeDir: string; + let tempWorkspaceDir: string; + + beforeEach(() => { + debugLoggerLogSpy = vi.spyOn(debugLogger, 'log'); + debugLoggerWarnSpy = vi.spyOn(debugLogger, 'warn'); + debugLoggerErrorSpy = vi.spyOn(debugLogger, 'error'); + processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-home')); + tempWorkspaceDir = fs.mkdtempSync(path.join(tempHomeDir, 'test-workspace')); + vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + }); + + it('should validate an extension from a local dir', async () => { + createExtension({ + extensionsDir: tempWorkspaceDir, + name: 'local-ext-name', + version: '1.0.0', + }); + + await handleValidate({ + path: 'local-ext-name', + }); + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + 'Extension local-ext-name has been successfully validated.', + ); + }); + + it('should throw an error if the extension name is invalid', async () => { + createExtension({ + extensionsDir: tempWorkspaceDir, + name: 'INVALID_NAME', + version: '1.0.0', + }); + + await handleValidate({ + path: 'INVALID_NAME', + }); + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Invalid extension name: "INVALID_NAME". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.', + ), + ); + expect(processSpy).toHaveBeenCalledWith(1); + }); + + it('should warn if version is not formatted with semver', async () => { + createExtension({ + extensionsDir: tempWorkspaceDir, + name: 'valid-name', + version: '1', + }); + + await handleValidate({ + path: 'valid-name', + }); + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Version '1' does not appear to be standard semver (e.g., 1.0.0).", + ), + ); + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + 'Extension valid-name has been successfully validated.', + ); + }); + + it('should throw an error if context files are missing', async () => { + createExtension({ + extensionsDir: tempWorkspaceDir, + name: 'valid-name', + version: '1.0.0', + contextFileName: 'contextFile.md', + }); + fs.rmSync(path.join(tempWorkspaceDir, 'valid-name/contextFile.md')); + await handleValidate({ + path: 'valid-name', + }); + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'The following context files referenced in gemini-extension.json are missing: contextFile.md', + ), + ); + expect(processSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/cli/src/commands/extensions/validate.ts b/packages/cli/src/commands/extensions/validate.ts new file mode 100644 index 0000000000..c8cacc8b4f --- /dev/null +++ b/packages/cli/src/commands/extensions/validate.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { debugLogger } from '@google/gemini-cli-core'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import semver from 'semver'; +import { getErrorMessage } from '../../utils/errors.js'; +import type { ExtensionConfig } from '../../config/extension.js'; +import { ExtensionManager } from '../../config/extension-manager.js'; +import { requestConsentNonInteractive } from '../../config/extensions/consent.js'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; +import { loadSettings } from '../../config/settings.js'; + +interface ValidateArgs { + path: string; +} + +export async function handleValidate(args: ValidateArgs) { + try { + await validateExtension(args); + debugLogger.log(`Extension ${args.path} has been successfully validated.`); + } catch (error) { + debugLogger.error(getErrorMessage(error)); + process.exit(1); + } +} + +async function validateExtension(args: ValidateArgs) { + const workspaceDir = process.cwd(); + const extensionManager = new ExtensionManager({ + workspaceDir, + requestConsent: requestConsentNonInteractive, + requestSetting: promptForSetting, + settings: loadSettings(workspaceDir).merged, + }); + const absoluteInputPath = path.resolve(args.path); + const extensionConfig: ExtensionConfig = + extensionManager.loadExtensionConfig(absoluteInputPath); + const warnings: string[] = []; + const errors: string[] = []; + + if (extensionConfig.contextFileName) { + const contextFileNames = Array.isArray(extensionConfig.contextFileName) + ? extensionConfig.contextFileName + : [extensionConfig.contextFileName]; + + const missingContextFiles: string[] = []; + for (const contextFilePath of contextFileNames) { + const contextFileAbsolutePath = path.resolve( + absoluteInputPath, + contextFilePath, + ); + if (!fs.existsSync(contextFileAbsolutePath)) { + missingContextFiles.push(contextFilePath); + } + } + if (missingContextFiles.length > 0) { + errors.push( + `The following context files referenced in gemini-extension.json are missing: ${missingContextFiles}`, + ); + } + } + + if (!semver.valid(extensionConfig.version)) { + warnings.push( + `Warning: Version '${extensionConfig.version}' does not appear to be standard semver (e.g., 1.0.0).`, + ); + } + + if (warnings.length > 0) { + debugLogger.warn('Validation warnings:'); + for (const warning of warnings) { + debugLogger.warn(` - ${warning}`); + } + } + + if (errors.length > 0) { + debugLogger.error('Validation failed with the following errors:'); + for (const error of errors) { + debugLogger.error(` - ${error}`); + } + throw new Error('Extension validation failed.'); + } +} + +export const validateCommand: CommandModule = { + command: 'validate ', + describe: 'Validates an extension from a local path.', + builder: (yargs) => + yargs.positional('path', { + describe: 'The path of the extension to validate.', + type: 'string', + demandOption: true, + }), + handler: async (args) => { + await handleValidate({ + path: args['path'] as string, + }); + }, +};