Skip to content

Commit 0150409

Browse files
committed
refactor(cli): modularize CLI and update tests
Refactor CLI code into separate modules for better maintainability: - Extract CLI options, output, schemas, types, and validation - Add commands directory for command implementations - Update tests for new buildQueryUrl default per_page behavior - Fix test flexibility for filesystem-dependent tests
1 parent 13f2ce4 commit 0150409

25 files changed

+1869
-1288
lines changed

apps/cli/eslint.config.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import baseConfig from "../../eslint.config.base.js";
2+
3+
/**
4+
* ESLint configuration for CLI package
5+
*
6+
* Extends base config with CLI-specific overrides:
7+
* - Disable no-deprecated rule for command files (Commander.js has deprecated
8+
* overloads for .option() and .description() methods, but the signatures
9+
* actually used in this codebase are NOT deprecated)
10+
* - Disable Node.js builtins check (base config has this but paths don't resolve
11+
* when running from CLI directory)
12+
*/
13+
export default [
14+
...baseConfig,
15+
{
16+
// CLI uses modern Node.js features like fetch - base config disables these rules
17+
// for apps/cli/** but paths don't resolve when running from CLI directory
18+
files: ["**/*.ts"],
19+
rules: {
20+
"n/no-unsupported-features/node-builtins": "off",
21+
"n/no-missing-import": "off",
22+
"n/no-missing-require": "off",
23+
// CLI apps use process.exit() for proper exit codes
24+
"n/no-process-exit": "off",
25+
"unicorn/no-process-exit": "off",
26+
"sonarjs/no-os-command-from-path": "off",
27+
},
28+
},
29+
{
30+
// CLI commands use non-deprecated Commander.js patterns, but TypeScript
31+
// type resolution picks up deprecated overload signatures. Disable the
32+
// custom/no-deprecated rule for command files since the actual usage is valid.
33+
files: ["**/commands/**/*.ts", "**/openalex-cli*.ts", "**/simple-cli*.ts"],
34+
rules: {
35+
"custom/no-deprecated": "off",
36+
},
37+
},
38+
];

apps/cli/src/cli-options.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* CLI option constants and descriptions
3+
*/
4+
5+
// Common CLI option strings
6+
export const FORMAT_OPTION = "-f, --format <format>"
7+
export const PRETTY_OPTION = "-p, --pretty"
8+
export const NO_CACHE_OPTION = "--no-cache"
9+
export const NO_SAVE_OPTION = "--no-save"
10+
export const CACHE_ONLY_OPTION = "--cache-only"
11+
export const ENTITY_TYPE_OPTION = "--entity-type <type>"
12+
export const LIMIT_OPTION = "-l, --limit <limit>"
13+
14+
// Common CLI option descriptions
15+
export const FORMAT_TABLE_DESC = "Output format (json, table)"
16+
export const FORMAT_SUMMARY_DESC = "Output format (json, summary)"
17+
export const NO_CACHE_DESC = "Skip cache, fetch directly from API"
18+
export const NO_SAVE_DESC = "Don't save API results to cache"
19+
export const CACHE_ONLY_DESC = "Only use cache, don't fetch from API if not found"
20+
21+
// Common CLI option values and descriptions for duplicate strings
22+
export const LIMIT_RESULTS_DESC = "Limit results"
23+
24+
// Command names
25+
export const CACHE_GENERATE_STATIC_CMD = "cache:generate-static"

apps/cli/src/cli-output.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* Output formatting and printing functions for CLI
3+
*/
4+
5+
import { QueryResultSchema } from "./cli-schemas.js"
6+
import type { EntityOutputParams, EntitySummary, EntitySummaryPrintParams, QueryResultOutputParams } from "./cli-types.js"
7+
8+
/**
9+
* Print author-specific summary fields
10+
* @param entity
11+
*/
12+
export const printAuthorSummary = (entity: EntitySummary): void => {
13+
if ("works_count" in entity) {
14+
const worksCount = typeof entity["works_count"] === "number" ? entity["works_count"] : "Unknown"
15+
console.log(`Works Count: ${worksCount.toString()}`)
16+
}
17+
18+
const citedBy = typeof entity["cited_by_count"] === "number" ? entity["cited_by_count"] : 0
19+
console.log(`Cited By Count: ${citedBy.toString()}`)
20+
}
21+
22+
/**
23+
* Print work-specific summary fields
24+
* @param entity
25+
*/
26+
export const printWorkSummary = (entity: EntitySummary): void => {
27+
if ("publication_year" in entity) {
28+
const pubYear =
29+
typeof entity["publication_year"] === "number" ? entity["publication_year"] : "Unknown"
30+
console.log(`Publication Year: ${pubYear.toString()}`)
31+
}
32+
33+
const citedBy = typeof entity["cited_by_count"] === "number" ? entity["cited_by_count"] : 0
34+
console.log(`Cited By Count: ${citedBy.toString()}`)
35+
}
36+
37+
/**
38+
* Print institution-specific summary fields
39+
* @param entity
40+
*/
41+
export const printInstitutionSummary = (entity: EntitySummary): void => {
42+
if ("works_count" in entity) {
43+
const worksCount = typeof entity["works_count"] === "number" ? entity["works_count"] : "Unknown"
44+
console.log(`Works Count: ${worksCount.toString()}`)
45+
}
46+
47+
const country =
48+
"country_code" in entity && typeof entity["country_code"] === "string"
49+
? entity["country_code"]
50+
: "Unknown"
51+
console.log(`Country: ${country}`)
52+
}
53+
54+
/**
55+
* Print entity summary to console
56+
* @param root0
57+
* @param root0.entity
58+
* @param root0.entityType
59+
*/
60+
export const printEntitySummary = ({ entity, entityType }: EntitySummaryPrintParams): void => {
61+
console.log(`\n${entityType.toUpperCase()}: ${entity.display_name}`)
62+
console.log(`ID: ${entity.id}`)
63+
64+
// Entity-specific summary fields
65+
switch (entityType) {
66+
case "authors": {
67+
printAuthorSummary(entity)
68+
break
69+
}
70+
case "works": {
71+
printWorkSummary(entity)
72+
break
73+
}
74+
case "institutions": {
75+
printInstitutionSummary(entity)
76+
break
77+
}
78+
}
79+
}
80+
81+
/**
82+
* Output query result to console
83+
* @param root0
84+
* @param root0.result
85+
* @param root0.staticEntityType
86+
* @param root0.format
87+
*/
88+
export const outputQueryResult = ({
89+
result,
90+
staticEntityType,
91+
format,
92+
}: QueryResultOutputParams): void => {
93+
if (format === "json") {
94+
console.log(JSON.stringify(result, null, 2))
95+
} else {
96+
// Summary format for query results
97+
const queryResultValidation = QueryResultSchema.safeParse(result)
98+
if (queryResultValidation.success) {
99+
const apiResult = queryResultValidation.data
100+
console.log(`\nQuery Results for ${staticEntityType.toUpperCase()}:`)
101+
console.log(`Total results: ${(apiResult.meta?.count ?? apiResult.results.length).toString()}`)
102+
console.log(`Returned: ${apiResult.results.length.toString()}`)
103+
104+
if (apiResult.results.length > 0) {
105+
apiResult.results.slice(0, 10).forEach((item, index: number) => {
106+
const displayName = item.display_name ?? item.id ?? `Item ${(index + 1).toString()}`
107+
console.log(`${(index + 1).toString().padStart(3)}: ${displayName}`)
108+
})
109+
110+
if (apiResult.results.length > 10) {
111+
console.log(`... and ${(apiResult.results.length - 10).toString()} more`)
112+
}
113+
}
114+
} else {
115+
console.log("Unexpected result format")
116+
}
117+
}
118+
}
119+
120+
/**
121+
* Output entity to console
122+
* @param root0
123+
* @param root0.entity
124+
* @param root0.staticEntityType
125+
* @param root0.format
126+
* @param root0.pretty
127+
*/
128+
export const outputEntity = ({
129+
entity,
130+
staticEntityType,
131+
format,
132+
pretty,
133+
}: EntityOutputParams): void => {
134+
if (format === "json") {
135+
console.log(JSON.stringify(entity, null, pretty ? 2 : 0))
136+
} else {
137+
printEntitySummary({ entity, entityType: staticEntityType })
138+
}
139+
}

apps/cli/src/cli-schemas.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/**
2+
* Zod schemas for CLI validation
3+
*/
4+
5+
import { z } from "zod"
6+
7+
/**
8+
* Static entity type schema - validates supported entity types
9+
*/
10+
export const StaticEntityTypeSchema = z.enum([
11+
"authors",
12+
"works",
13+
"institutions",
14+
"topics",
15+
"publishers",
16+
"funders",
17+
])
18+
19+
/**
20+
* Query result item schema for display
21+
*/
22+
export const QueryResultItemSchema = z
23+
.object({
24+
display_name: z.string().optional(),
25+
id: z.string().optional(),
26+
})
27+
.strict()
28+
29+
/**
30+
* Query result schema for API responses
31+
*/
32+
export const QueryResultSchema = z
33+
.object({
34+
results: z.array(QueryResultItemSchema),
35+
meta: z
36+
.object({
37+
count: z.number().optional(),
38+
})
39+
.strict()
40+
.optional(),
41+
})
42+
.strict()
43+
44+
/**
45+
* List command options schema
46+
*/
47+
export const ListCommandOptionsSchema = z.object({
48+
count: z.boolean().optional(),
49+
format: z.string().optional(),
50+
})
51+
52+
/**
53+
* Search command options schema
54+
*/
55+
export const SearchCommandOptionsSchema = z.object({
56+
limit: z.union([z.string(), z.undefined()]).optional(),
57+
format: z.string().optional(),
58+
})
59+
60+
/**
61+
* Get typed command options schema
62+
*/
63+
export const GetTypedCommandOptionsSchema = z.object({
64+
format: z.string().optional(),
65+
pretty: z.boolean().optional(),
66+
noCache: z.boolean().optional(),
67+
noSave: z.boolean().optional(),
68+
cacheOnly: z.boolean().optional(),
69+
})
70+
71+
/**
72+
* Stats command options schema
73+
*/
74+
export const StatsCommandOptionsSchema = z.object({
75+
format: z.string().optional(),
76+
})
77+
78+
/**
79+
* Cache stats command options schema
80+
*/
81+
export const CacheStatsCommandOptionsSchema = z.object({
82+
format: z.string().optional(),
83+
})
84+
85+
/**
86+
* Cache field coverage command options schema
87+
*/
88+
export const CacheFieldCoverageCommandOptionsSchema = z.object({
89+
format: z.string().optional(),
90+
})
91+
92+
/**
93+
* Cache popular entities command options schema
94+
*/
95+
export const CachePopularEntitiesCommandOptionsSchema = z.object({
96+
limit: z.union([z.string(), z.undefined()]).optional(),
97+
format: z.string().optional(),
98+
})
99+
100+
/**
101+
* Cache popular collections command options schema
102+
*/
103+
export const CachePopularCollectionsCommandOptionsSchema = z.object({
104+
limit: z.union([z.string(), z.undefined()]).optional(),
105+
format: z.string().optional(),
106+
})
107+
108+
/**
109+
* Cache clear command options schema
110+
*/
111+
export const CacheClearCommandOptionsSchema = z.object({
112+
confirm: z.boolean().optional(),
113+
})
114+
115+
/**
116+
* Static analyze command options schema
117+
*/
118+
export const StaticAnalyzeCommandOptionsSchema = z.object({
119+
format: z.string().optional(),
120+
})
121+
122+
/**
123+
* Static generate command options schema
124+
*/
125+
export const StaticGenerateCommandOptionsSchema = z.object({
126+
entityType: z.string().optional(),
127+
dryRun: z.boolean().optional(),
128+
force: z.boolean().optional(),
129+
})
130+
131+
/**
132+
* Cache generate static command options schema
133+
*/
134+
export const CacheGenerateStaticCommandOptionsSchema = z.object({
135+
entityType: z.string().optional(),
136+
limit: z.union([z.string(), z.undefined()]).optional(),
137+
force: z.boolean().optional(),
138+
dryRun: z.boolean().optional(),
139+
format: z.string().optional(),
140+
})
141+
142+
/**
143+
* Cache validate static command options schema
144+
*/
145+
export const CacheValidateStaticCommandOptionsSchema = z.object({
146+
format: z.string().optional(),
147+
verbose: z.boolean().optional(),
148+
})
149+
150+
/**
151+
* Cache clear static command options schema
152+
*/
153+
export const CacheClearStaticCommandOptionsSchema = z.object({
154+
entityType: z.string().optional(),
155+
confirm: z.boolean().optional(),
156+
})
157+
158+
/**
159+
* Fetch command options schema
160+
*/
161+
export const FetchCommandOptionsSchema = z.object({
162+
perPage: z.union([z.string(), z.undefined()]).optional(),
163+
page: z.union([z.string(), z.undefined()]).optional(),
164+
filter: z.string().optional(),
165+
select: z.string().optional(),
166+
sort: z.string().optional(),
167+
noCache: z.boolean().optional(),
168+
noSave: z.boolean().optional(),
169+
cacheOnly: z.boolean().optional(),
170+
format: z.string().optional(),
171+
})
172+
173+
// Export inferred types
174+
export type GetTypedCommandOptions = z.infer<typeof GetTypedCommandOptionsSchema>
175+
export type FetchCommandOptions = z.infer<typeof FetchCommandOptionsSchema>
176+
export type ListCommandOptions = z.infer<typeof ListCommandOptionsSchema>
177+
export type SearchCommandOptions = z.infer<typeof SearchCommandOptionsSchema>
178+
export type StatsCommandOptions = z.infer<typeof StatsCommandOptionsSchema>
179+
export type CacheStatsCommandOptions = z.infer<typeof CacheStatsCommandOptionsSchema>
180+
export type CacheFieldCoverageCommandOptions = z.infer<typeof CacheFieldCoverageCommandOptionsSchema>
181+
export type CachePopularEntitiesCommandOptions = z.infer<typeof CachePopularEntitiesCommandOptionsSchema>
182+
export type CachePopularCollectionsCommandOptions = z.infer<typeof CachePopularCollectionsCommandOptionsSchema>
183+
export type CacheClearCommandOptions = z.infer<typeof CacheClearCommandOptionsSchema>
184+
export type StaticAnalyzeCommandOptions = z.infer<typeof StaticAnalyzeCommandOptionsSchema>
185+
export type StaticGenerateCommandOptions = z.infer<typeof StaticGenerateCommandOptionsSchema>
186+
export type CacheGenerateStaticCommandOptions = z.infer<typeof CacheGenerateStaticCommandOptionsSchema>
187+
export type CacheValidateStaticCommandOptions = z.infer<typeof CacheValidateStaticCommandOptionsSchema>
188+
export type CacheClearStaticCommandOptions = z.infer<typeof CacheClearStaticCommandOptionsSchema>

0 commit comments

Comments
 (0)