invoket 0.1.4 → 0.1.6

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +271 -37
  3. package/src/context.ts +22 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "invoket",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "TypeScript task runner for Bun - uses type annotations to parse CLI arguments",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -4,12 +4,20 @@ import { Context } from "./context";
4
4
  // Supported parameter types
5
5
  type ParamType = "string" | "number" | "boolean" | "object" | "array";
6
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
+
7
14
  // Parameter metadata extracted from TypeScript
8
15
  interface ParamMeta {
9
16
  name: string;
10
17
  type: ParamType;
11
18
  required: boolean;
12
19
  isRest: boolean;
20
+ flag?: FlagMeta;
13
21
  }
14
22
 
15
23
  interface TaskMeta {
@@ -17,6 +25,12 @@ interface TaskMeta {
17
25
  params: ParamMeta[];
18
26
  }
19
27
 
28
+ // Parsed CLI arguments
29
+ interface ParsedArgs {
30
+ positional: string[];
31
+ flags: Map<string, string | boolean>;
32
+ }
33
+
20
34
  interface DiscoveredTasks {
21
35
  root: Map<string, TaskMeta>;
22
36
  namespaced: Map<string, Map<string, TaskMeta>>; // namespace -> method -> meta
@@ -100,18 +114,57 @@ function extractMethodsFromClass(
100
114
  .map((line) => line.replace(/^\s*\*?\s*/, "").trim())
101
115
  .filter((line) => line && !line.startsWith("@"))[0] || "";
102
116
 
103
- const params = parseParams(paramsStr);
117
+ const params = parseParams(paramsStr, jsdoc);
104
118
  methods.set(methodName, { description, params });
105
119
  }
106
120
 
107
121
  return methods;
108
122
  }
109
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
+
110
158
  // Parse parameter string into ParamMeta array
111
- function parseParams(paramsStr: string | undefined): ParamMeta[] {
159
+ function parseParams(
160
+ paramsStr: string | undefined,
161
+ jsdoc: string = "",
162
+ ): ParamMeta[] {
112
163
  const params: ParamMeta[] = [];
113
164
  if (!paramsStr) return params;
114
165
 
166
+ const flagAnnotations = extractFlagAnnotations(jsdoc);
167
+
115
168
  // Check for rest parameter first: ...name: type
116
169
  const restMatch = paramsStr.match(/\.\.\.(\w+)\s*:\s*(\w+\[\]|\w+)/);
117
170
  if (restMatch) {
@@ -121,6 +174,7 @@ function parseParams(paramsStr: string | undefined): ParamMeta[] {
121
174
  type: rawType.endsWith("[]") ? "array" : "string",
122
175
  required: false,
123
176
  isRest: true,
177
+ // Rest params don't get flags
124
178
  });
125
179
  return params;
126
180
  }
@@ -146,7 +200,15 @@ function parseParams(paramsStr: string | undefined): ParamMeta[] {
146
200
  type = "object";
147
201
  }
148
202
 
149
- params.push({ name, type, required: !hasDefault, isRest: false });
203
+ // Build flag metadata
204
+ const annotation = flagAnnotations.get(name);
205
+ const flag: FlagMeta = {
206
+ long: `--${name}`,
207
+ short: annotation?.short,
208
+ aliases: annotation?.aliases,
209
+ };
210
+
211
+ params.push({ name, type, required: !hasDefault, isRest: false, flag });
150
212
  }
151
213
 
152
214
  return params;
@@ -282,6 +344,162 @@ function coerceArg(value: string, type: ParamType): unknown {
282
344
  }
283
345
  }
284
346
 
347
+ // Parse CLI arguments into flags and positional args
348
+ function parseCliArgs(args: string[]): ParsedArgs {
349
+ const positional: string[] = [];
350
+ const flags = new Map<string, string | boolean>();
351
+ let stopFlagParsing = false;
352
+
353
+ for (let i = 0; i < args.length; i++) {
354
+ const arg = args[i];
355
+
356
+ if (stopFlagParsing) {
357
+ positional.push(arg);
358
+ continue;
359
+ }
360
+
361
+ if (arg === "--") {
362
+ stopFlagParsing = true;
363
+ continue;
364
+ }
365
+
366
+ // --flag=value
367
+ if (arg.startsWith("--") && arg.includes("=")) {
368
+ const eqIdx = arg.indexOf("=");
369
+ const name = arg.slice(2, eqIdx);
370
+ const value = arg.slice(eqIdx + 1);
371
+ flags.set(name, value);
372
+ continue;
373
+ }
374
+
375
+ // --no-flag (boolean negation)
376
+ if (arg.startsWith("--no-")) {
377
+ const name = arg.slice(5);
378
+ flags.set(name, false);
379
+ continue;
380
+ }
381
+
382
+ // --flag (may be boolean or need next arg)
383
+ if (arg.startsWith("--")) {
384
+ const name = arg.slice(2);
385
+ const nextArg = args[i + 1];
386
+
387
+ // If next arg exists and doesn't look like a flag, use it as value
388
+ if (nextArg !== undefined && !nextArg.startsWith("-")) {
389
+ flags.set(name, nextArg);
390
+ i++; // Skip next arg
391
+ } else {
392
+ flags.set(name, true); // Boolean flag
393
+ }
394
+ continue;
395
+ }
396
+
397
+ // -f=value (short with equals)
398
+ if (arg.startsWith("-") && arg.length > 2 && arg.includes("=")) {
399
+ const eqIdx = arg.indexOf("=");
400
+ const name = arg.slice(1, eqIdx);
401
+ const value = arg.slice(eqIdx + 1);
402
+ flags.set(name, value);
403
+ continue;
404
+ }
405
+
406
+ // -f value or -f (boolean)
407
+ if (arg.startsWith("-") && arg.length === 2) {
408
+ const name = arg.slice(1);
409
+ const nextArg = args[i + 1];
410
+
411
+ if (nextArg !== undefined && !nextArg.startsWith("-")) {
412
+ flags.set(name, nextArg);
413
+ i++;
414
+ } else {
415
+ flags.set(name, true);
416
+ }
417
+ continue;
418
+ }
419
+
420
+ // Positional argument
421
+ positional.push(arg);
422
+ }
423
+
424
+ return { positional, flags };
425
+ }
426
+
427
+ // Resolve arguments from parsed CLI args using param metadata
428
+ function resolveArgs(params: ParamMeta[], parsed: ParsedArgs): unknown[] {
429
+ const result: unknown[] = [];
430
+ const usedPositional = new Set<number>();
431
+
432
+ for (const param of params) {
433
+ // Handle rest parameters - collect all remaining positional args
434
+ if (param.isRest) {
435
+ const remaining = parsed.positional.filter(
436
+ (_, i) => !usedPositional.has(i),
437
+ );
438
+ result.push(...remaining);
439
+ break;
440
+ }
441
+
442
+ let value: string | boolean | undefined;
443
+
444
+ // Try to get value from flags first
445
+ if (param.flag) {
446
+ // Check long flag (without --)
447
+ const longName = param.flag.long.slice(2);
448
+ if (parsed.flags.has(longName)) {
449
+ value = parsed.flags.get(longName);
450
+ }
451
+ // Check short flag (without -)
452
+ else if (param.flag.short) {
453
+ const shortName = param.flag.short.slice(1);
454
+ if (parsed.flags.has(shortName)) {
455
+ value = parsed.flags.get(shortName);
456
+ }
457
+ }
458
+ // Check aliases
459
+ if (value === undefined && param.flag.aliases) {
460
+ for (const alias of param.flag.aliases) {
461
+ const aliasName = alias.slice(2);
462
+ if (parsed.flags.has(aliasName)) {
463
+ value = parsed.flags.get(aliasName);
464
+ break;
465
+ }
466
+ }
467
+ }
468
+ }
469
+
470
+ // Fall back to positional if no flag found
471
+ if (value === undefined) {
472
+ for (let i = 0; i < parsed.positional.length; i++) {
473
+ if (!usedPositional.has(i)) {
474
+ value = parsed.positional[i];
475
+ usedPositional.add(i);
476
+ break;
477
+ }
478
+ }
479
+ }
480
+
481
+ // Handle missing values
482
+ if (value === undefined) {
483
+ if (param.required) {
484
+ throw new Error(
485
+ `Missing required argument: <${param.name}> (${param.type})`,
486
+ );
487
+ }
488
+ break; // Optional param not provided, stop processing
489
+ }
490
+
491
+ // Coerce and add to result
492
+ // Boolean flags that are already boolean don't need coercion
493
+ if (typeof value === "boolean" && param.type === "boolean") {
494
+ result.push(value);
495
+ } else {
496
+ result.push(coerceArg(String(value), param.type));
497
+ }
498
+ }
499
+
500
+ return result;
501
+ }
502
+
285
503
  // Format param for help display
286
504
  function formatParam(param: ParamMeta): string {
287
505
  if (param.isRest) {
@@ -290,6 +508,20 @@ function formatParam(param: ParamMeta): string {
290
508
  return param.required ? `<${param.name}>` : `[${param.name}]`;
291
509
  }
292
510
 
511
+ // Format flag info for display
512
+ function formatFlagInfo(param: ParamMeta): string {
513
+ if (!param.flag || param.isRest) return "";
514
+
515
+ const parts: string[] = [param.flag.long];
516
+ if (param.flag.short) {
517
+ parts.push(param.flag.short);
518
+ }
519
+ if (param.flag.aliases) {
520
+ parts.push(...param.flag.aliases);
521
+ }
522
+ return parts.join(", ");
523
+ }
524
+
293
525
  // Display help for a specific task
294
526
  function showTaskHelp(command: string, meta: TaskMeta): void {
295
527
  const paramStr = meta.params.map(formatParam).join(" ");
@@ -306,7 +538,11 @@ function showTaskHelp(command: string, meta: TaskMeta): void {
306
538
  for (const param of meta.params) {
307
539
  const reqStr = param.required ? "(required)" : "(optional)";
308
540
  const typeStr = param.isRest ? `${param.type}...` : param.type;
309
- console.log(` ${param.name.padEnd(15)} ${typeStr.padEnd(10)} ${reqStr}`);
541
+ const flagStr = formatFlagInfo(param);
542
+ const flagDisplay = flagStr ? ` ${flagStr}` : "";
543
+ console.log(
544
+ ` ${param.name.padEnd(15)} ${typeStr.padEnd(10)} ${reqStr}${flagDisplay}`,
545
+ );
310
546
  }
311
547
  }
312
548
  }
@@ -324,7 +560,23 @@ async function main() {
324
560
  }
325
561
 
326
562
  // Find tasks.ts
327
- const tasksPath = Bun.resolveSync("./tasks.ts", process.cwd());
563
+ let tasksPath: string;
564
+ try {
565
+ tasksPath = Bun.resolveSync("./tasks.ts", process.cwd());
566
+ } catch {
567
+ console.log("No tasks.ts found. Create one to get started:\n");
568
+ console.log(`import { Context } from "invoket/context";
569
+
570
+ export class Tasks {
571
+ /** Say hello */
572
+ async hello(c: Context) {
573
+ console.log("Hello, World!");
574
+ }
575
+ }
576
+ `);
577
+ process.exit(1);
578
+ }
579
+
328
580
  const source = await Bun.file(tasksPath).text();
329
581
 
330
582
  // Import and instantiate Tasks class
@@ -480,43 +732,25 @@ async function main() {
480
732
  return;
481
733
  }
482
734
 
483
- // Validate and coerce arguments
484
- const coercedArgs: unknown[] = [];
485
-
486
- // If no param info (imported namespace), pass all args as strings
487
- if (meta.params.length === 0 && taskArgs.length > 0) {
488
- coercedArgs.push(...taskArgs);
489
- }
490
-
491
- for (let i = 0; i < meta.params.length; i++) {
492
- const param = meta.params[i];
493
-
494
- // Handle rest parameters - collect all remaining args and spread them
495
- if (param.isRest) {
496
- const restArgs = taskArgs.slice(i);
497
- coercedArgs.push(...restArgs);
498
- break;
499
- }
735
+ // Filter out help flags from taskArgs before parsing
736
+ const argsWithoutHelp = taskArgs.filter((a) => a !== "-h" && a !== "--help");
500
737
 
501
- const arg = taskArgs[i];
738
+ // Parse CLI args into flags and positional
739
+ const parsed = parseCliArgs(argsWithoutHelp);
502
740
 
503
- if (arg === undefined) {
504
- if (param.required) {
505
- console.error(
506
- `Missing required argument: <${param.name}> (${param.type})`,
507
- );
508
- const paramStr = meta.params.map(formatParam).join(" ");
509
- console.error(`Usage: ${command} ${paramStr}`);
510
- process.exit(1);
511
- }
512
- // Optional param not provided, don't push (use default)
513
- break;
514
- }
741
+ // Validate and coerce arguments
742
+ let coercedArgs: unknown[];
515
743
 
744
+ // If no param info (imported namespace), pass all args as strings
745
+ if (meta.params.length === 0 && argsWithoutHelp.length > 0) {
746
+ coercedArgs = [...parsed.positional];
747
+ } else {
516
748
  try {
517
- coercedArgs.push(coerceArg(arg, param.type));
749
+ coercedArgs = resolveArgs(meta.params, parsed);
518
750
  } catch (e) {
519
- console.error(`Argument "${param.name}": ${(e as Error).message}`);
751
+ console.error((e as Error).message);
752
+ const paramStr = meta.params.map(formatParam).join(" ");
753
+ console.error(`Usage: ${command} ${paramStr}`);
520
754
  process.exit(1);
521
755
  }
522
756
  }
package/src/context.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { $ } from "bun";
1
+ import { $, spawn } from "bun";
2
2
  import { resolve } from "path";
3
3
 
4
4
  export interface RunResult {
@@ -13,6 +13,7 @@ export interface RunOptions {
13
13
  echo?: boolean;
14
14
  warn?: boolean;
15
15
  hide?: boolean;
16
+ stream?: boolean;
16
17
  cwd?: string;
17
18
  }
18
19
 
@@ -43,14 +44,26 @@ export class Context {
43
44
  console.log(`$ ${command}`);
44
45
  }
45
46
 
46
- const result = await $`sh -c ${command}`
47
- .cwd(opts.cwd ?? this.cwd)
48
- .nothrow()
49
- .quiet();
47
+ let result;
48
+ if (opts.stream) {
49
+ // Stream output in real-time using Bun.spawn with inherited stdio
50
+ const proc = spawn(["sh", "-c", command], {
51
+ cwd: opts.cwd ?? this.cwd,
52
+ stdout: "inherit",
53
+ stderr: "inherit",
54
+ });
55
+ const exitCode = await proc.exited;
56
+ result = { exitCode, stdout: Buffer.from(""), stderr: Buffer.from("") };
57
+ } else {
58
+ result = await $`sh -c ${command}`
59
+ .cwd(opts.cwd ?? this.cwd)
60
+ .nothrow()
61
+ .quiet();
62
+ }
50
63
 
51
64
  const runResult: RunResult = {
52
- stdout: result.stdout.toString(),
53
- stderr: result.stderr.toString(),
65
+ stdout: opts.stream ? "" : result.stdout.toString(),
66
+ stderr: opts.stream ? "" : result.stderr.toString(),
54
67
  code: result.exitCode,
55
68
  ok: result.exitCode === 0,
56
69
  failed: result.exitCode !== 0,
@@ -64,7 +77,8 @@ export class Context {
64
77
  throw error;
65
78
  }
66
79
 
67
- if (!opts.hide) {
80
+ // When streaming, output already went to terminal; otherwise write captured output
81
+ if (!opts.stream && !opts.hide) {
68
82
  if (runResult.stdout) process.stdout.write(runResult.stdout);
69
83
  if (runResult.stderr) process.stderr.write(runResult.stderr);
70
84
  }