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 +208 -0
- package/package.json +33 -0
- package/src/cli.ts +483 -0
- package/src/context.ts +88 -0
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
|
+
}
|