invoket 0.1.0

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/README.md ADDED
@@ -0,0 +1,208 @@
1
+ # invoket
2
+
3
+ A TypeScript task runner for Bun that uses type annotations to parse CLI arguments.
4
+
5
+ ## Features
6
+
7
+ - **Type-safe CLI parsing** — TypeScript types determine how arguments are parsed
8
+ - **Zero configuration** — Just write a `Tasks` class with typed methods
9
+ - **JSON support** — Object and array parameters are automatically parsed from JSON
10
+ - **Namespace support** — Organize tasks with `db:migrate` style namespaces
11
+ - **Rest parameters** — Support for `...args` variadic parameters
12
+ - **Auto-generated help** — JSDoc descriptions become CLI help text
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ bun link invoket
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ```bash
23
+ invt # Show help
24
+ invt hello World 3 # Run task with args
25
+ invt db:migrate up # Run namespaced task
26
+ invt --version # Show version
27
+ ```
28
+
29
+ ## Writing Tasks
30
+
31
+ Create a `tasks.ts` file with a `Tasks` class:
32
+
33
+ ```typescript
34
+ import { Context } from "invoket/context";
35
+
36
+ interface SearchParams {
37
+ query: string;
38
+ limit?: number;
39
+ }
40
+
41
+ /**
42
+ * Project build and deployment tasks
43
+ */
44
+ export class Tasks {
45
+ /** Say hello with a name and repeat count */
46
+ async hello(c: Context, name: string, count: number) {
47
+ for (let i = 0; i < count; i++) {
48
+ console.log(`Hello, ${name}!`);
49
+ }
50
+ }
51
+
52
+ /** Search with JSON parameters */
53
+ async search(c: Context, entity: string, params: SearchParams) {
54
+ console.log(`Searching ${entity}: ${params.query}`);
55
+ }
56
+
57
+ /** Install packages (rest params) */
58
+ async install(c: Context, ...packages: string[]) {
59
+ for (const pkg of packages) {
60
+ await c.run(`npm install ${pkg}`);
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ## Namespaces
67
+
68
+ Organize related tasks into namespaces:
69
+
70
+ ```typescript
71
+ class DbNamespace {
72
+ /** Run database migrations */
73
+ async migrate(c: Context, direction: string = "up") {
74
+ await c.run(`prisma migrate ${direction}`);
75
+ }
76
+
77
+ /** Seed the database */
78
+ async seed(c: Context) {
79
+ await c.run("prisma db seed");
80
+ }
81
+ }
82
+
83
+ export class Tasks {
84
+ db = new DbNamespace();
85
+ }
86
+ ```
87
+
88
+ Call with `invt db:migrate up` or `invt db.seed`.
89
+
90
+ ## Type Mapping
91
+
92
+ | TypeScript | CLI Display | Example Input |
93
+ |------------|-------------|---------------|
94
+ | `name: string` | `<name>` | `hello` |
95
+ | `name: string = "default"` | `[name]` | `hello` (optional) |
96
+ | `count: number` | `<count>` | `42` |
97
+ | `force: boolean` | `<force>` | `true` or `1` |
98
+ | `params: SomeInterface` | `<params>` | `'{"key": "value"}'` |
99
+ | `items: string[]` | `<items>` | `'["a", "b", "c"]'` |
100
+ | `...args: string[]` | `[args...]` | `a b c` (variadic) |
101
+
102
+ ## CLI Flags
103
+
104
+ | Flag | Description |
105
+ |------|-------------|
106
+ | `-h`, `--help` | Show help with all tasks |
107
+ | `<task> -h` | Show help for a specific task |
108
+ | `-l`, `--list` | List available tasks |
109
+ | `--version` | Show version |
110
+
111
+ ### Task-Specific Help
112
+
113
+ Get detailed help for any task:
114
+
115
+ ```bash
116
+ invt hello -h
117
+ # Usage: invt hello <name> <count>
118
+ #
119
+ # Say hello with a name and repeat count
120
+ #
121
+ # Arguments:
122
+ # name string (required)
123
+ # count number (required)
124
+
125
+ invt db:migrate --help
126
+ # Usage: invt db:migrate [direction]
127
+ #
128
+ # Run database migrations
129
+ #
130
+ # Arguments:
131
+ # direction string (optional)
132
+ ```
133
+
134
+ ## Context API
135
+
136
+ Every task receives a `Context` object as the first parameter:
137
+
138
+ ```typescript
139
+ async deploy(c: Context, env: string) {
140
+ // Run shell commands
141
+ await c.run("npm run build");
142
+
143
+ // Capture output
144
+ const { stdout } = await c.run("git rev-parse HEAD", { hide: true });
145
+
146
+ // Ignore errors
147
+ await c.run("rm -f temp.txt", { warn: true });
148
+
149
+ // Echo command before running
150
+ await c.run("npm test", { echo: true });
151
+
152
+ // Change directory temporarily
153
+ for await (const _ of c.cd("subdir")) {
154
+ await c.run("ls");
155
+ }
156
+
157
+ // Sudo
158
+ await c.sudo("apt update");
159
+
160
+ // Access config
161
+ console.log(c.config); // { echo: false, warn: false, ... }
162
+
163
+ // local() is alias for run()
164
+ await c.local("echo hello");
165
+ }
166
+ ```
167
+
168
+ ### Context Options
169
+
170
+ | Option | Type | Default | Description |
171
+ |--------|------|---------|-------------|
172
+ | `echo` | boolean | false | Print command before execution |
173
+ | `warn` | boolean | false | Don't throw on non-zero exit |
174
+ | `hide` | boolean | false | Capture output instead of printing |
175
+ | `cwd` | string | process.cwd() | Working directory |
176
+
177
+ ### RunResult
178
+
179
+ ```typescript
180
+ interface RunResult {
181
+ stdout: string;
182
+ stderr: string;
183
+ code: number;
184
+ ok: boolean; // code === 0
185
+ failed: boolean; // code !== 0
186
+ }
187
+ ```
188
+
189
+ ## Private Methods
190
+
191
+ Methods starting with `_` are private and won't appear in help or be callable:
192
+
193
+ ```typescript
194
+ export class Tasks {
195
+ async publicTask(c: Context) { }
196
+ async _privateHelper(c: Context) { } // Hidden
197
+ }
198
+ ```
199
+
200
+ ## Testing
201
+
202
+ ```bash
203
+ bun test
204
+ ```
205
+
206
+ ## Requirements
207
+
208
+ - Bun >= 1.0.0
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "invoket",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "TypeScript task runner for Bun - uses type annotations to parse CLI arguments",
6
+ "bin": {
7
+ "invt": "./src/cli.ts"
8
+ },
9
+ "exports": {
10
+ "./context": "./src/context.ts"
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "keywords": [
16
+ "task-runner",
17
+ "cli",
18
+ "bun",
19
+ "typescript",
20
+ "invoke"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/blueshed/invoket"
25
+ },
26
+ "license": "MIT",
27
+ "engines": {
28
+ "bun": ">=1.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "bun-types": "^1.3.5"
32
+ }
33
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,483 @@
1
+ #!/usr/bin/env bun
2
+ import { Context } from "./context";
3
+
4
+ // Supported parameter types
5
+ type ParamType = "string" | "number" | "boolean" | "object" | "array";
6
+
7
+ // Parameter metadata extracted from TypeScript
8
+ interface ParamMeta {
9
+ name: string;
10
+ type: ParamType;
11
+ required: boolean;
12
+ isRest: boolean;
13
+ }
14
+
15
+ interface TaskMeta {
16
+ description: string;
17
+ params: ParamMeta[];
18
+ }
19
+
20
+ interface DiscoveredTasks {
21
+ root: Map<string, TaskMeta>;
22
+ namespaced: Map<string, Map<string, TaskMeta>>; // namespace -> method -> meta
23
+ classDoc: string | null;
24
+ }
25
+
26
+ // Extract class-level JSDoc for Tasks class
27
+ function extractClassDoc(source: string): string | null {
28
+ const match = source.match(
29
+ /\/\*\*\s*([^*]*(?:\*(?!\/)[^*]*)*)\*\/\s*export\s+class\s+Tasks/,
30
+ );
31
+ if (!match) return null;
32
+
33
+ const lines = match[1]
34
+ .split("\n")
35
+ .map((line) => line.replace(/^\s*\*?\s*/, "").trim())
36
+ .filter((line) => line && !line.startsWith("@"));
37
+
38
+ return lines[0] || null;
39
+ }
40
+
41
+ // Parse command to extract namespace and method
42
+ function parseCommand(command: string): {
43
+ namespace: string | null;
44
+ method: string;
45
+ } {
46
+ const colonIdx = command.indexOf(":");
47
+ const dotIdx = command.indexOf(".");
48
+
49
+ let sepIdx = -1;
50
+ if (colonIdx !== -1 && dotIdx !== -1) {
51
+ sepIdx = Math.min(colonIdx, dotIdx);
52
+ } else if (colonIdx !== -1) {
53
+ sepIdx = colonIdx;
54
+ } else if (dotIdx !== -1) {
55
+ sepIdx = dotIdx;
56
+ }
57
+
58
+ if (sepIdx !== -1) {
59
+ return {
60
+ namespace: command.slice(0, sepIdx),
61
+ method: command.slice(sepIdx + 1),
62
+ };
63
+ }
64
+
65
+ return { namespace: null, method: command };
66
+ }
67
+
68
+ // Extract methods from a class definition in source
69
+ function extractMethodsFromClass(
70
+ source: string,
71
+ className: string,
72
+ ): Map<string, TaskMeta> {
73
+ const methods = new Map<string, TaskMeta>();
74
+
75
+ // Find the class body
76
+ const classPattern = new RegExp(
77
+ `class\\s+${className}\\s*(?:extends\\s+\\w+)?\\s*\\{([\\s\\S]*?)\\n\\}`,
78
+ );
79
+ const classMatch = source.match(classPattern);
80
+ if (!classMatch) return methods;
81
+
82
+ const classBody = classMatch[1];
83
+
84
+ // Match method declarations with JSDoc
85
+ const methodPattern =
86
+ /\/\*\*\s*([^*]*(?:\*(?!\/)[^*]*)*)\*\/\s*async\s+(\w+)\s*\(\s*c\s*:\s*Context\s*(?:,\s*([^)]+))?\s*\)/g;
87
+
88
+ let match;
89
+ while ((match = methodPattern.exec(classBody)) !== null) {
90
+ const [, jsdoc, methodName, paramsStr] = match;
91
+
92
+ // Skip private methods and constructor
93
+ if (methodName.startsWith("_") || methodName === "constructor") {
94
+ continue;
95
+ }
96
+
97
+ const description =
98
+ jsdoc
99
+ .split("\n")
100
+ .map((line) => line.replace(/^\s*\*?\s*/, "").trim())
101
+ .filter((line) => line && !line.startsWith("@"))[0] || "";
102
+
103
+ const params = parseParams(paramsStr);
104
+ methods.set(methodName, { description, params });
105
+ }
106
+
107
+ return methods;
108
+ }
109
+
110
+ // Parse parameter string into ParamMeta array
111
+ function parseParams(paramsStr: string | undefined): ParamMeta[] {
112
+ const params: ParamMeta[] = [];
113
+ if (!paramsStr) return params;
114
+
115
+ // Check for rest parameter first: ...name: type
116
+ const restMatch = paramsStr.match(/\.\.\.(\w+)\s*:\s*(\w+\[\]|\w+)/);
117
+ if (restMatch) {
118
+ const [, name, rawType] = restMatch;
119
+ params.push({
120
+ name,
121
+ type: rawType.endsWith("[]") ? "array" : "string",
122
+ required: false,
123
+ isRest: true,
124
+ });
125
+ return params;
126
+ }
127
+
128
+ const paramPattern =
129
+ /(\w+)\s*:\s*(\w+\[\]|Record<[^>]+>|\{[^}]*\}|string|number|boolean|\w+)(?:\s*=\s*[^,)]+)?/g;
130
+ let paramMatch;
131
+
132
+ while ((paramMatch = paramPattern.exec(paramsStr)) !== null) {
133
+ const [fullMatch, name, rawType] = paramMatch;
134
+ const hasDefault = fullMatch.includes("=");
135
+
136
+ let type: ParamType;
137
+ if (rawType === "string") {
138
+ type = "string";
139
+ } else if (rawType === "number") {
140
+ type = "number";
141
+ } else if (rawType === "boolean") {
142
+ type = "boolean";
143
+ } else if (rawType.endsWith("[]")) {
144
+ type = "array";
145
+ } else {
146
+ type = "object";
147
+ }
148
+
149
+ params.push({ name, type, required: !hasDefault, isRest: false });
150
+ }
151
+
152
+ return params;
153
+ }
154
+
155
+ // Discover all tasks including namespaced ones
156
+ function discoverAllTasks(source: string): DiscoveredTasks {
157
+ const root = extractMethodsFromClass(source, "Tasks");
158
+ const namespaced = new Map<string, Map<string, TaskMeta>>();
159
+ const classDoc = extractClassDoc(source);
160
+
161
+ // Find namespace assignments in Tasks class: propertyName = new ClassName()
162
+ const nsPattern = /(\w+)\s*=\s*new\s+(\w+)\s*\(\s*\)/g;
163
+ let nsMatch;
164
+
165
+ while ((nsMatch = nsPattern.exec(source)) !== null) {
166
+ const [, propName, className] = nsMatch;
167
+
168
+ // Skip private namespaces
169
+ if (propName.startsWith("_")) continue;
170
+
171
+ const nsMethods = extractMethodsFromClass(source, className);
172
+ if (nsMethods.size > 0) {
173
+ namespaced.set(propName, nsMethods);
174
+ }
175
+ }
176
+
177
+ return { root, namespaced, classDoc };
178
+ }
179
+
180
+ // Parse TypeScript source to extract method signatures and types (legacy, for compatibility)
181
+ async function extractTaskMeta(source: string): Promise<Map<string, TaskMeta>> {
182
+ const { root, namespaced } = discoverAllTasks(source);
183
+
184
+ // Combine root and namespaced for backward compat
185
+ const all = new Map(root);
186
+ for (const [ns, methods] of namespaced) {
187
+ for (const [method, meta] of methods) {
188
+ all.set(method, meta); // This flattens - we'll fix in main()
189
+ }
190
+ }
191
+
192
+ return all;
193
+ }
194
+
195
+ // Convert CLI arg to typed value
196
+ function coerceArg(value: string, type: ParamType): unknown {
197
+ switch (type) {
198
+ case "number": {
199
+ if (value === "") {
200
+ throw new Error(`Expected number, got ""`);
201
+ }
202
+ const n = Number(value);
203
+ if (Number.isNaN(n)) {
204
+ throw new Error(`Expected number, got "${value}"`);
205
+ }
206
+ return n;
207
+ }
208
+ case "boolean":
209
+ if (value === "true" || value === "1") return true;
210
+ if (value === "false" || value === "0") return false;
211
+ throw new Error(`Expected boolean, got "${value}"`);
212
+ case "object":
213
+ case "array": {
214
+ try {
215
+ const parsed = JSON.parse(value);
216
+ if (type === "array" && !Array.isArray(parsed)) {
217
+ throw new Error(`Expected array, got ${typeof parsed}`);
218
+ }
219
+ if (
220
+ type === "object" &&
221
+ (typeof parsed !== "object" ||
222
+ Array.isArray(parsed) ||
223
+ parsed === null)
224
+ ) {
225
+ throw new Error(
226
+ `Expected object, got ${Array.isArray(parsed) ? "array" : typeof parsed}`,
227
+ );
228
+ }
229
+ return parsed;
230
+ } catch (e) {
231
+ if (e instanceof SyntaxError) {
232
+ throw new Error(`Invalid JSON: ${e.message}`);
233
+ }
234
+ throw e;
235
+ }
236
+ }
237
+ case "string":
238
+ default:
239
+ return value;
240
+ }
241
+ }
242
+
243
+ // Format param for help display
244
+ function formatParam(param: ParamMeta): string {
245
+ if (param.isRest) {
246
+ return `[${param.name}...]`;
247
+ }
248
+ return param.required ? `<${param.name}>` : `[${param.name}]`;
249
+ }
250
+
251
+ // Display help for a specific task
252
+ function showTaskHelp(command: string, meta: TaskMeta): void {
253
+ const paramStr = meta.params.map(formatParam).join(" ");
254
+ const signature = paramStr ? `${command} ${paramStr}` : command;
255
+
256
+ console.log(`Usage: invt ${signature}\n`);
257
+
258
+ if (meta.description) {
259
+ console.log(`${meta.description}\n`);
260
+ }
261
+
262
+ if (meta.params.length > 0) {
263
+ console.log("Arguments:");
264
+ for (const param of meta.params) {
265
+ const reqStr = param.required ? "(required)" : "(optional)";
266
+ const typeStr = param.isRest ? `${param.type}...` : param.type;
267
+ console.log(` ${param.name.padEnd(15)} ${typeStr.padEnd(10)} ${reqStr}`);
268
+ }
269
+ }
270
+ }
271
+
272
+ // Main CLI entry point
273
+ async function main() {
274
+ const args = Bun.argv.slice(2);
275
+
276
+ // --version flag
277
+ if (args[0] === "--version") {
278
+ const pkgPath = new URL("../package.json", import.meta.url).pathname;
279
+ const pkg = await Bun.file(pkgPath).json();
280
+ console.log(pkg.version);
281
+ return;
282
+ }
283
+
284
+ // Find tasks.ts
285
+ const tasksPath = Bun.resolveSync("./tasks.ts", process.cwd());
286
+ const source = await Bun.file(tasksPath).text();
287
+
288
+ // Discover all tasks including namespaced
289
+ const discovered = discoverAllTasks(source);
290
+
291
+ // Import and instantiate Tasks class
292
+ const { Tasks } = await import(tasksPath);
293
+ const instance = new Tasks();
294
+ const context = new Context();
295
+
296
+ // No args or just help flag -> show general help
297
+ if (
298
+ args.length === 0 ||
299
+ (args.length === 1 && (args[0] === "-h" || args[0] === "--help"))
300
+ ) {
301
+ console.log("invoket — TypeScript task runner\n");
302
+
303
+ if (discovered.classDoc) {
304
+ console.log(`${discovered.classDoc}\n`);
305
+ }
306
+
307
+ console.log("Available tasks:\n");
308
+
309
+ // Root tasks
310
+ for (const [name, meta] of discovered.root) {
311
+ const paramStr = meta.params.map(formatParam).join(" ");
312
+ const signature = paramStr ? `${name} ${paramStr}` : name;
313
+ console.log(` ${signature}`);
314
+ }
315
+
316
+ // Namespaced tasks
317
+ for (const [ns, methods] of discovered.namespaced) {
318
+ console.log(`\n${ns}:`);
319
+ for (const [name, meta] of methods) {
320
+ const paramStr = meta.params.map(formatParam).join(" ");
321
+ const signature = paramStr
322
+ ? `${ns}:${name} ${paramStr}`
323
+ : `${ns}:${name}`;
324
+ console.log(` ${signature}`);
325
+ }
326
+ }
327
+
328
+ console.log("\nUsage: invt <task> [args...]");
329
+ console.log(" invt <task> -h Show help for a specific task");
330
+ return;
331
+ }
332
+
333
+ // List flag
334
+ if (args[0] === "-l" || args[0] === "--list") {
335
+ console.log("Available tasks:\n");
336
+
337
+ // Root tasks
338
+ for (const [name, meta] of discovered.root) {
339
+ const paramStr = meta.params.map(formatParam).join(" ");
340
+ const signature = paramStr ? `${name} ${paramStr}` : name;
341
+ console.log(` ${signature}`);
342
+ }
343
+
344
+ // Namespaced tasks
345
+ for (const [ns, methods] of discovered.namespaced) {
346
+ console.log(`\n${ns}:`);
347
+ for (const [name, meta] of methods) {
348
+ const paramStr = meta.params.map(formatParam).join(" ");
349
+ const signature = paramStr
350
+ ? `${ns}:${name} ${paramStr}`
351
+ : `${ns}:${name}`;
352
+ console.log(` ${signature}`);
353
+ }
354
+ }
355
+ return;
356
+ }
357
+
358
+ const command = args[0];
359
+ const taskArgs = args.slice(1);
360
+
361
+ // Check if asking for task-specific help: invt hello -h
362
+ const wantsTaskHelp = taskArgs.includes("-h") || taskArgs.includes("--help");
363
+
364
+ const { namespace, method: methodName } = parseCommand(command);
365
+
366
+ let meta: TaskMeta | undefined;
367
+ let method: Function | undefined;
368
+ let thisArg: any = instance;
369
+
370
+ if (namespace) {
371
+ // Validate namespace
372
+ if (namespace.startsWith("_")) {
373
+ console.error(`Cannot call private namespace "${namespace}"`);
374
+ process.exit(1);
375
+ }
376
+
377
+ // Validate method
378
+ if (methodName.startsWith("_")) {
379
+ console.error(`Cannot call private method "${methodName}"`);
380
+ process.exit(1);
381
+ }
382
+
383
+ const nsMethods = discovered.namespaced.get(namespace);
384
+ if (!nsMethods) {
385
+ console.error(`Unknown namespace: ${namespace}`);
386
+ process.exit(1);
387
+ }
388
+
389
+ meta = nsMethods.get(methodName);
390
+ if (!meta) {
391
+ console.error(`Unknown task: ${command}`);
392
+ console.error(
393
+ `Available in ${namespace}: ${[...nsMethods.keys()].join(", ")}`,
394
+ );
395
+ process.exit(1);
396
+ }
397
+
398
+ thisArg = instance[namespace];
399
+ method = thisArg[methodName];
400
+ } else {
401
+ // Root task
402
+ if (methodName.startsWith("_")) {
403
+ console.error(`Cannot call private method "${methodName}"`);
404
+ process.exit(1);
405
+ }
406
+
407
+ meta = discovered.root.get(methodName);
408
+ method = instance[methodName];
409
+
410
+ // If method exists at runtime but not in source (inherited), allow it
411
+ if (!meta && typeof method === "function") {
412
+ // Inherited method - no type info, treat all args as strings
413
+ meta = { description: "", params: [] };
414
+ } else if (!meta) {
415
+ console.error(`Unknown task: ${command}`);
416
+ const allTasks = [...discovered.root.keys()];
417
+ for (const [ns, methods] of discovered.namespaced) {
418
+ for (const m of methods.keys()) {
419
+ allTasks.push(`${ns}:${m}`);
420
+ }
421
+ }
422
+ console.error(`Available: ${allTasks.join(", ")}`);
423
+ process.exit(1);
424
+ }
425
+ }
426
+
427
+ if (typeof method !== "function") {
428
+ console.error(`Task "${command}" is not a function`);
429
+ process.exit(1);
430
+ }
431
+
432
+ // Show task-specific help if requested
433
+ if (wantsTaskHelp) {
434
+ showTaskHelp(command, meta);
435
+ return;
436
+ }
437
+
438
+ // Validate and coerce arguments
439
+ const coercedArgs: unknown[] = [];
440
+
441
+ for (let i = 0; i < meta.params.length; i++) {
442
+ const param = meta.params[i];
443
+
444
+ // Handle rest parameters - collect all remaining args and spread them
445
+ if (param.isRest) {
446
+ const restArgs = taskArgs.slice(i);
447
+ coercedArgs.push(...restArgs);
448
+ break;
449
+ }
450
+
451
+ const arg = taskArgs[i];
452
+
453
+ if (arg === undefined) {
454
+ if (param.required) {
455
+ console.error(
456
+ `Missing required argument: <${param.name}> (${param.type})`,
457
+ );
458
+ const paramStr = meta.params.map(formatParam).join(" ");
459
+ console.error(`Usage: ${command} ${paramStr}`);
460
+ process.exit(1);
461
+ }
462
+ // Optional param not provided, don't push (use default)
463
+ break;
464
+ }
465
+
466
+ try {
467
+ coercedArgs.push(coerceArg(arg, param.type));
468
+ } catch (e) {
469
+ console.error(`Argument "${param.name}": ${(e as Error).message}`);
470
+ process.exit(1);
471
+ }
472
+ }
473
+
474
+ // Execute task
475
+ try {
476
+ await method.call(thisArg, context, ...coercedArgs);
477
+ } catch (e) {
478
+ console.error(`Error running "${command}": ${(e as Error).message}`);
479
+ process.exit(1);
480
+ }
481
+ }
482
+
483
+ main();
package/src/context.ts ADDED
@@ -0,0 +1,88 @@
1
+ import { $ } from "bun";
2
+ import { resolve } from "path";
3
+
4
+ export interface RunResult {
5
+ stdout: string;
6
+ stderr: string;
7
+ code: number;
8
+ ok: boolean;
9
+ failed: boolean;
10
+ }
11
+
12
+ export interface RunOptions {
13
+ echo?: boolean;
14
+ warn?: boolean;
15
+ hide?: boolean;
16
+ cwd?: string;
17
+ }
18
+
19
+ export class Context {
20
+ cwd: string;
21
+ private options: RunOptions;
22
+
23
+ constructor(options: RunOptions = {}) {
24
+ this.cwd = options.cwd ?? process.cwd();
25
+ this.options = options;
26
+ }
27
+
28
+ get pwd(): string {
29
+ return this.cwd;
30
+ }
31
+
32
+ get config(): RunOptions {
33
+ return { ...this.options };
34
+ }
35
+
36
+ // Alias for run() - explicit local execution
37
+ local = this.run.bind(this);
38
+
39
+ async run(command: string, options?: RunOptions): Promise<RunResult> {
40
+ const opts = { ...this.options, ...options };
41
+
42
+ if (opts.echo) {
43
+ console.log(`$ ${command}`);
44
+ }
45
+
46
+ const result = await $`sh -c ${command}`
47
+ .cwd(opts.cwd ?? this.cwd)
48
+ .nothrow()
49
+ .quiet();
50
+
51
+ const runResult: RunResult = {
52
+ stdout: result.stdout.toString(),
53
+ stderr: result.stderr.toString(),
54
+ code: result.exitCode,
55
+ ok: result.exitCode === 0,
56
+ failed: result.exitCode !== 0,
57
+ };
58
+
59
+ if (!opts.warn && runResult.failed) {
60
+ const error = new Error(
61
+ `Command failed with exit code ${runResult.code}: ${command}`,
62
+ );
63
+ (error as any).result = runResult;
64
+ throw error;
65
+ }
66
+
67
+ if (!opts.hide) {
68
+ if (runResult.stdout) process.stdout.write(runResult.stdout);
69
+ if (runResult.stderr) process.stderr.write(runResult.stderr);
70
+ }
71
+
72
+ return runResult;
73
+ }
74
+
75
+ async sudo(command: string, options?: RunOptions): Promise<RunResult> {
76
+ return this.run(`sudo ${command}`, options);
77
+ }
78
+
79
+ async *cd(directory: string): AsyncGenerator<void, void, unknown> {
80
+ const previous = this.cwd;
81
+ this.cwd = resolve(this.cwd, directory);
82
+ try {
83
+ yield;
84
+ } finally {
85
+ this.cwd = previous;
86
+ }
87
+ }
88
+ }