Add validate command (#12186)

This commit is contained in:
kevinjwang1
2025-10-30 16:08:13 +00:00
committed by GitHub
parent 7d03151cd5
commit a3370ac86b
3 changed files with 229 additions and 0 deletions

View File

@@ -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 <command>',
@@ -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: () => {

View File

@@ -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);
});
});

View File

@@ -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 <path>',
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,
});
},
};