invoket 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -1,559 +1,19 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Context } from "./context";
3
-
4
- // Supported parameter types
5
- type ParamType = "string" | "number" | "boolean" | "object" | "array";
6
-
7
- // Flag metadata for a parameter
8
- interface FlagMeta {
9
- long: string; // e.g., "--name"
10
- short?: string; // e.g., "-n"
11
- aliases?: string[]; // e.g., ["--environment"]
12
- }
13
-
14
- // Parameter metadata extracted from TypeScript
15
- interface ParamMeta {
16
- name: string;
17
- type: ParamType;
18
- required: boolean;
19
- isRest: boolean;
20
- flag?: FlagMeta;
21
- }
22
-
23
- interface TaskMeta {
24
- description: string;
25
- params: ParamMeta[];
26
- }
27
-
28
- // Parsed CLI arguments
29
- interface ParsedArgs {
30
- positional: string[];
31
- flags: Map<string, string | boolean>;
32
- }
33
-
34
- interface DiscoveredTasks {
35
- root: Map<string, TaskMeta>;
36
- namespaced: Map<string, Map<string, TaskMeta>>; // namespace -> method -> meta
37
- classDoc: string | null;
38
- }
39
-
40
- // Extract class-level JSDoc for Tasks class
41
- function extractClassDoc(source: string): string | null {
42
- const match = source.match(
43
- /\/\*\*\s*([^*]*(?:\*(?!\/)[^*]*)*)\*\/\s*export\s+class\s+Tasks/,
44
- );
45
- if (!match) return null;
46
-
47
- const lines = match[1]
48
- .split("\n")
49
- .map((line) => line.replace(/^\s*\*?\s*/, "").trim())
50
- .filter((line) => line && !line.startsWith("@"));
51
-
52
- return lines[0] || null;
53
- }
54
-
55
- // Parse command to extract namespace and method
56
- function parseCommand(command: string): {
57
- namespace: string | null;
58
- method: string;
59
- } {
60
- const colonIdx = command.indexOf(":");
61
- const dotIdx = command.indexOf(".");
62
-
63
- let sepIdx = -1;
64
- if (colonIdx !== -1 && dotIdx !== -1) {
65
- sepIdx = Math.min(colonIdx, dotIdx);
66
- } else if (colonIdx !== -1) {
67
- sepIdx = colonIdx;
68
- } else if (dotIdx !== -1) {
69
- sepIdx = dotIdx;
70
- }
71
-
72
- if (sepIdx !== -1) {
73
- return {
74
- namespace: command.slice(0, sepIdx),
75
- method: command.slice(sepIdx + 1),
76
- };
77
- }
78
-
79
- return { namespace: null, method: command };
80
- }
81
-
82
- // Extract methods from a class definition in source
83
- function extractMethodsFromClass(
84
- source: string,
85
- className: string,
86
- ): Map<string, TaskMeta> {
87
- const methods = new Map<string, TaskMeta>();
88
-
89
- // Find the class body
90
- const classPattern = new RegExp(
91
- `class\\s+${className}\\s*(?:extends\\s+\\w+)?\\s*\\{([\\s\\S]*?)\\n\\}`,
92
- );
93
- const classMatch = source.match(classPattern);
94
- if (!classMatch) return methods;
95
-
96
- const classBody = classMatch[1];
97
-
98
- // Match method declarations with JSDoc
99
- const methodPattern =
100
- /\/\*\*\s*([^*]*(?:\*(?!\/)[^*]*)*)\*\/\s*async\s+(\w+)\s*\(\s*c\s*:\s*Context\s*(?:,\s*([^)]+))?\s*\)/g;
101
-
102
- let match;
103
- while ((match = methodPattern.exec(classBody)) !== null) {
104
- const [, jsdoc, methodName, paramsStr] = match;
105
-
106
- // Skip private methods and constructor
107
- if (methodName.startsWith("_") || methodName === "constructor") {
108
- continue;
109
- }
110
-
111
- const description =
112
- jsdoc
113
- .split("\n")
114
- .map((line) => line.replace(/^\s*\*?\s*/, "").trim())
115
- .filter((line) => line && !line.startsWith("@"))[0] || "";
116
-
117
- const params = parseParams(paramsStr, jsdoc);
118
- methods.set(methodName, { description, params });
119
- }
120
-
121
- return methods;
122
- }
123
-
124
- // Extract @flag annotations from JSDoc
125
- function extractFlagAnnotations(
126
- jsdoc: string,
127
- ): Map<string, { short?: string; aliases?: string[] }> {
128
- const flags = new Map<string, { short?: string; aliases?: string[] }>();
129
-
130
- // Match @flag paramName -s --alias1 --alias2
131
- const flagPattern = /@flag\s+(\w+)\s+([^\n@]*)/g;
132
- let match;
133
-
134
- while ((match = flagPattern.exec(jsdoc)) !== null) {
135
- const [, paramName, flagsStr] = match;
136
- const parts = flagsStr.trim().split(/\s+/);
137
-
138
- let short: string | undefined;
139
- const aliases: string[] = [];
140
-
141
- for (const part of parts) {
142
- if (part.startsWith("--")) {
143
- aliases.push(part);
144
- } else if (part.startsWith("-") && part.length === 2) {
145
- short = part;
146
- }
147
- }
148
-
149
- flags.set(paramName, {
150
- short: short,
151
- aliases: aliases.length > 0 ? aliases : undefined,
152
- });
153
- }
154
-
155
- return flags;
156
- }
157
-
158
- // Parse parameter string into ParamMeta array
159
- function parseParams(
160
- paramsStr: string | undefined,
161
- jsdoc: string = "",
162
- ): ParamMeta[] {
163
- const params: ParamMeta[] = [];
164
- if (!paramsStr) return params;
165
-
166
- const flagAnnotations = extractFlagAnnotations(jsdoc);
167
-
168
- // Check for rest parameter first: ...name: type
169
- const restMatch = paramsStr.match(/\.\.\.(\w+)\s*:\s*(\w+\[\]|\w+)/);
170
- if (restMatch) {
171
- const [, name, rawType] = restMatch;
172
- params.push({
173
- name,
174
- type: rawType.endsWith("[]") ? "array" : "string",
175
- required: false,
176
- isRest: true,
177
- // Rest params don't get flags
178
- });
179
- return params;
180
- }
181
-
182
- // Updated regex to handle union types with null (e.g., string | null)
183
- const paramPattern =
184
- /(\w+)\s*:\s*(\w+\[\]|Record<[^>]+>|\{[^}]*\}|string|number|boolean|\w+)(?:\s*\|\s*null)?(?:\s*=\s*[^,)]+)?/g;
185
- let paramMatch;
186
-
187
- while ((paramMatch = paramPattern.exec(paramsStr)) !== null) {
188
- const [fullMatch, name, rawType] = paramMatch;
189
- const hasDefault = fullMatch.includes("=");
190
- const isNullable = fullMatch.includes("| null");
191
-
192
- let type: ParamType;
193
- if (rawType === "string") {
194
- type = "string";
195
- } else if (rawType === "number") {
196
- type = "number";
197
- } else if (rawType === "boolean") {
198
- type = "boolean";
199
- } else if (rawType.endsWith("[]")) {
200
- type = "array";
201
- } else {
202
- type = "object";
203
- }
204
-
205
- // Build flag metadata
206
- const annotation = flagAnnotations.get(name);
207
- const flag: FlagMeta = {
208
- long: `--${name}`,
209
- short: annotation?.short,
210
- aliases: annotation?.aliases,
211
- };
212
-
213
- params.push({
214
- name,
215
- type,
216
- required: !hasDefault && !isNullable,
217
- isRest: false,
218
- flag,
219
- });
220
- }
221
-
222
- return params;
223
- }
224
-
225
- // Discover all tasks including namespaced ones (source parsing only)
226
- function discoverAllTasks(source: string): DiscoveredTasks {
227
- const root = extractMethodsFromClass(source, "Tasks");
228
- const namespaced = new Map<string, Map<string, TaskMeta>>();
229
- const classDoc = extractClassDoc(source);
230
-
231
- // Find namespace assignments in Tasks class: propertyName = new ClassName()
232
- const nsPattern = /(\w+)\s*=\s*new\s+(\w+)\s*\(\s*\)/g;
233
- let nsMatch;
234
-
235
- while ((nsMatch = nsPattern.exec(source)) !== null) {
236
- const [, propName, className] = nsMatch;
237
-
238
- // Skip private namespaces
239
- if (propName.startsWith("_")) continue;
240
-
241
- const nsMethods = extractMethodsFromClass(source, className);
242
- if (nsMethods.size > 0) {
243
- namespaced.set(propName, nsMethods);
244
- }
245
- }
246
-
247
- return { root, namespaced, classDoc };
248
- }
249
-
250
- // Discover methods from runtime instance (for imported namespaces)
251
- function discoverRuntimeNamespaces(
252
- instance: any,
253
- discovered: DiscoveredTasks,
254
- ): void {
255
- // Find namespace properties on the instance
256
- for (const propName of Object.getOwnPropertyNames(instance)) {
257
- // Skip private, already discovered, or non-objects
258
- if (propName.startsWith("_")) continue;
259
- if (discovered.namespaced.has(propName)) continue;
260
-
261
- const prop = instance[propName];
262
- if (!prop || typeof prop !== "object" || Array.isArray(prop)) continue;
263
-
264
- // Discover methods from this namespace at runtime
265
- const methods = new Map<string, TaskMeta>();
266
- let proto = Object.getPrototypeOf(prop);
267
-
268
- while (proto && proto !== Object.prototype) {
269
- for (const methodName of Object.getOwnPropertyNames(proto)) {
270
- if (
271
- methodName === "constructor" ||
272
- methodName.startsWith("_") ||
273
- typeof prop[methodName] !== "function"
274
- ) {
275
- continue;
276
- }
277
-
278
- // No type info for imported methods - treat args as strings
279
- if (!methods.has(methodName)) {
280
- methods.set(methodName, { description: "", params: [] });
281
- }
282
- }
283
- proto = Object.getPrototypeOf(proto);
284
- }
285
-
286
- if (methods.size > 0) {
287
- discovered.namespaced.set(propName, methods);
288
- }
289
- }
290
- }
291
-
292
- // Parse TypeScript source to extract method signatures and types (legacy, for compatibility)
293
- async function extractTaskMeta(source: string): Promise<Map<string, TaskMeta>> {
294
- const { root, namespaced } = discoverAllTasks(source);
295
-
296
- // Combine root and namespaced for backward compat
297
- const all = new Map(root);
298
- for (const [ns, methods] of namespaced) {
299
- for (const [method, meta] of methods) {
300
- all.set(method, meta); // This flattens - we'll fix in main()
301
- }
302
- }
303
-
304
- return all;
305
- }
306
-
307
- // Convert CLI arg to typed value
308
- function coerceArg(value: string, type: ParamType): unknown {
309
- switch (type) {
310
- case "number": {
311
- if (value === "") {
312
- throw new Error(`Expected number, got ""`);
313
- }
314
- const n = Number(value);
315
- if (Number.isNaN(n)) {
316
- throw new Error(`Expected number, got "${value}"`);
317
- }
318
- return n;
319
- }
320
- case "boolean":
321
- if (value === "true" || value === "1") return true;
322
- if (value === "false" || value === "0") return false;
323
- throw new Error(`Expected boolean, got "${value}"`);
324
- case "object":
325
- case "array": {
326
- try {
327
- const parsed = JSON.parse(value);
328
- if (type === "array" && !Array.isArray(parsed)) {
329
- throw new Error(`Expected array, got ${typeof parsed}`);
330
- }
331
- if (
332
- type === "object" &&
333
- (typeof parsed !== "object" ||
334
- Array.isArray(parsed) ||
335
- parsed === null)
336
- ) {
337
- throw new Error(
338
- `Expected object, got ${Array.isArray(parsed) ? "array" : typeof parsed}`,
339
- );
340
- }
341
- return parsed;
342
- } catch (e) {
343
- if (e instanceof SyntaxError) {
344
- throw new Error(`Invalid JSON: ${e.message}`);
345
- }
346
- throw e;
347
- }
348
- }
349
- case "string":
350
- default:
351
- return value;
352
- }
353
- }
354
-
355
- // Parse CLI arguments into flags and positional args
356
- function parseCliArgs(args: string[]): ParsedArgs {
357
- const positional: string[] = [];
358
- const flags = new Map<string, string | boolean>();
359
- let stopFlagParsing = false;
360
-
361
- for (let i = 0; i < args.length; i++) {
362
- const arg = args[i];
363
-
364
- if (stopFlagParsing) {
365
- positional.push(arg);
366
- continue;
367
- }
368
-
369
- if (arg === "--") {
370
- stopFlagParsing = true;
371
- continue;
372
- }
373
-
374
- // --flag=value
375
- if (arg.startsWith("--") && arg.includes("=")) {
376
- const eqIdx = arg.indexOf("=");
377
- const name = arg.slice(2, eqIdx);
378
- const value = arg.slice(eqIdx + 1);
379
- flags.set(name, value);
380
- continue;
381
- }
382
-
383
- // --no-flag (boolean negation)
384
- if (arg.startsWith("--no-")) {
385
- const name = arg.slice(5);
386
- flags.set(name, false);
387
- continue;
388
- }
389
-
390
- // --flag (may be boolean or need next arg)
391
- if (arg.startsWith("--")) {
392
- const name = arg.slice(2);
393
- const nextArg = args[i + 1];
394
-
395
- // If next arg exists and doesn't look like a flag, use it as value
396
- if (nextArg !== undefined && !nextArg.startsWith("-")) {
397
- flags.set(name, nextArg);
398
- i++; // Skip next arg
399
- } else {
400
- flags.set(name, true); // Boolean flag
401
- }
402
- continue;
403
- }
404
-
405
- // -f=value (short with equals)
406
- if (arg.startsWith("-") && arg.length > 2 && arg.includes("=")) {
407
- const eqIdx = arg.indexOf("=");
408
- const name = arg.slice(1, eqIdx);
409
- const value = arg.slice(eqIdx + 1);
410
- flags.set(name, value);
411
- continue;
412
- }
413
-
414
- // -f value or -f (boolean)
415
- if (arg.startsWith("-") && arg.length === 2) {
416
- const name = arg.slice(1);
417
- const nextArg = args[i + 1];
418
-
419
- if (nextArg !== undefined && !nextArg.startsWith("-")) {
420
- flags.set(name, nextArg);
421
- i++;
422
- } else {
423
- flags.set(name, true);
424
- }
425
- continue;
426
- }
427
-
428
- // Positional argument
429
- positional.push(arg);
430
- }
431
-
432
- return { positional, flags };
433
- }
434
-
435
- // Resolve arguments from parsed CLI args using param metadata
436
- function resolveArgs(params: ParamMeta[], parsed: ParsedArgs): unknown[] {
437
- const result: unknown[] = [];
438
- const usedPositional = new Set<number>();
439
-
440
- for (const param of params) {
441
- // Handle rest parameters - collect all remaining positional args
442
- if (param.isRest) {
443
- const remaining = parsed.positional.filter(
444
- (_, i) => !usedPositional.has(i),
445
- );
446
- result.push(...remaining);
447
- break;
448
- }
449
-
450
- let value: string | boolean | undefined;
451
-
452
- // Try to get value from flags first
453
- if (param.flag) {
454
- // Check long flag (without --)
455
- const longName = param.flag.long.slice(2);
456
- if (parsed.flags.has(longName)) {
457
- value = parsed.flags.get(longName);
458
- }
459
- // Check short flag (without -)
460
- else if (param.flag.short) {
461
- const shortName = param.flag.short.slice(1);
462
- if (parsed.flags.has(shortName)) {
463
- value = parsed.flags.get(shortName);
464
- }
465
- }
466
- // Check aliases
467
- if (value === undefined && param.flag.aliases) {
468
- for (const alias of param.flag.aliases) {
469
- const aliasName = alias.slice(2);
470
- if (parsed.flags.has(aliasName)) {
471
- value = parsed.flags.get(aliasName);
472
- break;
473
- }
474
- }
475
- }
476
- }
477
-
478
- // Fall back to positional if no flag found
479
- if (value === undefined) {
480
- for (let i = 0; i < parsed.positional.length; i++) {
481
- if (!usedPositional.has(i)) {
482
- value = parsed.positional[i];
483
- usedPositional.add(i);
484
- break;
485
- }
486
- }
487
- }
488
-
489
- // Handle missing values
490
- if (value === undefined) {
491
- if (param.required) {
492
- throw new Error(
493
- `Missing required argument: <${param.name}> (${param.type})`,
494
- );
495
- }
496
- break; // Optional param not provided, stop processing
497
- }
498
-
499
- // Coerce and add to result
500
- // Boolean flags that are already boolean don't need coercion
501
- if (typeof value === "boolean" && param.type === "boolean") {
502
- result.push(value);
503
- } else {
504
- result.push(coerceArg(String(value), param.type));
505
- }
506
- }
507
-
508
- return result;
509
- }
510
-
511
- // Format param for help display
512
- function formatParam(param: ParamMeta): string {
513
- if (param.isRest) {
514
- return `[${param.name}...]`;
515
- }
516
- return param.required ? `<${param.name}>` : `[${param.name}]`;
517
- }
518
-
519
- // Format flag info for display
520
- function formatFlagInfo(param: ParamMeta): string {
521
- if (!param.flag || param.isRest) return "";
522
-
523
- const parts: string[] = [param.flag.long];
524
- if (param.flag.short) {
525
- parts.push(param.flag.short);
526
- }
527
- if (param.flag.aliases) {
528
- parts.push(...param.flag.aliases);
529
- }
530
- return parts.join(", ");
531
- }
532
-
533
- // Display help for a specific task
534
- function showTaskHelp(command: string, meta: TaskMeta): void {
535
- const paramStr = meta.params.map(formatParam).join(" ");
536
- const signature = paramStr ? `${command} ${paramStr}` : command;
537
-
538
- console.log(`Usage: invt ${signature}\n`);
539
-
540
- if (meta.description) {
541
- console.log(`${meta.description}\n`);
542
- }
543
-
544
- if (meta.params.length > 0) {
545
- console.log("Arguments:");
546
- for (const param of meta.params) {
547
- const reqStr = param.required ? "(required)" : "(optional)";
548
- const typeStr = param.isRest ? `${param.type}...` : param.type;
549
- const flagStr = formatFlagInfo(param);
550
- const flagDisplay = flagStr ? ` ${flagStr}` : "";
551
- console.log(
552
- ` ${param.name.padEnd(15)} ${typeStr.padEnd(10)} ${reqStr}${flagDisplay}`,
553
- );
554
- }
555
- }
556
- }
3
+ import { dirname } from "path";
4
+ import {
5
+ discoverAllTasks,
6
+ discoverRuntimeNamespaces,
7
+ extractImports,
8
+ extractMethodsFromClass,
9
+ findUnresolvedNamespaces,
10
+ parseCommand,
11
+ parseCliArgs,
12
+ resolveArgs,
13
+ formatParam,
14
+ printTaskList,
15
+ showTaskHelp,
16
+ } from "./parser";
557
17
 
558
18
  // Main CLI entry point
559
19
  async function main() {
@@ -595,6 +55,24 @@ export class Tasks {
595
55
  // Discover all tasks including namespaced
596
56
  const discovered = discoverAllTasks(source);
597
57
 
58
+ // Resolve imported namespace classes from their source files
59
+ const imports = extractImports(source);
60
+ const unresolved = findUnresolvedNamespaces(source, discovered);
61
+ for (const [propName, className] of unresolved) {
62
+ const importPath = imports.get(className);
63
+ if (!importPath) continue;
64
+ try {
65
+ const resolvedPath = Bun.resolveSync(importPath, dirname(tasksPath));
66
+ const importedSource = await Bun.file(resolvedPath).text();
67
+ const methods = extractMethodsFromClass(importedSource, className);
68
+ if (methods.size > 0) {
69
+ discovered.namespaced.set(propName, methods);
70
+ }
71
+ } catch {
72
+ // Can't resolve import — runtime discovery will handle it
73
+ }
74
+ }
75
+
598
76
  // Also discover imported namespaces from runtime
599
77
  discoverRuntimeNamespaces(instance, discovered);
600
78
 
@@ -610,26 +88,7 @@ export class Tasks {
610
88
  }
611
89
 
612
90
  console.log("Available tasks:\n");
613
-
614
- // Root tasks
615
- for (const [name, meta] of discovered.root) {
616
- const paramStr = meta.params.map(formatParam).join(" ");
617
- const signature = paramStr ? `${name} ${paramStr}` : name;
618
- console.log(` ${signature}`);
619
- }
620
-
621
- // Namespaced tasks
622
- for (const [ns, methods] of discovered.namespaced) {
623
- console.log(`\n${ns}:`);
624
- for (const [name, meta] of methods) {
625
- const paramStr = meta.params.map(formatParam).join(" ");
626
- const signature = paramStr
627
- ? `${ns}:${name} ${paramStr}`
628
- : `${ns}:${name}`;
629
- console.log(` ${signature}`);
630
- }
631
- }
632
-
91
+ printTaskList(discovered);
633
92
  console.log("\nUsage: invt <task> [args...]");
634
93
  console.log(" invt <task> -h Show help for a specific task");
635
94
  return;
@@ -638,25 +97,7 @@ export class Tasks {
638
97
  // List flag
639
98
  if (args[0] === "-l" || args[0] === "--list") {
640
99
  console.log("Available tasks:\n");
641
-
642
- // Root tasks
643
- for (const [name, meta] of discovered.root) {
644
- const paramStr = meta.params.map(formatParam).join(" ");
645
- const signature = paramStr ? `${name} ${paramStr}` : name;
646
- console.log(` ${signature}`);
647
- }
648
-
649
- // Namespaced tasks
650
- for (const [ns, methods] of discovered.namespaced) {
651
- console.log(`\n${ns}:`);
652
- for (const [name, meta] of methods) {
653
- const paramStr = meta.params.map(formatParam).join(" ");
654
- const signature = paramStr
655
- ? `${ns}:${name} ${paramStr}`
656
- : `${ns}:${name}`;
657
- console.log(` ${signature}`);
658
- }
659
- }
100
+ printTaskList(discovered);
660
101
  return;
661
102
  }
662
103
 
@@ -668,7 +109,7 @@ export class Tasks {
668
109
 
669
110
  const { namespace, method: methodName } = parseCommand(command);
670
111
 
671
- let meta: TaskMeta | undefined;
112
+ let meta;
672
113
  let method: Function | undefined;
673
114
  let thisArg: any = instance;
674
115
 
package/src/context.ts CHANGED
@@ -17,6 +17,16 @@ export interface RunOptions {
17
17
  cwd?: string;
18
18
  }
19
19
 
20
+ export class CommandError extends Error {
21
+ result: RunResult;
22
+
23
+ constructor(message: string, result: RunResult) {
24
+ super(message);
25
+ this.name = "CommandError";
26
+ this.result = result;
27
+ }
28
+ }
29
+
20
30
  export class Context {
21
31
  cwd: string;
22
32
  private options: RunOptions;
@@ -70,11 +80,10 @@ export class Context {
70
80
  };
71
81
 
72
82
  if (!opts.warn && runResult.failed) {
73
- const error = new Error(
83
+ throw new CommandError(
74
84
  `Command failed with exit code ${runResult.code}: ${command}`,
85
+ runResult,
75
86
  );
76
- (error as any).result = runResult;
77
- throw error;
78
87
  }
79
88
 
80
89
  // When streaming, output already went to terminal; otherwise write captured output