mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-11-11 13:28:19 +00:00
1162 lines
40 KiB
TypeScript
1162 lines
40 KiB
TypeScript
import 'dotenv/config';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
const VERSION = '0.1.0';
|
|
const CACHE_DIR = path.resolve(process.cwd(), '.cache/lucide-icon-usage');
|
|
const SEARCH_RESULT_CAP = 1000;
|
|
const PER_PAGE = 100;
|
|
const MIN_SEARCH_DELAY_MS = 6500;
|
|
const REQUEST_TIMEOUT_MS = 30000;
|
|
const DEFAULT_MAX_PAGES = 2;
|
|
const DEFAULT_MAX_FILES_PER_QUERY = 200;
|
|
const THOROUGH_MAX_PAGES = 10;
|
|
const THOROUGH_MAX_FILES_PER_QUERY = 1000;
|
|
|
|
const SUPPORTED_PACKAGES = [
|
|
'lucide-react',
|
|
'@lucide/vue',
|
|
'@lucide/svelte',
|
|
'@lucide/angular',
|
|
'@lucide/astro',
|
|
'lucide-preact',
|
|
'lucide-solid',
|
|
'lucide',
|
|
'@lucide/lab',
|
|
] as const;
|
|
|
|
type SupportedPackage = (typeof SUPPORTED_PACKAGES)[number];
|
|
|
|
type FileScope = {
|
|
label: string;
|
|
qualifier: string;
|
|
extensions: string[];
|
|
};
|
|
|
|
const PACKAGE_FILE_SCOPES: Record<SupportedPackage, FileScope[]> = {
|
|
'lucide-react': [
|
|
{ label: 'typescript', qualifier: 'language:typescript', extensions: ['ts', 'tsx'] },
|
|
{
|
|
label: 'javascript',
|
|
qualifier: 'language:javascript',
|
|
extensions: ['js', 'jsx', 'mjs', 'cjs'],
|
|
},
|
|
],
|
|
'@lucide/vue': [
|
|
{ label: 'vue', qualifier: 'extension:vue', extensions: ['vue'] },
|
|
{ label: 'typescript', qualifier: 'language:typescript', extensions: ['ts', 'tsx'] },
|
|
{
|
|
label: 'javascript',
|
|
qualifier: 'language:javascript',
|
|
extensions: ['js', 'jsx', 'mjs', 'cjs'],
|
|
},
|
|
],
|
|
'@lucide/svelte': [
|
|
{ label: 'svelte', qualifier: 'extension:svelte', extensions: ['svelte'] },
|
|
{ label: 'typescript', qualifier: 'language:typescript', extensions: ['ts', 'tsx'] },
|
|
{
|
|
label: 'javascript',
|
|
qualifier: 'language:javascript',
|
|
extensions: ['js', 'jsx', 'mjs', 'cjs'],
|
|
},
|
|
],
|
|
'@lucide/angular': [
|
|
{ label: 'typescript', qualifier: 'language:typescript', extensions: ['ts'] },
|
|
],
|
|
'@lucide/astro': [
|
|
{ label: 'astro', qualifier: 'extension:astro', extensions: ['astro'] },
|
|
{ label: 'typescript', qualifier: 'language:typescript', extensions: ['ts', 'tsx'] },
|
|
{
|
|
label: 'javascript',
|
|
qualifier: 'language:javascript',
|
|
extensions: ['js', 'jsx', 'mjs', 'cjs'],
|
|
},
|
|
],
|
|
'lucide-preact': [
|
|
{ label: 'typescript', qualifier: 'language:typescript', extensions: ['ts', 'tsx'] },
|
|
{
|
|
label: 'javascript',
|
|
qualifier: 'language:javascript',
|
|
extensions: ['js', 'jsx', 'mjs', 'cjs'],
|
|
},
|
|
],
|
|
'lucide-solid': [
|
|
{ label: 'typescript', qualifier: 'language:typescript', extensions: ['ts', 'tsx'] },
|
|
{
|
|
label: 'javascript',
|
|
qualifier: 'language:javascript',
|
|
extensions: ['js', 'jsx', 'mjs', 'cjs'],
|
|
},
|
|
],
|
|
lucide: [
|
|
{ label: 'typescript', qualifier: 'language:typescript', extensions: ['ts', 'tsx'] },
|
|
{
|
|
label: 'javascript',
|
|
qualifier: 'language:javascript',
|
|
extensions: ['js', 'jsx', 'mjs', 'cjs'],
|
|
},
|
|
{ label: 'html', qualifier: 'extension:html', extensions: ['html'] },
|
|
],
|
|
'@lucide/lab': [
|
|
{ label: 'typescript', qualifier: 'language:typescript', extensions: ['ts', 'tsx'] },
|
|
{
|
|
label: 'javascript',
|
|
qualifier: 'language:javascript',
|
|
extensions: ['js', 'jsx', 'mjs', 'cjs'],
|
|
},
|
|
{ label: 'vue', qualifier: 'extension:vue', extensions: ['vue'] },
|
|
{ label: 'svelte', qualifier: 'extension:svelte', extensions: ['svelte'] },
|
|
{ label: 'astro', qualifier: 'extension:astro', extensions: ['astro'] },
|
|
{ label: 'html', qualifier: 'extension:html', extensions: ['html'] },
|
|
],
|
|
};
|
|
|
|
const DIRECT_ICON_IMPORT_PACKAGES = new Set<SupportedPackage>([
|
|
'lucide-react',
|
|
'@lucide/vue',
|
|
'@lucide/svelte',
|
|
'@lucide/astro',
|
|
'lucide-preact',
|
|
'lucide-solid',
|
|
'lucide',
|
|
]);
|
|
|
|
type CliOptions = {
|
|
icons: string[];
|
|
packages: SupportedPackage[];
|
|
includeForks: boolean;
|
|
includeArchived: boolean;
|
|
includeGenerated: boolean;
|
|
includeVendored: boolean;
|
|
json: boolean;
|
|
csv: boolean;
|
|
refresh: boolean;
|
|
verbose: boolean;
|
|
maxPages: number;
|
|
maxFilesPerQuery: number;
|
|
tokenEnv: string;
|
|
cacheDir: string;
|
|
};
|
|
|
|
type SearchItem = {
|
|
name: string;
|
|
path: string;
|
|
url: string;
|
|
html_url: string;
|
|
repository: {
|
|
full_name: string;
|
|
fork?: boolean;
|
|
archived?: boolean;
|
|
};
|
|
};
|
|
|
|
type SearchResponse = {
|
|
total_count: number;
|
|
incomplete_results: boolean;
|
|
items: SearchItem[];
|
|
};
|
|
|
|
type QueryRecord = {
|
|
icon: string;
|
|
exportName: string;
|
|
packageName: SupportedPackage;
|
|
queryKind: string;
|
|
extension: string;
|
|
query: string;
|
|
totalCount: number;
|
|
fetchedItems: number;
|
|
capped: boolean;
|
|
incompleteResults: boolean;
|
|
pagesFetched: number;
|
|
cacheHits: number;
|
|
localLimitReached: boolean;
|
|
retrievableCapReached: boolean;
|
|
};
|
|
|
|
type RepositoryRecord = {
|
|
icons: string[];
|
|
packages: SupportedPackage[];
|
|
matches: Array<{
|
|
icon: string;
|
|
packageName: SupportedPackage;
|
|
path: string;
|
|
htmlUrl: string;
|
|
evidence: string;
|
|
}>;
|
|
};
|
|
|
|
type AnalysisResult = {
|
|
metadata: {
|
|
version: string;
|
|
analyzedAt: string;
|
|
gitCommit: string | null;
|
|
githubApi: string;
|
|
githubSearchMethod: string;
|
|
resultCaps: string[];
|
|
packages: SupportedPackage[];
|
|
packageFileScopes: Record<SupportedPackage, FileScope[]>;
|
|
includeForks: boolean;
|
|
includeArchived: boolean;
|
|
includeGenerated: boolean;
|
|
includeVendored: boolean;
|
|
maxPages: number;
|
|
maxFilesPerQuery: number;
|
|
cacheDir: string;
|
|
queries: QueryRecord[];
|
|
warnings: string[];
|
|
biases: string[];
|
|
};
|
|
repositories: Record<string, RepositoryRecord>;
|
|
summary: ReturnType<typeof summarize>;
|
|
};
|
|
|
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
const toPascalCase = (icon: string) =>
|
|
icon
|
|
.split(/[-_\s]+/)
|
|
.filter(Boolean)
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join('');
|
|
|
|
const toCamelCase = (icon: string) => {
|
|
const pascal = toPascalCase(icon);
|
|
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
};
|
|
|
|
const exportNameForPackage = (icon: string, packageName: SupportedPackage) =>
|
|
packageName === '@lucide/angular'
|
|
? `Lucide${toPascalCase(icon)}`
|
|
: packageName === '@lucide/lab' || packageName === 'lucide'
|
|
? toCamelCase(icon)
|
|
: toPascalCase(icon);
|
|
|
|
const usageNamesForPackage = (icon: string, packageName: SupportedPackage) => {
|
|
const exportName = exportNameForPackage(icon, packageName);
|
|
if (packageName === '@lucide/angular') {
|
|
return [exportName, `lucide${toPascalCase(icon)}`];
|
|
}
|
|
return [exportName];
|
|
};
|
|
|
|
const parseArgs = (argv: string[]): CliOptions => {
|
|
let explicitPackages = false;
|
|
const options: CliOptions = {
|
|
icons: [],
|
|
packages: ['lucide-react'],
|
|
includeForks: false,
|
|
includeArchived: false,
|
|
includeGenerated: false,
|
|
includeVendored: false,
|
|
json: false,
|
|
csv: false,
|
|
refresh: false,
|
|
verbose: false,
|
|
maxPages: DEFAULT_MAX_PAGES,
|
|
maxFilesPerQuery: DEFAULT_MAX_FILES_PER_QUERY,
|
|
tokenEnv: 'GITHUB_TOKEN',
|
|
cacheDir: CACHE_DIR,
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === '--help' || arg === '-h') {
|
|
printHelp();
|
|
process.exit(0);
|
|
}
|
|
if (arg === '--all-packages') {
|
|
options.packages = [...SUPPORTED_PACKAGES];
|
|
explicitPackages = true;
|
|
continue;
|
|
}
|
|
if (arg === '--package') {
|
|
const packageName = argv[++index] as SupportedPackage | undefined;
|
|
if (!packageName || !SUPPORTED_PACKAGES.includes(packageName)) {
|
|
throw new Error(`Unsupported package: ${packageName ?? '(missing)'}`);
|
|
}
|
|
if (!explicitPackages) options.packages = [];
|
|
explicitPackages = true;
|
|
options.packages = [...options.packages, packageName];
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--package=')) {
|
|
const packageName = arg.slice('--package='.length) as SupportedPackage;
|
|
if (!SUPPORTED_PACKAGES.includes(packageName)) {
|
|
throw new Error(`Unsupported package: ${packageName}`);
|
|
}
|
|
if (!explicitPackages) options.packages = [];
|
|
explicitPackages = true;
|
|
options.packages = [...options.packages, packageName];
|
|
continue;
|
|
}
|
|
if (arg === '--include-forks') {
|
|
options.includeForks = true;
|
|
continue;
|
|
}
|
|
if (arg === '--include-archived') {
|
|
options.includeArchived = true;
|
|
continue;
|
|
}
|
|
if (arg === '--include-generated') {
|
|
options.includeGenerated = true;
|
|
continue;
|
|
}
|
|
if (arg === '--include-vendored') {
|
|
options.includeVendored = true;
|
|
continue;
|
|
}
|
|
if (arg === '--json') {
|
|
options.json = true;
|
|
continue;
|
|
}
|
|
if (arg === '--csv') {
|
|
options.csv = true;
|
|
continue;
|
|
}
|
|
if (arg === '--refresh') {
|
|
options.refresh = true;
|
|
continue;
|
|
}
|
|
if (arg === '--thorough') {
|
|
options.maxPages = THOROUGH_MAX_PAGES;
|
|
options.maxFilesPerQuery = THOROUGH_MAX_FILES_PER_QUERY;
|
|
continue;
|
|
}
|
|
if (arg === '--max-pages') {
|
|
options.maxPages = parsePositiveInteger(argv[++index], '--max-pages');
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--max-pages=')) {
|
|
options.maxPages = parsePositiveInteger(arg.slice('--max-pages='.length), '--max-pages');
|
|
continue;
|
|
}
|
|
if (arg === '--max-files-per-query') {
|
|
options.maxFilesPerQuery = parsePositiveInteger(argv[++index], '--max-files-per-query');
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--max-files-per-query=')) {
|
|
options.maxFilesPerQuery = parsePositiveInteger(
|
|
arg.slice('--max-files-per-query='.length),
|
|
'--max-files-per-query',
|
|
);
|
|
continue;
|
|
}
|
|
if (arg === '--verbose' || arg === '-v') {
|
|
options.verbose = true;
|
|
continue;
|
|
}
|
|
if (arg === '--token-env') {
|
|
options.tokenEnv = argv[++index] ?? options.tokenEnv;
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--token-env=')) {
|
|
options.tokenEnv = arg.slice('--token-env='.length);
|
|
continue;
|
|
}
|
|
if (arg === '--cache-dir') {
|
|
options.cacheDir = path.resolve(argv[++index] ?? options.cacheDir);
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--cache-dir=')) {
|
|
options.cacheDir = path.resolve(arg.slice('--cache-dir='.length));
|
|
continue;
|
|
}
|
|
if (arg.startsWith('-')) {
|
|
throw new Error(`Unknown option: ${arg}`);
|
|
}
|
|
options.icons.push(arg);
|
|
}
|
|
|
|
options.icons = [...new Set(options.icons.map((icon) => icon.trim()).filter(Boolean))];
|
|
options.packages = [...new Set(options.packages)];
|
|
if (options.icons.length < 2) {
|
|
throw new Error(
|
|
'Pass at least two Lucide icon names, for example: pnpm icon-usage trash trash-2',
|
|
);
|
|
}
|
|
return options;
|
|
};
|
|
|
|
const parsePositiveInteger = (value: string | undefined, option: string) => {
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
throw new Error(`${option} must be a positive integer.`);
|
|
}
|
|
return parsed;
|
|
};
|
|
|
|
const printHelp = () => {
|
|
console.log(`Lucide icon usage estimator
|
|
|
|
Usage:
|
|
pnpm icon-usage trash trash-2 [options]
|
|
|
|
Options:
|
|
--package <name> Search one supported package; repeat for several
|
|
--all-packages Search all supported packages
|
|
--include-forks Include repositories GitHub marks as forks
|
|
--include-archived Include archived repositories
|
|
--include-generated Include files GitHub marks as generated
|
|
--include-vendored Include files GitHub marks as vendored
|
|
--json Print JSON including repository-to-icon mapping
|
|
--csv Print repository CSV
|
|
--refresh Ignore cached GitHub responses
|
|
--max-pages <n> Max pages per query slice (default: ${DEFAULT_MAX_PAGES})
|
|
--max-files-per-query <n>
|
|
Max candidate files to validate per query slice (default: ${DEFAULT_MAX_FILES_PER_QUERY})
|
|
--thorough Shortcut for --max-pages ${THOROUGH_MAX_PAGES} --max-files-per-query ${THOROUGH_MAX_FILES_PER_QUERY}
|
|
--verbose, -v Print detailed progress logs on stderr
|
|
--token-env <name> Token environment variable (default: GITHUB_TOKEN)
|
|
--cache-dir <path> Cache directory (default: .cache/lucide-icon-usage)
|
|
|
|
Supported packages:
|
|
${SUPPORTED_PACKAGES.join(', ')}
|
|
|
|
Default package:
|
|
lucide-react
|
|
`);
|
|
};
|
|
|
|
const stableHash = (value: string) => crypto.createHash('sha256').update(value).digest('hex');
|
|
|
|
const progress = (options: Pick<CliOptions, 'verbose'>, message: string) => {
|
|
if (options.verbose) console.error(`[icon-usage] ${message}`);
|
|
};
|
|
|
|
const status = (message: string) => {
|
|
console.error(`[icon-usage] ${message}`);
|
|
};
|
|
|
|
const readCachedJson = async <T,>(cacheFile: string): Promise<T | null> => {
|
|
try {
|
|
return JSON.parse(await fs.readFile(cacheFile, 'utf8')) as T;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const writeCachedJson = async (cacheFile: string, value: unknown) => {
|
|
await fs.mkdir(path.dirname(cacheFile), { recursive: true });
|
|
await fs.writeFile(cacheFile, `${JSON.stringify(value, null, 2)}\n`);
|
|
};
|
|
|
|
class GitHubClient {
|
|
#token: string;
|
|
#cacheDir: string;
|
|
#refresh: boolean;
|
|
#verbose: boolean;
|
|
#lastSearchAt = 0;
|
|
|
|
constructor(token: string, cacheDir: string, refresh: boolean, verbose: boolean) {
|
|
this.#token = token;
|
|
this.#cacheDir = cacheDir;
|
|
this.#refresh = refresh;
|
|
this.#verbose = verbose;
|
|
}
|
|
|
|
async searchCode(
|
|
query: string,
|
|
page: number,
|
|
): Promise<{ response: SearchResponse; cacheHit: boolean }> {
|
|
const url = new URL('https://api.github.com/search/code');
|
|
url.searchParams.set('q', query);
|
|
url.searchParams.set('per_page', String(PER_PAGE));
|
|
url.searchParams.set('page', String(page));
|
|
return this.#cachedFetch<SearchResponse>(url.toString(), 'search', true);
|
|
}
|
|
|
|
async getJson<T>(url: string): Promise<{ response: T; cacheHit: boolean }> {
|
|
return this.#cachedFetch<T>(url, 'contents', false);
|
|
}
|
|
|
|
async #cachedFetch<T>(
|
|
url: string,
|
|
namespace: string,
|
|
searchEndpoint: boolean,
|
|
): Promise<{ response: T; cacheHit: boolean }> {
|
|
const cacheFile = path.join(this.#cacheDir, namespace, `${stableHash(url)}.json`);
|
|
if (!this.#refresh) {
|
|
const cached = await readCachedJson<T>(cacheFile);
|
|
if (cached) {
|
|
progress({ verbose: this.#verbose }, `cache hit: ${namespace} ${url}`);
|
|
return { response: cached, cacheHit: true };
|
|
}
|
|
}
|
|
|
|
progress({ verbose: this.#verbose }, `fetching: ${namespace} ${url}`);
|
|
const response = await this.#fetchJson<T>(url, searchEndpoint);
|
|
await writeCachedJson(cacheFile, response);
|
|
return { response, cacheHit: false };
|
|
}
|
|
|
|
async #fetchJson<T>(url: string, searchEndpoint: boolean): Promise<T> {
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
if (searchEndpoint) {
|
|
const elapsed = Date.now() - this.#lastSearchAt;
|
|
if (elapsed < MIN_SEARCH_DELAY_MS) await sleep(MIN_SEARCH_DELAY_MS - elapsed);
|
|
this.#lastSearchAt = Date.now();
|
|
}
|
|
|
|
let response: Response;
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
try {
|
|
response = await fetch(url, {
|
|
signal: controller.signal,
|
|
headers: {
|
|
Accept: 'application/vnd.github+json',
|
|
Authorization: `Bearer ${this.#token}`,
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
'User-Agent': 'lucide-icon-usage-script',
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (attempt < 4) {
|
|
const waitMs = 2 ** attempt * 3000;
|
|
progress(
|
|
{ verbose: this.#verbose },
|
|
`network error on attempt ${attempt + 1}/5; retrying in ${Math.round(waitMs / 1000)}s: ${
|
|
(error as Error).message
|
|
}`,
|
|
);
|
|
await sleep(waitMs);
|
|
continue;
|
|
}
|
|
throw new Error(
|
|
`GitHub request failed before receiving a response for ${url}: ${(error as Error).message}`,
|
|
);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
|
|
if (response.ok) return response.json() as Promise<T>;
|
|
|
|
const retryAfter = Number(response.headers.get('retry-after'));
|
|
const reset = Number(response.headers.get('x-ratelimit-reset'));
|
|
const body = await response.text();
|
|
if ([403, 429, 500, 502, 503, 504].includes(response.status) && attempt < 4) {
|
|
const waitMs =
|
|
Number.isFinite(retryAfter) && retryAfter > 0
|
|
? retryAfter * 1000
|
|
: Number.isFinite(reset) && reset > Date.now() / 1000
|
|
? reset * 1000 - Date.now() + 1000
|
|
: 2 ** attempt * 3000;
|
|
const boundedWaitMs = Math.min(Math.max(waitMs, 1000), 120000);
|
|
progress(
|
|
{ verbose: this.#verbose },
|
|
`GitHub returned ${response.status} on attempt ${attempt + 1}/5; retrying in ${Math.round(
|
|
boundedWaitMs / 1000,
|
|
)}s`,
|
|
);
|
|
await sleep(boundedWaitMs);
|
|
continue;
|
|
}
|
|
|
|
throw new Error(`GitHub request failed (${response.status}) for ${url}: ${body}`);
|
|
}
|
|
throw new Error(`GitHub request failed after retries for ${url}`);
|
|
}
|
|
}
|
|
|
|
const queryFilters = (options: CliOptions) =>
|
|
[
|
|
'in:file',
|
|
options.includeForks ? null : 'NOT is:fork',
|
|
options.includeArchived ? null : 'NOT is:archived',
|
|
options.includeGenerated ? null : 'NOT is:generated',
|
|
options.includeVendored ? null : 'NOT is:vendored',
|
|
].filter(Boolean) as string[];
|
|
|
|
const queriesFor = (icon: string, packageName: SupportedPackage, options: CliOptions) => {
|
|
const exportName = exportNameForPackage(icon, packageName);
|
|
const filters = queryFilters(options);
|
|
return PACKAGE_FILE_SCOPES[packageName].flatMap((fileScope) => {
|
|
const importHint = fileScope.extensions.includes('html') ? null : 'import';
|
|
const namedImportQuery = [
|
|
`"${packageName}"`,
|
|
exportName,
|
|
importHint,
|
|
fileScope.qualifier,
|
|
...filters,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
if (!DIRECT_ICON_IMPORT_PACKAGES.has(packageName)) {
|
|
return [{ queryKind: 'named-import', fileScope, query: namedImportQuery }];
|
|
}
|
|
return [
|
|
{ queryKind: 'named-import', fileScope, query: namedImportQuery },
|
|
{
|
|
queryKind: 'direct-icon-import',
|
|
fileScope,
|
|
query: [`"${packageName}/icons/${icon}"`, fileScope.qualifier, ...filters].join(' '),
|
|
},
|
|
];
|
|
});
|
|
};
|
|
|
|
const hasAllowedExtension = (filePath: string, fileScope: FileScope) =>
|
|
fileScope.extensions.some((extension) => filePath.toLowerCase().endsWith(`.${extension}`));
|
|
|
|
const fetchAllSearchResults = async (
|
|
client: GitHubClient,
|
|
icon: string,
|
|
packageName: SupportedPackage,
|
|
queryKind: string,
|
|
fileScope: FileScope,
|
|
query: string,
|
|
options: CliOptions,
|
|
): Promise<{ items: SearchItem[]; record: QueryRecord }> => {
|
|
const items: SearchItem[] = [];
|
|
let totalCount = 0;
|
|
let incompleteResults = false;
|
|
let cacheHits = 0;
|
|
let pagesFetched = 0;
|
|
|
|
progress(
|
|
options,
|
|
`search start: icon=${icon} package=${packageName} kind=${queryKind} scope=${fileScope.label}`,
|
|
);
|
|
progress(options, `query: ${query}`);
|
|
|
|
for (let page = 1; page <= options.maxPages; page += 1) {
|
|
progress(
|
|
options,
|
|
`search page ${page}/${options.maxPages}: icon=${icon} package=${packageName} kind=${queryKind} scope=${fileScope.label}`,
|
|
);
|
|
const { response, cacheHit } = await client.searchCode(query, page);
|
|
pagesFetched += 1;
|
|
if (cacheHit) cacheHits += 1;
|
|
totalCount = response.total_count;
|
|
incompleteResults = incompleteResults || response.incomplete_results;
|
|
const matchingItems = response.items.filter((item) =>
|
|
hasAllowedExtension(item.path, fileScope),
|
|
);
|
|
items.push(...matchingItems.slice(0, Math.max(options.maxFilesPerQuery - items.length, 0)));
|
|
progress(
|
|
options,
|
|
`page ${page} done: ${response.items.length} items (${matchingItems.length} matching ${fileScope.label}), ${items.length} fetched, total_count=${totalCount.toLocaleString(
|
|
'en-US',
|
|
)}, incomplete=${response.incomplete_results}, cache=${cacheHit ? 'hit' : 'miss'}`,
|
|
);
|
|
if (
|
|
response.items.length < PER_PAGE ||
|
|
items.length >= SEARCH_RESULT_CAP ||
|
|
items.length >= options.maxFilesPerQuery
|
|
)
|
|
break;
|
|
}
|
|
|
|
progress(
|
|
options,
|
|
`search done: icon=${icon} package=${packageName} kind=${queryKind} scope=${fileScope.label}; fetched=${items.length}, total_count=${totalCount.toLocaleString(
|
|
'en-US',
|
|
)}${totalCount > SEARCH_RESULT_CAP || items.length >= SEARCH_RESULT_CAP ? ' (GITHUB CAPPED)' : ''}${items.length >= options.maxFilesPerQuery && totalCount > items.length ? ' (LOCAL LIMIT)' : ''}`,
|
|
);
|
|
|
|
return {
|
|
items,
|
|
record: {
|
|
icon,
|
|
exportName: exportNameForPackage(icon, packageName),
|
|
packageName,
|
|
queryKind,
|
|
extension: fileScope.label,
|
|
query,
|
|
totalCount,
|
|
fetchedItems: items.length,
|
|
capped: totalCount > SEARCH_RESULT_CAP || items.length >= SEARCH_RESULT_CAP,
|
|
incompleteResults,
|
|
pagesFetched,
|
|
cacheHits,
|
|
localLimitReached: items.length >= options.maxFilesPerQuery && totalCount > items.length,
|
|
retrievableCapReached: totalCount > SEARCH_RESULT_CAP || items.length >= SEARCH_RESULT_CAP,
|
|
},
|
|
};
|
|
};
|
|
|
|
const decodeContent = (payload: { content?: string; encoding?: string }) => {
|
|
if (payload.encoding !== 'base64' || !payload.content) return '';
|
|
return Buffer.from(payload.content.replace(/\n/g, ''), 'base64').toString('utf8');
|
|
};
|
|
|
|
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
const hasNamedImport = (source: string, packageName: SupportedPackage, icon: string) => {
|
|
const names = usageNamesForPackage(icon, packageName).map(escapeRegExp).join('|');
|
|
const importRegex = new RegExp(
|
|
`import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+['"]${escapeRegExp(packageName)}['"]`,
|
|
'g',
|
|
);
|
|
for (const match of source.matchAll(importRegex)) {
|
|
const specifiers = match[1] ?? '';
|
|
if (
|
|
new RegExp(
|
|
`(^|,)\\s*(?:type\\s+)?(${names})(?:\\s+as\\s+[A-Za-z_$][\\w$]*)?\\s*(?=,|$)`,
|
|
).test(specifiers)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const hasDirectImport = (source: string, packageName: SupportedPackage, icon: string) => {
|
|
if (packageName === '@lucide/lab') return false;
|
|
return (
|
|
new RegExp(`from\\s+['"]${escapeRegExp(`${packageName}/icons/${icon}`)}['"]`).test(source) ||
|
|
new RegExp(
|
|
`import\\s*\\(\\s*['"]${escapeRegExp(`${packageName}/icons/${icon}`)}['"]\\s*\\)`,
|
|
).test(source)
|
|
);
|
|
};
|
|
|
|
const hasCommonUsage = (source: string, packageName: SupportedPackage, icon: string) => {
|
|
const names = usageNamesForPackage(icon, packageName).map(escapeRegExp).join('|');
|
|
if (hasNamedImport(source, packageName, icon)) return 'named import';
|
|
if (hasDirectImport(source, packageName, icon)) return 'direct icon import';
|
|
if (packageName === 'lucide' && new RegExp(`\\b(?:icons\\.)?${names}\\b`).test(source))
|
|
return 'lucide export reference';
|
|
if (packageName === '@lucide/angular' && new RegExp(`\\b${names}\\b`).test(source))
|
|
return 'angular icon reference';
|
|
if (packageName === '@lucide/lab' && hasNamedImport(source, packageName, icon))
|
|
return 'lab named import';
|
|
return null;
|
|
};
|
|
|
|
const analyze = async (options: CliOptions): Promise<AnalysisResult> => {
|
|
const token = process.env[options.tokenEnv];
|
|
if (!token) {
|
|
throw new Error(
|
|
`GitHub code search requires authentication. Set ${options.tokenEnv} or pass --token-env <name>.`,
|
|
);
|
|
}
|
|
|
|
const client = new GitHubClient(token, options.cacheDir, options.refresh, options.verbose);
|
|
const repositories = new Map<string, RepositoryRecord>();
|
|
const queries: QueryRecord[] = [];
|
|
const seenFiles = new Set<string>();
|
|
const candidateRepositories = new Set<string>();
|
|
const candidatePackageRepositories = new Map<SupportedPackage, Set<string>>(
|
|
options.packages.map((packageName) => [packageName, new Set<string>()]),
|
|
);
|
|
const querySlices = options.icons.reduce(
|
|
(count, icon) =>
|
|
count +
|
|
options.packages.reduce(
|
|
(sum, packageName) => sum + queriesFor(icon, packageName, options).length,
|
|
0,
|
|
),
|
|
0,
|
|
);
|
|
let completedQuerySlices = 0;
|
|
let contentFilesChecked = 0;
|
|
|
|
status(
|
|
`starting: icons=${options.icons.join(', ')} packages=${options.packages.join(', ')} query-slices=${querySlices} max-pages=${options.maxPages} max-files-per-query=${options.maxFilesPerQuery}`,
|
|
);
|
|
progress(options, `starting analysis for icons: ${options.icons.join(', ')}`);
|
|
progress(options, `packages: ${options.packages.join(', ')}`);
|
|
progress(options, `query slices planned: ${querySlices}`);
|
|
progress(
|
|
options,
|
|
`cache directory: ${options.cacheDir}${options.refresh ? ' (refresh enabled)' : ''}`,
|
|
);
|
|
|
|
for (const icon of options.icons) {
|
|
for (const packageName of options.packages) {
|
|
for (const { queryKind, fileScope, query } of queriesFor(icon, packageName, options)) {
|
|
const { items, record } = await fetchAllSearchResults(
|
|
client,
|
|
icon,
|
|
packageName,
|
|
queryKind,
|
|
fileScope,
|
|
query,
|
|
options,
|
|
);
|
|
queries.push(record);
|
|
completedQuerySlices += 1;
|
|
status(
|
|
`search ${completedQuerySlices}/${querySlices}: ${icon} ${packageName} ${fileScope.label} ${queryKind}; fetched=${items.length}/${record.totalCount.toLocaleString('en-US')}${record.retrievableCapReached ? ' GitHub-capped' : ''}${record.localLimitReached ? ' local-limit' : ''}`,
|
|
);
|
|
progress(options, `query slice progress: ${completedQuerySlices}/${querySlices}`);
|
|
|
|
for (const [itemIndex, item] of items.entries()) {
|
|
const repo = item.repository.full_name;
|
|
candidateRepositories.add(repo);
|
|
candidatePackageRepositories.get(packageName)?.add(repo);
|
|
const fileKey = `${item.url}#${icon}#${packageName}`;
|
|
if (seenFiles.has(fileKey)) continue;
|
|
seenFiles.add(fileKey);
|
|
contentFilesChecked += 1;
|
|
if (!options.verbose && contentFilesChecked % 100 === 0) {
|
|
status(
|
|
`validated ${contentFilesChecked} candidate files; matched repositories=${repositories.size}`,
|
|
);
|
|
}
|
|
if (contentFilesChecked === 1 || contentFilesChecked % 25 === 0) {
|
|
progress(
|
|
options,
|
|
`validating file content: checked=${contentFilesChecked}, candidates=${candidateRepositories.size}, matches=${repositories.size}, current=${itemIndex + 1}/${items.length} ${repo}/${item.path}`,
|
|
);
|
|
}
|
|
|
|
const { response: contentPayload } = await client.getJson<{
|
|
content?: string;
|
|
encoding?: string;
|
|
}>(item.url);
|
|
const source = decodeContent(contentPayload);
|
|
const evidence = hasCommonUsage(source, packageName, icon);
|
|
if (!evidence) continue;
|
|
|
|
const recordForRepo = repositories.get(repo) ?? { icons: [], packages: [], matches: [] };
|
|
if (!recordForRepo.icons.includes(icon)) recordForRepo.icons.push(icon);
|
|
if (!recordForRepo.packages.includes(packageName))
|
|
recordForRepo.packages.push(packageName);
|
|
recordForRepo.matches.push({
|
|
icon,
|
|
packageName,
|
|
path: item.path,
|
|
htmlUrl: item.html_url,
|
|
evidence,
|
|
});
|
|
repositories.set(repo, recordForRepo);
|
|
if (!options.verbose) {
|
|
status(`match: ${repo} uses ${icon} via ${packageName} in ${item.path}`);
|
|
}
|
|
progress(
|
|
options,
|
|
`match: ${repo} uses ${icon} via ${packageName} (${evidence}) in ${item.path}; matched repositories=${repositories.size}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
progress(
|
|
options,
|
|
`analysis complete: candidate repositories=${candidateRepositories.size}, validated repositories=${repositories.size}, files checked=${contentFilesChecked}`,
|
|
);
|
|
status(
|
|
`complete: candidate repositories=${candidateRepositories.size}, matched repositories=${repositories.size}, files checked=${contentFilesChecked}`,
|
|
);
|
|
|
|
const repositoryObject = Object.fromEntries(
|
|
[...repositories.entries()]
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([repo, record]) => [
|
|
repo,
|
|
{
|
|
...record,
|
|
icons: record.icons.sort(),
|
|
packages: record.packages.sort(),
|
|
},
|
|
]),
|
|
);
|
|
|
|
const warnings = buildWarnings(queries);
|
|
return {
|
|
metadata: {
|
|
version: VERSION,
|
|
analyzedAt: new Date().toISOString(),
|
|
gitCommit: await gitCommit(),
|
|
githubApi: 'GitHub REST API GET /search/code, API version 2022-11-28',
|
|
githubSearchMethod:
|
|
'Package-scoped code search queries, followed by file-content validation of common Lucide import styles, deduplicated by repository full_name.',
|
|
resultCaps: [
|
|
'GitHub REST code search returns at most 1,000 results per query.',
|
|
'GitHub REST code search is authenticated and rate-limited to 10 requests per minute.',
|
|
'Only default branches and files smaller than 384 KB are searchable.',
|
|
'Search can time out and return incomplete_results=true.',
|
|
'total_count is a search estimate and is not used as the repository count.',
|
|
],
|
|
packages: options.packages,
|
|
packageFileScopes: Object.fromEntries(
|
|
options.packages.map((packageName) => [packageName, PACKAGE_FILE_SCOPES[packageName]]),
|
|
) as Record<SupportedPackage, FileScope[]>,
|
|
includeForks: options.includeForks,
|
|
includeArchived: options.includeArchived,
|
|
includeGenerated: options.includeGenerated,
|
|
includeVendored: options.includeVendored,
|
|
maxPages: options.maxPages,
|
|
maxFilesPerQuery: options.maxFilesPerQuery,
|
|
cacheDir: options.cacheDir,
|
|
queries,
|
|
warnings,
|
|
biases: [
|
|
'Only public GitHub repositories visible to the authenticated token are observable.',
|
|
'Private repositories and consumers outside GitHub are absent.',
|
|
'GitHub-hosted code is not representative of every Lucide consumer.',
|
|
'Package/framework usage may differ across ecosystems.',
|
|
'GitHub code indexing can be delayed, incomplete, or limited by file size/default branch.',
|
|
'Generated and vendored code may still create false positives when GitHub cannot identify it.',
|
|
'Very common icons can exceed search-result caps; capped queries should be treated as lower-bound or sampled evidence, not complete counts.',
|
|
],
|
|
},
|
|
repositories: repositoryObject,
|
|
summary: summarize(
|
|
options.icons,
|
|
options.packages,
|
|
repositoryObject,
|
|
candidateRepositories.size,
|
|
candidatePackageRepositories,
|
|
),
|
|
};
|
|
};
|
|
|
|
const gitCommit = async () => {
|
|
try {
|
|
const { execFile } = await import('node:child_process');
|
|
return await new Promise<string | null>((resolve) => {
|
|
execFile('git', ['rev-parse', 'HEAD'], (error, stdout) =>
|
|
resolve(error ? null : stdout.trim()),
|
|
);
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const buildWarnings = (queries: QueryRecord[]) => {
|
|
const warnings: string[] = [];
|
|
const capped = queries.filter((query) => query.capped);
|
|
const incomplete = queries.filter((query) => query.incompleteResults);
|
|
const locallyLimited = queries.filter((query) => query.localLimitReached);
|
|
const pageLimited = queries.filter(
|
|
(query) =>
|
|
query.pagesFetched >= 1 &&
|
|
query.pagesFetched < THOROUGH_MAX_PAGES &&
|
|
query.totalCount > query.pagesFetched * PER_PAGE,
|
|
);
|
|
if (capped.length > 0) {
|
|
warnings.push(
|
|
`${capped.length} query/queries hit or exceeded GitHub's 1,000-result retrieval cap. Counts are incomplete for those query slices.`,
|
|
);
|
|
}
|
|
if (locallyLimited.length > 0) {
|
|
warnings.push(
|
|
`${locallyLimited.length} query/queries reached --max-files-per-query before all fetched candidates were validated. Increase --max-files-per-query or use --thorough for a broader sample.`,
|
|
);
|
|
}
|
|
if (pageLimited.length > 0) {
|
|
warnings.push(
|
|
`${pageLimited.length} query/queries likely had additional pages beyond --max-pages. Increase --max-pages or use --thorough for a broader sample.`,
|
|
);
|
|
}
|
|
if (incomplete.length > 0) {
|
|
warnings.push(
|
|
`${incomplete.length} query/queries returned incomplete_results=true. GitHub may have timed out before searching all eligible code.`,
|
|
);
|
|
}
|
|
warnings.push(
|
|
'For broad icons, prefer narrowing by package, language/path, or time/repository cohorts and cite the query metadata rather than treating the result as exhaustive.',
|
|
);
|
|
return warnings;
|
|
};
|
|
|
|
const summarize = (
|
|
icons: string[],
|
|
packages: SupportedPackage[],
|
|
repositories: Record<string, RepositoryRecord>,
|
|
repositoriesSearched: number,
|
|
candidatePackageRepositories: Map<SupportedPackage, Set<string>>,
|
|
) => {
|
|
const iconTotals = Object.fromEntries(icons.map((icon) => [icon, 0])) as Record<string, number>;
|
|
const exclusive = Object.fromEntries(icons.map((icon) => [icon, 0])) as Record<string, number>;
|
|
const combinations: Record<string, { icons: string[]; count: number; percentage: number }> = {};
|
|
const totalRepositories = Object.keys(repositories).length;
|
|
|
|
for (const record of Object.values(repositories)) {
|
|
const matchedIcons = icons.filter((icon) => record.icons.includes(icon));
|
|
for (const icon of matchedIcons) iconTotals[icon] += 1;
|
|
if (matchedIcons.length === 1) exclusive[matchedIcons[0]] += 1;
|
|
const key = matchedIcons.join('+');
|
|
combinations[key] ??= { icons: matchedIcons, count: 0, percentage: 0 };
|
|
combinations[key].count += 1;
|
|
}
|
|
|
|
for (const combination of Object.values(combinations)) {
|
|
combination.percentage =
|
|
totalRepositories === 0 ? 0 : (combination.count / totalRepositories) * 100;
|
|
}
|
|
|
|
return {
|
|
icons: Object.fromEntries(
|
|
icons.map((icon) => [
|
|
icon,
|
|
{
|
|
uniqueRepositories: iconTotals[icon],
|
|
exclusive: exclusive[icon],
|
|
percentageOfTotal:
|
|
totalRepositories === 0 ? 0 : (iconTotals[icon] / totalRepositories) * 100,
|
|
exclusivePercentageOfTotal:
|
|
totalRepositories === 0 ? 0 : (exclusive[icon] / totalRepositories) * 100,
|
|
},
|
|
]),
|
|
),
|
|
combinations,
|
|
packages: Object.fromEntries(
|
|
packages.map((packageName) => [
|
|
packageName,
|
|
summarizePackage(
|
|
icons,
|
|
repositories,
|
|
packageName,
|
|
candidatePackageRepositories.get(packageName)?.size ?? 0,
|
|
),
|
|
]),
|
|
),
|
|
totalRepositories,
|
|
repositoriesSearched,
|
|
};
|
|
};
|
|
|
|
const summarizePackage = (
|
|
icons: string[],
|
|
repositories: Record<string, RepositoryRecord>,
|
|
packageName: SupportedPackage,
|
|
repositoriesSearched: number,
|
|
) => {
|
|
const iconTotals = Object.fromEntries(icons.map((icon) => [icon, 0])) as Record<string, number>;
|
|
const exclusive = Object.fromEntries(icons.map((icon) => [icon, 0])) as Record<string, number>;
|
|
const combinations: Record<string, { icons: string[]; count: number; percentage: number }> = {};
|
|
let totalRepositories = 0;
|
|
|
|
for (const record of Object.values(repositories)) {
|
|
const packageIcons = icons.filter((icon) =>
|
|
record.matches.some((match) => match.packageName === packageName && match.icon === icon),
|
|
);
|
|
if (packageIcons.length === 0) continue;
|
|
|
|
totalRepositories += 1;
|
|
for (const icon of packageIcons) iconTotals[icon] += 1;
|
|
if (packageIcons.length === 1) exclusive[packageIcons[0]] += 1;
|
|
const key = packageIcons.join('+');
|
|
combinations[key] ??= { icons: packageIcons, count: 0, percentage: 0 };
|
|
combinations[key].count += 1;
|
|
}
|
|
|
|
for (const combination of Object.values(combinations)) {
|
|
combination.percentage =
|
|
totalRepositories === 0 ? 0 : (combination.count / totalRepositories) * 100;
|
|
}
|
|
|
|
return {
|
|
icons: Object.fromEntries(
|
|
icons.map((icon) => [
|
|
icon,
|
|
{
|
|
uniqueRepositories: iconTotals[icon],
|
|
exclusive: exclusive[icon],
|
|
percentageOfTotal:
|
|
totalRepositories === 0 ? 0 : (iconTotals[icon] / totalRepositories) * 100,
|
|
exclusivePercentageOfTotal:
|
|
totalRepositories === 0 ? 0 : (exclusive[icon] / totalRepositories) * 100,
|
|
},
|
|
]),
|
|
),
|
|
combinations,
|
|
totalRepositories,
|
|
repositoriesSearched,
|
|
};
|
|
};
|
|
|
|
const formatNumber = (value: number) => value.toLocaleString('en-US');
|
|
const formatPercent = (value: number) => `${value.toFixed(1)}%`;
|
|
|
|
const combinationLabel = (icons: string[], totalQueriedIcons: number) =>
|
|
icons.length === 1
|
|
? `${icons[0]} only`
|
|
: icons.length === 2 && totalQueriedIcons === 2
|
|
? 'both'
|
|
: icons.join(' + ');
|
|
|
|
const printHuman = (result: AnalysisResult) => {
|
|
const { summary, metadata } = result;
|
|
console.log('Lucide icon usage on public GitHub repositories');
|
|
for (const [icon, stats] of Object.entries(summary.icons)) {
|
|
console.log(`${icon}`);
|
|
console.log(` unique repositories: ${formatNumber(stats.uniqueRepositories)}`);
|
|
console.log(
|
|
` exclusive: ${formatNumber(stats.exclusive)} (${formatPercent(stats.exclusivePercentageOfTotal)})`,
|
|
);
|
|
}
|
|
|
|
console.log('Combinations among repositories using any queried icon:');
|
|
for (const combination of Object.values(summary.combinations).sort((a, b) => b.count - a.count)) {
|
|
const label = combinationLabel(combination.icons, Object.keys(summary.icons).length);
|
|
console.log(
|
|
` ${label}: ${formatNumber(combination.count)} (${formatPercent(combination.percentage)})`,
|
|
);
|
|
}
|
|
|
|
console.log('Per package:');
|
|
for (const [packageName, packageSummary] of Object.entries(summary.packages)) {
|
|
console.log(`${packageName}`);
|
|
for (const [icon, stats] of Object.entries(packageSummary.icons)) {
|
|
console.log(
|
|
` ${icon}: ${formatNumber(stats.uniqueRepositories)} repositories, ${formatNumber(stats.exclusive)} exclusive (${formatPercent(stats.exclusivePercentageOfTotal)})`,
|
|
);
|
|
}
|
|
const combinations = Object.values(packageSummary.combinations).sort(
|
|
(a, b) => b.count - a.count,
|
|
);
|
|
if (combinations.length > 0) {
|
|
console.log(' combinations:');
|
|
for (const combination of combinations) {
|
|
console.log(
|
|
` ${combinationLabel(combination.icons, Object.keys(summary.icons).length)}: ${formatNumber(combination.count)} (${formatPercent(combination.percentage)})`,
|
|
);
|
|
}
|
|
}
|
|
console.log(` total repositories: ${formatNumber(packageSummary.totalRepositories)}`);
|
|
console.log(
|
|
` repositories sampled/searched: ${formatNumber(packageSummary.repositoriesSearched)}`,
|
|
);
|
|
}
|
|
|
|
console.log(`total repositories: ${formatNumber(summary.totalRepositories)}`);
|
|
console.log(`repositories sampled/searched: ${formatNumber(summary.repositoriesSearched)}`);
|
|
console.log(`queries performed: ${formatNumber(metadata.queries.length)}`);
|
|
|
|
const cappedQueries = metadata.queries.filter((query) => query.capped).length;
|
|
const incompleteQueries = metadata.queries.filter((query) => query.incompleteResults).length;
|
|
if (cappedQueries > 0 || incompleteQueries > 0 || metadata.warnings.length > 0) {
|
|
console.log('Warnings:');
|
|
for (const warning of metadata.warnings) console.log(` ${warning}`);
|
|
}
|
|
};
|
|
|
|
const printCsv = (result: AnalysisResult) => {
|
|
const rows = [['repository', 'icons', 'packages', 'matches']];
|
|
for (const [repo, record] of Object.entries(result.repositories)) {
|
|
rows.push([
|
|
repo,
|
|
record.icons.join('|'),
|
|
record.packages.join('|'),
|
|
record.matches.map((match) => `${match.icon}:${match.packageName}:${match.path}`).join('|'),
|
|
]);
|
|
}
|
|
console.log(
|
|
rows.map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(',')).join('\n'),
|
|
);
|
|
};
|
|
|
|
try {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const result = await analyze(options);
|
|
if (options.json) {
|
|
console.log(JSON.stringify(result, null, 2));
|
|
} else if (options.csv) {
|
|
printCsv(result);
|
|
} else {
|
|
printHuman(result);
|
|
}
|
|
} catch (error) {
|
|
console.error((error as Error).message);
|
|
process.exit(1);
|
|
}
|