Skip to content

Commit 15cf205

Browse files
vobuCopilotCopilot
authored
feat: implement unknown flag detection and enhance search result messaging (#106)
(ping back to camunda/product-hub#2638 for ref) * feat: implement unknown flag detection and enhance search result messaging * chore: update src/commands/search.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: update src/commands/search.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: align GLOBAL_FLAGS unit test with limit removal (#108) * Initial plan * fix: update GLOBAL_FLAGS test to reflect limit removal from global flags Co-authored-by: vobu <6573426+vobu@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: vobu <6573426+vobu@users.noreply.github.com> * fix: correct version property reference in main function * feat: export API_DEFAULT_PAGE_SIZE and enhance log functions for better feedback * fix: improve CLI invocation handling with realpathSync for symlink resolution * test: add integration tests for global install and symlink invocation * fix: update symlink handling in integration tests for clarity and accuracy * fix: skip global-install tests gracefully when dist/index.js is absent (#109) * Initial plan * fix: skip global-install tests when dist/index.js is absent (CI fix) Co-authored-by: vobu <6573426+vobu@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: vobu <6573426+vobu@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
1 parent 4172410 commit 15cf205

File tree

4 files changed

+545
-20
lines changed

4 files changed

+545
-20
lines changed

src/commands/search.ts

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,46 @@ import { parseBetween, buildDateFilter } from '../date-filter.ts';
99

1010
export type SearchResult = { items: Array<Record<string, unknown>>; total?: number };
1111

12+
/**
13+
* Flags that are valid globally (not specific to any search resource).
14+
*/
15+
export const GLOBAL_FLAGS = new Set([
16+
'profile', 'sortBy', 'asc', 'desc', 'help', 'version',
17+
]);
18+
19+
/**
20+
* Valid search filter flags per resource (values keys that are consumed by the search handler).
21+
* This map is used to detect when a user passes a flag that looks valid but is not recognized
22+
* for the specific resource, causing silent filter drops.
23+
*/
24+
export const SEARCH_RESOURCE_FLAGS: Record<string, Set<string>> = {
25+
'process-definition': new Set(['bpmnProcessId', 'id', 'processDefinitionId', 'name', 'key', 'iid', 'iname']),
26+
'process-instance': new Set(['bpmnProcessId', 'id', 'processDefinitionId', 'processDefinitionKey', 'state', 'key', 'parentProcessInstanceKey', 'iid']),
27+
'user-task': new Set(['state', 'assignee', 'processInstanceKey', 'processDefinitionKey', 'elementId', 'iassignee']),
28+
'incident': new Set(['state', 'processInstanceKey', 'processDefinitionKey', 'bpmnProcessId', 'id', 'processDefinitionId', 'errorType', 'errorMessage', 'ierrorMessage', 'iid']),
29+
'jobs': new Set(['state', 'type', 'processInstanceKey', 'processDefinitionKey', 'itype']),
30+
'variable': new Set(['name', 'value', 'processInstanceKey', 'scopeKey', 'fullValue', 'iname', 'ivalue', 'limit']),
31+
};
32+
33+
/**
34+
* Detect flags the user set that are not recognized for the given search resource.
35+
* Returns the list of unknown flag names (without the --prefix).
36+
*/
37+
export function detectUnknownSearchFlags(values: Record<string, unknown>, normalizedResource: string): string[] {
38+
const validFlags = SEARCH_RESOURCE_FLAGS[normalizedResource]
39+
|| SEARCH_RESOURCE_FLAGS[normalizedResource.replace(/s$/, '')];
40+
if (!validFlags) return [];
41+
42+
const unknown: string[] = [];
43+
for (const [key, val] of Object.entries(values)) {
44+
if (val === undefined || val === false) continue; // not set by the user
45+
if (GLOBAL_FLAGS.has(key)) continue;
46+
if (validFlags.has(key)) continue;
47+
unknown.push(key);
48+
}
49+
return unknown;
50+
}
51+
1252
/**
1353
* Detect wildcard characters (* or ?) in a string value and return
1454
* a $like filter object for the API. Returns the plain string for exact match.
@@ -71,6 +111,9 @@ const toBigIntSafe = (value: unknown): bigint => {
71111
}
72112
};
73113

114+
/** Default page size the Camunda REST API uses when no explicit limit is set */
115+
export const API_DEFAULT_PAGE_SIZE = 100;
116+
74117
/** Max page size for case-insensitive search (client-side filtering needs broader result set) */
75118
const CI_PAGE_SIZE = 1000;
76119

@@ -111,14 +154,41 @@ function formatCriterion(fieldLabel: string, value: string | number | boolean, i
111154
*/
112155
function logSearchCriteria(logger: Logger, resourceName: string, criteria: string[]): void {
113156
if (criteria.length === 0) {
114-
logger.info(`Searching ${resourceName}`);
157+
logger.info(`Searching ${resourceName} (no filters)`);
115158
} else if (criteria.length === 1) {
116159
logger.info(`Searching ${resourceName} where ${criteria[0]}`);
117160
} else {
118161
logger.info(`Searching ${resourceName} where ${criteria.join(' AND ')}`);
119162
}
120163
}
121164

165+
/**
166+
* Log a "no results" message with 🕳️ emoji and contextual hint.
167+
*/
168+
export function logNoResults(logger: Logger, resourceName: string, hasFilters: boolean, unknownFlags?: string[]): void {
169+
if (unknownFlags && unknownFlags.length > 0) {
170+
const flagList = unknownFlags.map(f => `--${f}`).join(', ');
171+
logger.info(`🕳️ No ${resourceName} found matching the criteria (ignored unknown flag(s): ${flagList})`);
172+
} else {
173+
logger.info(`🕳️ No ${resourceName} found matching the criteria`);
174+
}
175+
if (!hasFilters) {
176+
logger.info('No filters were applied. Use "c8ctl help search" to see available filter flags.');
177+
}
178+
}
179+
180+
/**
181+
* Log the result count with a truncation warning when the count matches the API default page size.
182+
*/
183+
export function logResultCount(logger: Logger, count: number, resourceName: string, hasFilters: boolean): void {
184+
logger.info(`Found ${count} ${resourceName}`);
185+
if (count === API_DEFAULT_PAGE_SIZE && !hasFilters) {
186+
logger.warn(`Showing first ${API_DEFAULT_PAGE_SIZE} results (API default page size). More results may exist — add filters to narrow down.`);
187+
} else if (count === API_DEFAULT_PAGE_SIZE) {
188+
logger.warn(`Result count equals the API default page size (${API_DEFAULT_PAGE_SIZE}). There may be more results.`);
189+
}
190+
}
191+
122192
/**
123193
* Search process definitions
124194
*/
@@ -132,6 +202,7 @@ export async function searchProcessDefinitions(options: {
132202
iName?: string;
133203
sortBy?: string;
134204
sortOrder?: SortOrder;
205+
_unknownFlags?: string[];
135206
}): Promise<SearchResult | undefined> {
136207
const logger = getLogger();
137208
const client = createClient(options.profile);
@@ -218,9 +289,9 @@ export async function searchProcessDefinitions(options: {
218289
}));
219290
tableData = sortTableData(tableData, options.sortBy, logger, options.sortOrder);
220291
logger.table(tableData);
221-
logger.info(`Found ${result.items.length} process definition(s)`);
292+
logResultCount(logger, result.items.length, 'process definition(s)', criteria.length > 0);
222293
} else {
223-
logger.info('No process definitions found matching the criteria');
294+
logNoResults(logger, 'process definitions', criteria.length > 0, options._unknownFlags);
224295
}
225296

226297
return result as SearchResult;
@@ -243,6 +314,7 @@ export async function searchProcessInstances(options: {
243314
iProcessDefinitionId?: string;
244315
sortBy?: string;
245316
sortOrder?: SortOrder;
317+
_unknownFlags?: string[];
246318
between?: string;
247319
dateField?: string;
248320
}): Promise<SearchResult | undefined> {
@@ -336,9 +408,9 @@ export async function searchProcessInstances(options: {
336408
}));
337409
tableData = sortTableData(tableData, options.sortBy, logger, options.sortOrder);
338410
logger.table(tableData);
339-
logger.info(`Found ${result.items.length} process instance(s)`);
411+
logResultCount(logger, result.items.length, 'process instance(s)', criteria.length > 0);
340412
} else {
341-
logger.info('No process instances found matching the criteria');
413+
logNoResults(logger, 'process instances', criteria.length > 0, options._unknownFlags);
342414
}
343415

344416
return result as SearchResult;
@@ -361,6 +433,7 @@ export async function searchUserTasks(options: {
361433
iAssignee?: string;
362434
sortBy?: string;
363435
sortOrder?: SortOrder;
436+
_unknownFlags?: string[];
364437
between?: string;
365438
dateField?: string;
366439
}): Promise<SearchResult | undefined> {
@@ -455,9 +528,9 @@ export async function searchUserTasks(options: {
455528
}));
456529
tableData = sortTableData(tableData, options.sortBy, logger, options.sortOrder);
457530
logger.table(tableData);
458-
logger.info(`Found ${result.items.length} user task(s)`);
531+
logResultCount(logger, result.items.length, 'user task(s)', criteria.length > 0);
459532
} else {
460-
logger.info('No user tasks found matching the criteria');
533+
logNoResults(logger, 'user tasks', criteria.length > 0, options._unknownFlags);
461534
}
462535

463536
return result as SearchResult;
@@ -482,6 +555,7 @@ export async function searchIncidents(options: {
482555
iProcessDefinitionId?: string;
483556
sortBy?: string;
484557
sortOrder?: SortOrder;
558+
_unknownFlags?: string[];
485559
between?: string;
486560
}): Promise<SearchResult | undefined> {
487561
const logger = getLogger();
@@ -587,9 +661,9 @@ export async function searchIncidents(options: {
587661
}));
588662
tableData = sortTableData(tableData, options.sortBy, logger, options.sortOrder);
589663
logger.table(tableData);
590-
logger.info(`Found ${result.items.length} incident(s)`);
664+
logResultCount(logger, result.items.length, 'incident(s)', criteria.length > 0);
591665
} else {
592-
logger.info('No incidents found matching the criteria');
666+
logNoResults(logger, 'incidents', criteria.length > 0, options._unknownFlags);
593667
}
594668

595669
return result as SearchResult;
@@ -611,6 +685,7 @@ export async function searchJobs(options: {
611685
iType?: string;
612686
sortBy?: string;
613687
sortOrder?: SortOrder;
688+
_unknownFlags?: string[];
614689
between?: string;
615690
dateField?: string;
616691
}): Promise<SearchResult | undefined> {
@@ -698,9 +773,9 @@ export async function searchJobs(options: {
698773
}));
699774
tableData = sortTableData(tableData, options.sortBy, logger, options.sortOrder);
700775
logger.table(tableData);
701-
logger.info(`Found ${result.items.length} job(s)`);
776+
logResultCount(logger, result.items.length, 'job(s)', criteria.length > 0);
702777
} else {
703-
logger.info('No jobs found matching the criteria');
778+
logNoResults(logger, 'jobs', criteria.length > 0, options._unknownFlags);
704779
}
705780

706781
return result as SearchResult;
@@ -725,6 +800,7 @@ export async function searchVariables(options: {
725800
sortBy?: string;
726801
sortOrder?: SortOrder;
727802
limit?: number;
803+
_unknownFlags?: string[];
728804
}): Promise<SearchResult | undefined> {
729805
const logger = getLogger();
730806
const client = createClient(options.profile);
@@ -833,13 +909,13 @@ export async function searchVariables(options: {
833909
});
834910
tableData = sortTableData(tableData, options.sortBy, logger, options.sortOrder);
835911
logger.table(tableData);
836-
logger.info(`Found ${result.items.length} variable(s)`);
912+
logResultCount(logger, result.items.length, 'variable(s)', criteria.length > 0);
837913

838914
if (!options.fullValue && result.items.some((v: any) => v.isTruncated)) {
839915
logger.info('Some values are truncated. Use --fullValue to see full values.');
840916
}
841917
} else {
842-
logger.info('No variables found matching the criteria');
918+
logNoResults(logger, 'variables', criteria.length > 0, options._unknownFlags);
843919
}
844920

845921
return result as SearchResult;

src/index.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
* Main entry point
55
*/
66

7+
import { realpathSync } from 'node:fs';
78
import { parseArgs } from 'node:util';
9+
import { fileURLToPath } from 'node:url';
810
import { getLogger, type SortOrder } from './logger.ts';
911
import { c8ctl } from './runtime.ts';
1012
import { loadSessionState } from './config.ts';
@@ -28,6 +30,7 @@ import {
2830
searchIncidents,
2931
searchJobs,
3032
searchVariables,
33+
detectUnknownSearchFlags,
3134
} from './commands/search.ts';
3235
import { listUserTasks, completeUserTask } from './commands/user-tasks.ts';
3336
import { listIncidents, getIncident, resolveIncident } from './commands/incidents.ts';
@@ -144,6 +147,17 @@ export function resolveProcessDefinitionId(values: any): string | undefined {
144147
return (values.id || values.processDefinitionId || values.bpmnProcessId) as string | undefined;
145148
}
146149

150+
/**
151+
* Warn about unrecognized flags for a search resource.
152+
*/
153+
function warnUnknownSearchFlags(logger: ReturnType<typeof getLogger>, unknownFlags: string[], resource: string): void {
154+
if (unknownFlags.length === 0) return;
155+
const flagList = unknownFlags.map(f => `--${f}`).join(', ');
156+
logger.warn(
157+
`Flag(s) ${flagList} not recognized for 'search ${resource}'. They will be ignored. Run "c8ctl help search" for valid options.`,
158+
);
159+
}
160+
147161
/**
148162
* Main CLI handler
149163
*/
@@ -666,18 +680,21 @@ async function main() {
666680
// Handle search commands
667681
if (verb === 'search') {
668682
const normalizedSearchResource = normalizeResource(resource);
683+
const unknownFlags = detectUnknownSearchFlags(values as Record<string, unknown>, normalizedSearchResource);
684+
warnUnknownSearchFlags(logger, unknownFlags, resource);
669685

670686
if (normalizedSearchResource === 'process-definition' || normalizedSearchResource === 'process-definitions') {
671687
await searchProcessDefinitions({
672688
profile: values.profile as string | undefined,
673689
processDefinitionId: resolveProcessDefinitionId(values),
674690
name: values.name as string | undefined,
675-
version: (values.version_num && typeof values.version_num === 'string') ? parseInt(values.version_num) : undefined,
691+
version: (values.version && typeof values.version === 'string') ? parseInt(values.version) : undefined,
676692
key: values.key as string | undefined,
677693
iProcessDefinitionId: values.iid as string | undefined,
678694
iName: values.iname as string | undefined,
679695
sortBy: values.sortBy as string | undefined,
680696
sortOrder,
697+
_unknownFlags: unknownFlags,
681698
});
682699
return;
683700
}
@@ -693,6 +710,7 @@ async function main() {
693710
iProcessDefinitionId: values.iid as string | undefined,
694711
sortBy: values.sortBy as string | undefined,
695712
sortOrder,
713+
_unknownFlags: unknownFlags,
696714
between: values.between as string | undefined,
697715
dateField: values.dateField as string | undefined,
698716
});
@@ -710,6 +728,7 @@ async function main() {
710728
iAssignee: values.iassignee as string | undefined,
711729
sortBy: values.sortBy as string | undefined,
712730
sortOrder,
731+
_unknownFlags: unknownFlags,
713732
between: values.between as string | undefined,
714733
dateField: values.dateField as string | undefined,
715734
});
@@ -729,6 +748,7 @@ async function main() {
729748
iProcessDefinitionId: values.iid as string | undefined,
730749
sortBy: values.sortBy as string | undefined,
731750
sortOrder,
751+
_unknownFlags: unknownFlags,
732752
between: values.between as string | undefined,
733753
});
734754
return;
@@ -744,6 +764,7 @@ async function main() {
744764
iType: values.itype as string | undefined,
745765
sortBy: values.sortBy as string | undefined,
746766
sortOrder,
767+
_unknownFlags: unknownFlags,
747768
between: values.between as string | undefined,
748769
dateField: values.dateField as string | undefined,
749770
});
@@ -763,6 +784,7 @@ async function main() {
763784
sortBy: values.sortBy as string | undefined,
764785
sortOrder,
765786
limit,
787+
_unknownFlags: unknownFlags,
766788
});
767789
return;
768790
}
@@ -790,9 +812,12 @@ async function main() {
790812
}
791813

792814
// Run the CLI only when invoked directly (not when imported)
793-
if (process.argv[1] === new URL(import.meta.url).pathname) {
794-
main().catch((error) => {
795-
console.error('Unexpected error:', error);
796-
process.exit(1);
797-
});
798-
}
815+
// Use realpathSync to resolve symlinks (e.g. when installed globally via npm link)
816+
try {
817+
if (realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
818+
main().catch((error) => {
819+
console.error('Unexpected error:', error);
820+
process.exit(1);
821+
});
822+
}
823+
} catch { /* not invoked directly */ }

0 commit comments

Comments
 (0)