Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,63 @@ program
}
});

program
.command("remove <family...>")
.description("Remove installed Google Font files from the system")
.option("-v, --variants <variants>", "Variants separated by comma")
.action(async (family, options) => {
const refresh = program.opts().refreshCache;
const variants = options.variants ? options.variants.split(",") : false;
const families = splitFamilies(family);

try {
await ensureFontsLoaded(refresh);
/** @type {FontResult[]} */
let allResults = [];
let successCount = 0;
let failCount = 0;

for (const term of families) {
try {
const filteredList = await getFontByNameAsync(term);
if (filteredList.data.length !== 1) {
handleMatchError("Removal", term, null);
failCount++;
continue;
}
const font = filteredList.getFirst();
if (!font) {
handleMatchError("Removal", term, null);
failCount++;
continue;
}
const result = await font.uninstallAsync(variants);
allResults = allResults.concat(result);
successCount++;
} catch (err) {
handleMatchError("Removal", term, /** @type {Error} */ (err));
failCount++;
}
}

if (allResults.length > 0) {
printResult(null, allResults);
}

if (failCount > 0 && successCount === 0) {
console.error(pc.red(pc.bold(`\nAll ${failCount} font removal(s) failed.`)));
process.exit(1);
}

if (failCount > 0 && successCount > 0) {
console.log(pc.yellow(`\n${successCount} font(s) removed successfully, ${failCount} failed.`));
}
} catch (err) {
console.error(pc.red(/** @type {Error} */ (err).toString()));
process.exit(1);
}
});

program
.command("copy <family...>")
.description("Copy Google Fonts stylesheet link to clipboard")
Expand Down
30 changes: 30 additions & 0 deletions lib/google-font.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,36 @@ googleFont.prototype.installAsync = async function(variants) {
return resultList;
};

/**
* Remove installed font variants from the system font folder
* @param {string[] | false} [variants] - Variants to remove, or false for all
* @returns {Promise<FontResult[]>} Results of removed fonts (one entry per variant)
*/
googleFont.prototype.uninstallAsync = async function(variants) {
const fileList = await this._getFileMapAsync(DEFAULT_FORMAT);
const requested = (variants && variants.length) ? variants : Object.keys(fileList);
const resultList = [];
const fileName = await toPascalCase(this.getFamily());

for (const v of requested) {
const norm = this._normalizeVariant(v);
const url = fileList[norm];

if (url) {
try {
const paths = await systemFont.uninstall(fileName + '-' + norm);
for (const p of paths) {
resultList.push({ family: this.getFamily(), variant: norm, path: p });
}
} catch (err) {
throw err;
}
}
}

return resultList;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove silently succeeds when no files are deleted

Medium Severity

In uninstallAsync, the if (url) guard silently skips any variant whose name doesn't match a key in the GWFH API file map. If all user-specified variants are skipped (e.g., typos or API changes since installation), the function returns an empty array without error. In cli.js, this increments successCount and produces zero output — the user gets no feedback and nothing was removed, yet the command appears to succeed.

Additional Locations (1)
Fix in Cursor Fix in Web

};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uninstall aborts on first missing variant, losing deletions

High Severity

uninstallAsync re-throws immediately when systemFont.uninstall fails for any variant (e.g., a variant that was never installed). When no --variants flag is given, the method attempts all variants from the API. If only a subset was installed, the first non-installed variant throws "Font file not found," aborting the loop. Files already deleted by earlier iterations are gone, but the partial resultList is never returned. The CLI counts this as a failure, leaving the user unaware that some fonts were actually removed. Unlike saveAtAsync, which collects errors and continues, this method uses a catch (err) { throw err; } pattern that makes partial uninstalls both destructive and unreported.

Fix in Cursor Fix in Web


/**
* Save font variants to a specified folder
* @param {string[] | false} [variants] - Variants to download, or false for all
Expand Down
68 changes: 68 additions & 0 deletions lib/system-font.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,74 @@ SystemFont.prototype.install = async function(remoteFile, fileName) {
}
};

/**
* Candidate paths for an installed font file (TTF and WOFF2) for the current platform.
* @param {string} fileBase - Base filename without extension (e.g. OpenSans-regular)
* @returns {string[]}
*/
SystemFont.prototype._uninstallCandidatePaths = function(fileBase) {
/** @type {string[]} */
const dirs = [];
switch (platform) {
case 'linux': {
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
dirs.push(path.join(xdgDataHome, 'fonts'));
break;
}
case 'darwin':
dirs.push(path.join(os.homedir(), 'Library', 'Fonts'));
break;
case 'win32': {
const localAppData = process.env.LOCALAPPDATA;
if (localAppData) {
dirs.push(path.join(localAppData, 'Microsoft', 'Windows', 'Fonts'));
}
break;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Windows uninstall path mismatches install destination

Medium Severity

On Windows, install uses Shell.Application.Namespace(0x14) which dynamically resolves to either C:\Windows\Fonts (admin) or %LOCALAPPDATA%\...\Fonts (non-admin on Win10 1809+). The new uninstall hardcodes only the %LOCALAPPDATA% per-user path. Fonts installed with admin privileges live in a different directory and will never be found by uninstall, yielding a misleading "Font file not found" error. Additionally, fs.unlink alone doesn't remove the Windows Registry entry (HKCU\...\Fonts), so even when the file is found, the font remains registered and appears broken to applications.

Additional Locations (1)
Fix in Cursor Fix in Web

}
Comment on lines +260 to +266
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the Windows install path behavior by checking the PowerShell namespace constant

# Search for how install handles Windows to understand the target directory
rg -n -A 10 "Namespace\(0x14\)" --type js

# Check if there are any comments or documentation about Windows font paths
rg -n -i "windows.*font" --type js --type md

Repository: t2sever/gfcli

Length of output: 1599


🌐 Web query:

What is the Shell.Application Namespace(0x14) fonts folder path in Windows 10 and 11?

💡 Result:

In Windows 10 and Windows 11, Shell.Application.Namespace(0x14) (aka ssfFONTS / CSIDL_FONTS) resolves to the system Fonts folder:

Citations:


Windows install/uninstall path mismatch prevents font removal.

The install method (lines 199-236) uses PowerShell with Shell.Application.Namespace(0x14) to copy fonts to the system fonts folder (C:\Windows\Fonts). However, _uninstallCandidatePaths searches for fonts in %LOCALAPPDATA%\Microsoft\Windows\Fonts (the per-user fonts folder).

Fonts installed via this tool will not be found during uninstall on Windows, causing "Font file not found" errors. Update the uninstall path to match the system folder where install places fonts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/system-font.js` around lines 260 - 266, The uninstall search currently
only adds the per-user fonts folder (%LOCALAPPDATA%\Microsoft\Windows\Fonts) for
Windows, but install copies to the system fonts folder (C:\Windows\Fonts) in
install; update the _uninstallCandidatePaths logic so the Windows branch also
includes the system fonts folder used by install (e.g.,
path.join(process.env.WINDIR || process.env.SystemROOT || 'C:\\Windows',
'Fonts')) so that _uninstallCandidatePaths (and related uninstall routines) can
find files installed by install.

default:
throw new Error('Platform not supported.');
}
/** @type {string[]} */
const out = [];
for (const d of dirs) {
out.push(path.join(d, fileBase + '.ttf'));
out.push(path.join(d, fileBase + '.woff2'));
}
return out;
};

/**
* Remove installed font files matching the same basename used by install (TTF or WOFF2).
* @param {string} fileBase - Base filename without extension
* @returns {Promise<string[]>} Paths that were removed
*/
SystemFont.prototype.uninstall = async function(fileBase) {
const candidates = this._uninstallCandidatePaths(fileBase);
/** @type {string[]} */
const removed = [];
for (const p of candidates) {
try {
await fs.unlink(p);
removed.push(p);
} catch (err) {
if (/** @type {NodeJS.ErrnoException} */ (err).code !== 'ENOENT') {
throw err;
}
}
}
if (removed.length === 0) {
throw new Error('Font file not found for: ' + fileBase);
}
if (platform === 'linux') {
try {
await util.promisify(exec)('fc-cache -f');
} catch (err) {
// fc-cache might not be available
}
}
return removed;
};

/**
* Get a supported extension from the remote URL.
* @param {string} remoteFile
Expand Down
1 change: 1 addition & 0 deletions lib/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export interface GoogleFontInstance extends FontData {
_getFileMapAsync(format?: FontFormat): Promise<Record<string, string>>;
install(variants?: string[] | false, callback?: FontResultCallback): void;
installAsync(variants?: string[] | false): Promise<FontResult[]>;
uninstallAsync(variants?: string[] | false): Promise<FontResult[]>;
saveAt(variants?: string[] | false, destFolder?: string, format?: FontFormat | FontResultCallback, callback?: FontResultCallback): void;
saveAtAsync(variants?: string[] | false, destFolder?: string, format?: FontFormat): Promise<FontResult[]>;
_normalizeVariant(variant: string): string;
Expand Down
19 changes: 18 additions & 1 deletion test/google-font.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ jest.mock('../lib/case', () => ({
jest.mock('../lib/request');
jest.mock('../lib/system-font', () => ({
install: jest.fn(),
saveAt: jest.fn()
saveAt: jest.fn(),
uninstall: jest.fn()
}));

const GoogleFont = require('../lib/google-font');
Expand Down Expand Up @@ -165,6 +166,22 @@ describe('GoogleFont', () => {
expect(systemFont.saveAt).toHaveBeenCalledWith('https://cdn.example.com/noto-sans-jp-regular.ttf', '/fonts', 'NotoSansJP-regular');
});

it('uses PascalCase basenames for uninstallAsync', async () => {
const font = new GoogleFont({ family: 'Open Sans' });
systemFont.uninstall.mockResolvedValue(['/fonts/OpenSans-regular.ttf']);

const promise = font.uninstallAsync(['regular']);
pendingRequests[0].emit('success', JSON.stringify({
variants: [{ id: 'regular', ttf: 'https://cdn.example.com/open-sans-regular.ttf' }]
}));

await expect(promise).resolves.toEqual([
{ family: 'Open Sans', variant: 'regular', path: '/fonts/OpenSans-regular.ttf' }
]);
expect(toPascalCase).toHaveBeenCalledWith('Open Sans');
expect(systemFont.uninstall).toHaveBeenCalledWith('OpenSans-regular');
});

it('collects partial save results and throws AggregateError for failures', async () => {
const font = new GoogleFont({ family: 'Inter' });
systemFont.saveAt
Expand Down
21 changes: 21 additions & 0 deletions test/system-font.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ describe('SystemFont', () => {
expect(typeof systemFont.install).toBe('function');
expect(typeof systemFont.saveAt).toBe('function');
expect(typeof systemFont.saveHere).toBe('function');
expect(typeof systemFont.uninstall).toBe('function');
});

it('_checkDestFolder resolves absolute paths', async () => {
Expand Down Expand Up @@ -133,4 +134,24 @@ describe('SystemFont', () => {
expect(systemFont._isValidFontFile(undefined, '.ttf')).toBe(true);
expect(systemFont._isValidFontFile(undefined, '.pdf')).toBe(false);
});

it('uninstall removes existing font files and returns their paths', async () => {
jest.spyOn(fsPromises, 'unlink').mockResolvedValue(undefined);
const execSpy = jest.spyOn(require('child_process'), 'exec').mockImplementation((cmd, cb) => {
if (typeof cb === 'function') cb(null, '', '');
return /** @type {any} */ ({});
});

const result = await systemFont.uninstall('TestFont-regular');

expect(fsPromises.unlink).toHaveBeenCalled();
expect(result.length).toBeGreaterThan(0);
execSpy.mockRestore();
});

it('uninstall throws when no matching files exist', async () => {
jest.spyOn(fsPromises, 'unlink').mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));

await expect(systemFont.uninstall('MissingFont-regular')).rejects.toThrow('Font file not found');
});
});