Skip to content

Commit a3370ac

Browse files
authored
Add validate command (google-gemini#12186)
1 parent 7d03151 commit a3370ac

File tree

3 files changed

+229
-0
lines changed

3 files changed

+229
-0
lines changed

packages/cli/src/commands/extensions.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { disableCommand } from './extensions/disable.js';
1313
import { enableCommand } from './extensions/enable.js';
1414
import { linkCommand } from './extensions/link.js';
1515
import { newCommand } from './extensions/new.js';
16+
import { validateCommand } from './extensions/validate.js';
1617

1718
export const extensionsCommand: CommandModule = {
1819
command: 'extensions <command>',
@@ -28,6 +29,7 @@ export const extensionsCommand: CommandModule = {
2829
.command(enableCommand)
2930
.command(linkCommand)
3031
.command(newCommand)
32+
.command(validateCommand)
3133
.demandCommand(1, 'You need at least one command before continuing.')
3234
.version(false),
3335
handler: () => {
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as fs from 'node:fs';
8+
import { describe, it, expect, vi, type MockInstance } from 'vitest';
9+
import { handleValidate, validateCommand } from './validate.js';
10+
import yargs from 'yargs';
11+
import { createExtension } from '../../test-utils/createExtension.js';
12+
import path from 'node:path';
13+
import * as os from 'node:os';
14+
import { debugLogger } from '@google/gemini-cli-core';
15+
16+
describe('extensions validate command', () => {
17+
it('should fail if no path is provided', () => {
18+
const validationParser = yargs([]).command(validateCommand).fail(false);
19+
expect(() => validationParser.parse('validate')).toThrow(
20+
'Not enough non-option arguments: got 0, need at least 1',
21+
);
22+
});
23+
});
24+
25+
describe('handleValidate', () => {
26+
let debugLoggerLogSpy: MockInstance;
27+
let debugLoggerWarnSpy: MockInstance;
28+
let debugLoggerErrorSpy: MockInstance;
29+
let processSpy: MockInstance;
30+
let tempHomeDir: string;
31+
let tempWorkspaceDir: string;
32+
33+
beforeEach(() => {
34+
debugLoggerLogSpy = vi.spyOn(debugLogger, 'log');
35+
debugLoggerWarnSpy = vi.spyOn(debugLogger, 'warn');
36+
debugLoggerErrorSpy = vi.spyOn(debugLogger, 'error');
37+
processSpy = vi
38+
.spyOn(process, 'exit')
39+
.mockImplementation(() => undefined as never);
40+
tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-home'));
41+
tempWorkspaceDir = fs.mkdtempSync(path.join(tempHomeDir, 'test-workspace'));
42+
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
43+
});
44+
45+
afterEach(() => {
46+
vi.restoreAllMocks();
47+
fs.rmSync(tempHomeDir, { recursive: true, force: true });
48+
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
49+
});
50+
51+
it('should validate an extension from a local dir', async () => {
52+
createExtension({
53+
extensionsDir: tempWorkspaceDir,
54+
name: 'local-ext-name',
55+
version: '1.0.0',
56+
});
57+
58+
await handleValidate({
59+
path: 'local-ext-name',
60+
});
61+
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
62+
'Extension local-ext-name has been successfully validated.',
63+
);
64+
});
65+
66+
it('should throw an error if the extension name is invalid', async () => {
67+
createExtension({
68+
extensionsDir: tempWorkspaceDir,
69+
name: 'INVALID_NAME',
70+
version: '1.0.0',
71+
});
72+
73+
await handleValidate({
74+
path: 'INVALID_NAME',
75+
});
76+
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
77+
expect.stringContaining(
78+
'Invalid extension name: "INVALID_NAME". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.',
79+
),
80+
);
81+
expect(processSpy).toHaveBeenCalledWith(1);
82+
});
83+
84+
it('should warn if version is not formatted with semver', async () => {
85+
createExtension({
86+
extensionsDir: tempWorkspaceDir,
87+
name: 'valid-name',
88+
version: '1',
89+
});
90+
91+
await handleValidate({
92+
path: 'valid-name',
93+
});
94+
expect(debugLoggerWarnSpy).toHaveBeenCalledWith(
95+
expect.stringContaining(
96+
"Version '1' does not appear to be standard semver (e.g., 1.0.0).",
97+
),
98+
);
99+
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
100+
'Extension valid-name has been successfully validated.',
101+
);
102+
});
103+
104+
it('should throw an error if context files are missing', async () => {
105+
createExtension({
106+
extensionsDir: tempWorkspaceDir,
107+
name: 'valid-name',
108+
version: '1.0.0',
109+
contextFileName: 'contextFile.md',
110+
});
111+
fs.rmSync(path.join(tempWorkspaceDir, 'valid-name/contextFile.md'));
112+
await handleValidate({
113+
path: 'valid-name',
114+
});
115+
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
116+
expect.stringContaining(
117+
'The following context files referenced in gemini-extension.json are missing: contextFile.md',
118+
),
119+
);
120+
expect(processSpy).toHaveBeenCalledWith(1);
121+
});
122+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type { CommandModule } from 'yargs';
8+
import { debugLogger } from '@google/gemini-cli-core';
9+
import * as fs from 'node:fs';
10+
import * as path from 'node:path';
11+
import semver from 'semver';
12+
import { getErrorMessage } from '../../utils/errors.js';
13+
import type { ExtensionConfig } from '../../config/extension.js';
14+
import { ExtensionManager } from '../../config/extension-manager.js';
15+
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
16+
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
17+
import { loadSettings } from '../../config/settings.js';
18+
19+
interface ValidateArgs {
20+
path: string;
21+
}
22+
23+
export async function handleValidate(args: ValidateArgs) {
24+
try {
25+
await validateExtension(args);
26+
debugLogger.log(`Extension ${args.path} has been successfully validated.`);
27+
} catch (error) {
28+
debugLogger.error(getErrorMessage(error));
29+
process.exit(1);
30+
}
31+
}
32+
33+
async function validateExtension(args: ValidateArgs) {
34+
const workspaceDir = process.cwd();
35+
const extensionManager = new ExtensionManager({
36+
workspaceDir,
37+
requestConsent: requestConsentNonInteractive,
38+
requestSetting: promptForSetting,
39+
settings: loadSettings(workspaceDir).merged,
40+
});
41+
const absoluteInputPath = path.resolve(args.path);
42+
const extensionConfig: ExtensionConfig =
43+
extensionManager.loadExtensionConfig(absoluteInputPath);
44+
const warnings: string[] = [];
45+
const errors: string[] = [];
46+
47+
if (extensionConfig.contextFileName) {
48+
const contextFileNames = Array.isArray(extensionConfig.contextFileName)
49+
? extensionConfig.contextFileName
50+
: [extensionConfig.contextFileName];
51+
52+
const missingContextFiles: string[] = [];
53+
for (const contextFilePath of contextFileNames) {
54+
const contextFileAbsolutePath = path.resolve(
55+
absoluteInputPath,
56+
contextFilePath,
57+
);
58+
if (!fs.existsSync(contextFileAbsolutePath)) {
59+
missingContextFiles.push(contextFilePath);
60+
}
61+
}
62+
if (missingContextFiles.length > 0) {
63+
errors.push(
64+
`The following context files referenced in gemini-extension.json are missing: ${missingContextFiles}`,
65+
);
66+
}
67+
}
68+
69+
if (!semver.valid(extensionConfig.version)) {
70+
warnings.push(
71+
`Warning: Version '${extensionConfig.version}' does not appear to be standard semver (e.g., 1.0.0).`,
72+
);
73+
}
74+
75+
if (warnings.length > 0) {
76+
debugLogger.warn('Validation warnings:');
77+
for (const warning of warnings) {
78+
debugLogger.warn(` - ${warning}`);
79+
}
80+
}
81+
82+
if (errors.length > 0) {
83+
debugLogger.error('Validation failed with the following errors:');
84+
for (const error of errors) {
85+
debugLogger.error(` - ${error}`);
86+
}
87+
throw new Error('Extension validation failed.');
88+
}
89+
}
90+
91+
export const validateCommand: CommandModule = {
92+
command: 'validate <path>',
93+
describe: 'Validates an extension from a local path.',
94+
builder: (yargs) =>
95+
yargs.positional('path', {
96+
describe: 'The path of the extension to validate.',
97+
type: 'string',
98+
demandOption: true,
99+
}),
100+
handler: async (args) => {
101+
await handleValidate({
102+
path: args['path'] as string,
103+
});
104+
},
105+
};

0 commit comments

Comments
 (0)