mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
fix(core): show descriptive error messages when saving settings fails (#18095)
Co-authored-by: Dev Randalpura <devrandalpura@google.com>
This commit is contained in:
@@ -2594,7 +2594,7 @@ describe('Settings Loading and Merging', () => {
|
|||||||
|
|
||||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||||
'error',
|
'error',
|
||||||
'There was an error saving your latest settings changes.',
|
'Failed to save settings: Write failed',
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
FatalConfigError,
|
FatalConfigError,
|
||||||
GEMINI_DIR,
|
GEMINI_DIR,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
|
getFsErrorMessage,
|
||||||
Storage,
|
Storage,
|
||||||
coreEvents,
|
coreEvents,
|
||||||
homedir,
|
homedir,
|
||||||
@@ -1072,9 +1073,10 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
|||||||
settingsToSave as Record<string, unknown>,
|
settingsToSave as Record<string, unknown>,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const detailedErrorMessage = getFsErrorMessage(error);
|
||||||
coreEvents.emitFeedback(
|
coreEvents.emitFeedback(
|
||||||
'error',
|
'error',
|
||||||
'There was an error saving your latest settings changes.',
|
`Failed to save settings: ${detailedErrorMessage}`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1087,9 +1089,10 @@ export function saveModelChange(
|
|||||||
try {
|
try {
|
||||||
loadedSettings.setValue(SettingScope.User, 'model.name', model);
|
loadedSettings.setValue(SettingScope.User, 'model.name', model);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const detailedErrorMessage = getFsErrorMessage(error);
|
||||||
coreEvents.emitFeedback(
|
coreEvents.emitFeedback(
|
||||||
'error',
|
'error',
|
||||||
'There was an error saving your preferred model.',
|
`Failed to save preferred model: ${detailedErrorMessage}`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export * from './utils/checks.js';
|
|||||||
export * from './utils/headless.js';
|
export * from './utils/headless.js';
|
||||||
export * from './utils/schemaValidator.js';
|
export * from './utils/schemaValidator.js';
|
||||||
export * from './utils/errors.js';
|
export * from './utils/errors.js';
|
||||||
|
export * from './utils/fsErrorMessages.js';
|
||||||
export * from './utils/exitCodes.js';
|
export * from './utils/exitCodes.js';
|
||||||
export * from './utils/getFolderStructure.js';
|
export * from './utils/getFolderStructure.js';
|
||||||
export * from './utils/memoryDiscovery.js';
|
export * from './utils/memoryDiscovery.js';
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getFsErrorMessage } from './fsErrorMessages.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a mock NodeJS.ErrnoException
|
||||||
|
*/
|
||||||
|
function createNodeError(
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
path?: string,
|
||||||
|
): NodeJS.ErrnoException {
|
||||||
|
const error = new Error(message) as NodeJS.ErrnoException;
|
||||||
|
error.code = code;
|
||||||
|
if (path) {
|
||||||
|
error.path = path;
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FsErrorCase {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
path?: string;
|
||||||
|
expected: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FallbackErrorCase {
|
||||||
|
value: unknown;
|
||||||
|
expected: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getFsErrorMessage', () => {
|
||||||
|
describe('known filesystem error codes', () => {
|
||||||
|
const testCases: FsErrorCase[] = [
|
||||||
|
{
|
||||||
|
code: 'EACCES',
|
||||||
|
message: 'EACCES: permission denied',
|
||||||
|
path: '/etc/gemini-cli/settings.json',
|
||||||
|
expected:
|
||||||
|
"Permission denied: cannot access '/etc/gemini-cli/settings.json'. Check file permissions or run with elevated privileges.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'EACCES',
|
||||||
|
message: 'EACCES: permission denied',
|
||||||
|
expected:
|
||||||
|
'Permission denied. Check file permissions or run with elevated privileges.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ENOENT',
|
||||||
|
message: 'ENOENT: no such file or directory',
|
||||||
|
path: '/nonexistent/file.txt',
|
||||||
|
expected:
|
||||||
|
"File or directory not found: '/nonexistent/file.txt'. Check if the path exists and is spelled correctly.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ENOENT',
|
||||||
|
message: 'ENOENT: no such file or directory',
|
||||||
|
expected:
|
||||||
|
'File or directory not found. Check if the path exists and is spelled correctly.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ENOSPC',
|
||||||
|
message: 'ENOSPC: no space left on device',
|
||||||
|
expected:
|
||||||
|
'No space left on device. Free up some disk space and try again.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'EISDIR',
|
||||||
|
message: 'EISDIR: illegal operation on a directory',
|
||||||
|
path: '/some/directory',
|
||||||
|
expected:
|
||||||
|
"Path is a directory, not a file: '/some/directory'. Please provide a path to a file instead.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'EISDIR',
|
||||||
|
message: 'EISDIR: illegal operation on a directory',
|
||||||
|
expected:
|
||||||
|
'Path is a directory, not a file. Please provide a path to a file instead.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'EROFS',
|
||||||
|
message: 'EROFS: read-only file system',
|
||||||
|
expected:
|
||||||
|
'Read-only file system. Ensure the file system allows write operations.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'EPERM',
|
||||||
|
message: 'EPERM: operation not permitted',
|
||||||
|
path: '/protected/file',
|
||||||
|
expected:
|
||||||
|
"Operation not permitted: '/protected/file'. Ensure you have the required permissions for this action.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'EPERM',
|
||||||
|
message: 'EPERM: operation not permitted',
|
||||||
|
expected:
|
||||||
|
'Operation not permitted. Ensure you have the required permissions for this action.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'EEXIST',
|
||||||
|
message: 'EEXIST: file already exists',
|
||||||
|
path: '/existing/file',
|
||||||
|
expected:
|
||||||
|
"File or directory already exists: '/existing/file'. Try using a different name or path.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'EEXIST',
|
||||||
|
message: 'EEXIST: file already exists',
|
||||||
|
expected:
|
||||||
|
'File or directory already exists. Try using a different name or path.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'EBUSY',
|
||||||
|
message: 'EBUSY: resource busy or locked',
|
||||||
|
path: '/locked/file',
|
||||||
|
expected:
|
||||||
|
"Resource busy or locked: '/locked/file'. Close any programs that might be using the file.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'EBUSY',
|
||||||
|
message: 'EBUSY: resource busy or locked',
|
||||||
|
expected:
|
||||||
|
'Resource busy or locked. Close any programs that might be using the file.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'EMFILE',
|
||||||
|
message: 'EMFILE: too many open files',
|
||||||
|
expected:
|
||||||
|
'Too many open files. Close some unused files or applications.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ENFILE',
|
||||||
|
message: 'ENFILE: file table overflow',
|
||||||
|
expected:
|
||||||
|
'Too many open files in system. Close some unused files or applications.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(testCases)(
|
||||||
|
'returns friendly message for $code (path: $path)',
|
||||||
|
({ code, message, path, expected }) => {
|
||||||
|
const error = createNodeError(code, message, path);
|
||||||
|
expect(getFsErrorMessage(error)).toBe(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unknown node error codes', () => {
|
||||||
|
const testCases: FsErrorCase[] = [
|
||||||
|
{
|
||||||
|
code: 'EUNKNOWN',
|
||||||
|
message: 'Some unknown error occurred',
|
||||||
|
expected: 'Some unknown error occurred (EUNKNOWN)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'toString',
|
||||||
|
message: 'Unexpected error',
|
||||||
|
path: '/some/path',
|
||||||
|
expected: 'Unexpected error (toString)',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(testCases)(
|
||||||
|
'includes code in fallback message for $code',
|
||||||
|
({ code, message, path, expected }) => {
|
||||||
|
const error = createNodeError(code, message, path);
|
||||||
|
expect(getFsErrorMessage(error)).toBe(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('non-node and nullish errors', () => {
|
||||||
|
const fallbackCases: FallbackErrorCase[] = [
|
||||||
|
{
|
||||||
|
value: new Error('Something went wrong'),
|
||||||
|
expected: 'Something went wrong',
|
||||||
|
},
|
||||||
|
{ value: 'string error', expected: 'string error' },
|
||||||
|
{ value: 12345, expected: '12345' },
|
||||||
|
{ value: null, expected: 'An unknown error occurred' },
|
||||||
|
{ value: undefined, expected: 'An unknown error occurred' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(fallbackCases)(
|
||||||
|
'returns a message for $value',
|
||||||
|
({ value, expected }) => {
|
||||||
|
expect(getFsErrorMessage(value)).toBe(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([null, undefined] as const)(
|
||||||
|
'uses custom default for %s',
|
||||||
|
(value) => {
|
||||||
|
expect(getFsErrorMessage(value, 'Custom default')).toBe(
|
||||||
|
'Custom default',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isNodeError, getErrorMessage } from './errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of Node.js filesystem error codes to user-friendly message generators.
|
||||||
|
* Each function takes the path (if available) and returns a descriptive message.
|
||||||
|
*/
|
||||||
|
const errorMessageGenerators: Record<string, (path?: string) => string> = {
|
||||||
|
EACCES: (path) =>
|
||||||
|
(path
|
||||||
|
? `Permission denied: cannot access '${path}'. `
|
||||||
|
: 'Permission denied. ') +
|
||||||
|
'Check file permissions or run with elevated privileges.',
|
||||||
|
ENOENT: (path) =>
|
||||||
|
(path
|
||||||
|
? `File or directory not found: '${path}'. `
|
||||||
|
: 'File or directory not found. ') +
|
||||||
|
'Check if the path exists and is spelled correctly.',
|
||||||
|
ENOSPC: () =>
|
||||||
|
'No space left on device. Free up some disk space and try again.',
|
||||||
|
EISDIR: (path) =>
|
||||||
|
(path
|
||||||
|
? `Path is a directory, not a file: '${path}'. `
|
||||||
|
: 'Path is a directory, not a file. ') +
|
||||||
|
'Please provide a path to a file instead.',
|
||||||
|
EROFS: () =>
|
||||||
|
'Read-only file system. Ensure the file system allows write operations.',
|
||||||
|
EPERM: (path) =>
|
||||||
|
(path
|
||||||
|
? `Operation not permitted: '${path}'. `
|
||||||
|
: 'Operation not permitted. ') +
|
||||||
|
'Ensure you have the required permissions for this action.',
|
||||||
|
EEXIST: (path) =>
|
||||||
|
(path
|
||||||
|
? `File or directory already exists: '${path}'. `
|
||||||
|
: 'File or directory already exists. ') +
|
||||||
|
'Try using a different name or path.',
|
||||||
|
EBUSY: (path) =>
|
||||||
|
(path
|
||||||
|
? `Resource busy or locked: '${path}'. `
|
||||||
|
: 'Resource busy or locked. ') +
|
||||||
|
'Close any programs that might be using the file.',
|
||||||
|
EMFILE: () => 'Too many open files. Close some unused files or applications.',
|
||||||
|
ENFILE: () =>
|
||||||
|
'Too many open files in system. Close some unused files or applications.',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a Node.js filesystem error to a user-friendly message.
|
||||||
|
*
|
||||||
|
* @param error - The error to convert
|
||||||
|
* @param defaultMessage - Optional default message if error cannot be interpreted
|
||||||
|
* @returns A user-friendly error message
|
||||||
|
*/
|
||||||
|
export function getFsErrorMessage(
|
||||||
|
error: unknown,
|
||||||
|
defaultMessage = 'An unknown error occurred',
|
||||||
|
): string {
|
||||||
|
if (error == null) {
|
||||||
|
return defaultMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNodeError(error)) {
|
||||||
|
const code = error.code;
|
||||||
|
const path = error.path;
|
||||||
|
|
||||||
|
if (code && Object.hasOwn(errorMessageGenerators, code)) {
|
||||||
|
return errorMessageGenerators[code](path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For unknown error codes, include the code in the message
|
||||||
|
if (code) {
|
||||||
|
const baseMessage = error.message || defaultMessage;
|
||||||
|
return `${baseMessage} (${code})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-Node errors, return the error message or string representation
|
||||||
|
return getErrorMessage(error);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user