@mcnekoneko/hookstream-cli 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/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/api.d.ts +52 -0
- package/dist/api.js +212 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +45 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +225 -0
- package/dist/types.d.ts +23 -0
- package/dist/types.js +1 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mc-nekoneko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# hookstream CLI
|
|
2
|
+
|
|
3
|
+
CLI for managing [hookstream](https://github.com/mc-nekoneko/hookstream) channels.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @mcnekoneko/hookstream-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
After installation, the `hookstream` command is available anywhere in your terminal.
|
|
12
|
+
|
|
13
|
+
To uninstall:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm uninstall -g @mcnekoneko/hookstream-cli
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Configuration
|
|
20
|
+
|
|
21
|
+
### Profile-based (recommended)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Configure the default profile
|
|
25
|
+
hookstream configure
|
|
26
|
+
|
|
27
|
+
# Configure a named profile
|
|
28
|
+
hookstream configure --profile production
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Credentials are saved to `~/.config/hookstream-cli/config.json`.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Use default profile
|
|
35
|
+
hookstream channels list
|
|
36
|
+
|
|
37
|
+
# Use named profile
|
|
38
|
+
hookstream --profile production channels list
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Environment variables
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
export HOOKSTREAM_URL=https://your-worker.workers.dev
|
|
45
|
+
export HOOKSTREAM_ADMIN_KEY=your-admin-key
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Inline flags
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
hookstream --url https://your-worker.workers.dev --admin-key your-key channels list
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Priority:** inline flags > environment variables > profile config
|
|
55
|
+
|
|
56
|
+
## Commands
|
|
57
|
+
|
|
58
|
+
### `configure`
|
|
59
|
+
|
|
60
|
+
Save Worker URL and admin key to a config profile.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
hookstream configure # default profile
|
|
64
|
+
hookstream configure --profile staging # named profile
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### `channels list`
|
|
68
|
+
|
|
69
|
+
List all channels.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
hookstream channels list
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### `channels create`
|
|
76
|
+
|
|
77
|
+
Create a channel.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# Minimal (no auth)
|
|
81
|
+
hookstream channels create --id my-channel
|
|
82
|
+
|
|
83
|
+
# With SSE token
|
|
84
|
+
hookstream channels create --id my-channel --token sse-token
|
|
85
|
+
|
|
86
|
+
# With event type header
|
|
87
|
+
hookstream channels create --id my-channel --event-header X-Event-Type
|
|
88
|
+
|
|
89
|
+
# With signature verification
|
|
90
|
+
hookstream channels create \
|
|
91
|
+
--id my-channel \
|
|
92
|
+
--token sse-token \
|
|
93
|
+
--event-header X-Event-Type \
|
|
94
|
+
--max-history 100 \
|
|
95
|
+
--sig-header X-Webhook-Signature \
|
|
96
|
+
--sig-algorithm hmac-sha256-hex \
|
|
97
|
+
--sig-secret my-secret \
|
|
98
|
+
--sig-prefix sha256=
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Options:**
|
|
102
|
+
|
|
103
|
+
| Flag | Description | Default |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `--id <id>` | Channel ID (`a-z0-9_-`, max 64 chars) | required |
|
|
106
|
+
| `--token <token>` | Bearer token for SSE access. If omitted, SSE is public. | — |
|
|
107
|
+
| `--event-header <header>` | Header to read event type from. If omitted, all events are `"message"`. | — |
|
|
108
|
+
| `--max-history <n>` | Ring buffer size for reconnect replay | `50` |
|
|
109
|
+
| `--sig-header <header>` | Signature header name | — |
|
|
110
|
+
| `--sig-algorithm <alg>` | `hmac-sha256-hex` or `hmac-sha256-base64` | — |
|
|
111
|
+
| `--sig-secret <secret>` | HMAC secret | — |
|
|
112
|
+
| `--sig-prefix <prefix>` | Prefix to strip before comparing (e.g. `sha256=`) | — |
|
|
113
|
+
|
|
114
|
+
### `channels delete <id>`
|
|
115
|
+
|
|
116
|
+
Delete a channel.
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
hookstream channels delete my-channel
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### `channels test <id>`
|
|
123
|
+
|
|
124
|
+
Open an SSE subscription, send a test webhook, and verify it is received.
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
hookstream channels test my-channel
|
|
128
|
+
hookstream channels test my-channel --token sse-token --timeout 10
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### `channels subscribe <id>`
|
|
132
|
+
|
|
133
|
+
Subscribe to channel events in real time.
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
hookstream channels subscribe my-channel
|
|
137
|
+
hookstream channels subscribe my-channel --token sse-token
|
|
138
|
+
hookstream channels subscribe my-channel --json | jq .
|
|
139
|
+
hookstream channels subscribe my-channel --last-event-id 42
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Releasing
|
|
143
|
+
|
|
144
|
+
The npm publish workflow lives at `.github/workflows/publish-cli.yml`.
|
|
145
|
+
|
|
146
|
+
Publishing flow:
|
|
147
|
+
|
|
148
|
+
1. Bump `cli/package.json` version
|
|
149
|
+
2. Commit and push to GitHub
|
|
150
|
+
3. Create and push a tag in the form `cli-vX.Y.Z`
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
git tag cli-v0.1.0
|
|
154
|
+
git push origin cli-v0.1.0
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
GitHub Actions will build the CLI and publish it to npm.
|
|
158
|
+
|
|
159
|
+
## Development
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
cd cli
|
|
163
|
+
npm install
|
|
164
|
+
npm run dev -- channels list # run without building
|
|
165
|
+
npm run lint
|
|
166
|
+
npm run typecheck
|
|
167
|
+
npm run build
|
|
168
|
+
```
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ChannelConfig, RelayEvent, SignatureAlgorithm } from "./types.js";
|
|
2
|
+
export type CreateChannelInput = {
|
|
3
|
+
id: string;
|
|
4
|
+
token?: string;
|
|
5
|
+
eventHeader?: string;
|
|
6
|
+
maxHistory?: number;
|
|
7
|
+
signature?: {
|
|
8
|
+
header: string;
|
|
9
|
+
algorithm: SignatureAlgorithm;
|
|
10
|
+
secret: string;
|
|
11
|
+
prefix?: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
export type TestResult = {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
channelId: string;
|
|
17
|
+
webhookStatus: number;
|
|
18
|
+
eventReceived: boolean;
|
|
19
|
+
eventId?: string;
|
|
20
|
+
roundTripMs?: number;
|
|
21
|
+
error?: string;
|
|
22
|
+
};
|
|
23
|
+
export declare class HookstreamClient {
|
|
24
|
+
private readonly url;
|
|
25
|
+
private readonly adminKey;
|
|
26
|
+
constructor(url: string, adminKey: string);
|
|
27
|
+
private headers;
|
|
28
|
+
listChannels(): Promise<ChannelConfig[]>;
|
|
29
|
+
createChannel(input: CreateChannelInput): Promise<ChannelConfig>;
|
|
30
|
+
deleteChannel(id: string): Promise<{
|
|
31
|
+
deleted: string;
|
|
32
|
+
}>;
|
|
33
|
+
/**
|
|
34
|
+
* End-to-end test: subscribe to SSE, send a test webhook, verify delivery.
|
|
35
|
+
*/
|
|
36
|
+
testChannel(channelId: string, opts?: {
|
|
37
|
+
token?: string;
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
}): Promise<TestResult>;
|
|
40
|
+
/**
|
|
41
|
+
* Subscribe to a channel's SSE stream. Calls `onEvent` for each received
|
|
42
|
+
* event. Returns when the stream closes or `signal` is aborted.
|
|
43
|
+
*/
|
|
44
|
+
subscribe(channelId: string, opts: {
|
|
45
|
+
token?: string;
|
|
46
|
+
lastEventId?: string;
|
|
47
|
+
onEvent: (event: RelayEvent) => void;
|
|
48
|
+
onKeepalive?: () => void;
|
|
49
|
+
onError?: (error: Error) => void;
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
}): Promise<void>;
|
|
52
|
+
}
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
export class HookstreamClient {
|
|
2
|
+
url;
|
|
3
|
+
adminKey;
|
|
4
|
+
constructor(url, adminKey) {
|
|
5
|
+
this.url = url;
|
|
6
|
+
this.adminKey = adminKey;
|
|
7
|
+
}
|
|
8
|
+
headers() {
|
|
9
|
+
return {
|
|
10
|
+
Authorization: `Bearer ${this.adminKey}`,
|
|
11
|
+
"Content-Type": "application/json",
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
async listChannels() {
|
|
15
|
+
const res = await fetch(`${this.url}/admin/channels`, {
|
|
16
|
+
headers: this.headers(),
|
|
17
|
+
});
|
|
18
|
+
const data = (await res.json());
|
|
19
|
+
if (!res.ok)
|
|
20
|
+
throw new Error(data.error);
|
|
21
|
+
return data;
|
|
22
|
+
}
|
|
23
|
+
async createChannel(input) {
|
|
24
|
+
const res = await fetch(`${this.url}/admin/channels`, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: this.headers(),
|
|
27
|
+
body: JSON.stringify(input),
|
|
28
|
+
});
|
|
29
|
+
const data = (await res.json());
|
|
30
|
+
if (!res.ok)
|
|
31
|
+
throw new Error(data.error);
|
|
32
|
+
return data;
|
|
33
|
+
}
|
|
34
|
+
async deleteChannel(id) {
|
|
35
|
+
const res = await fetch(`${this.url}/admin/channels/${id}`, {
|
|
36
|
+
method: "DELETE",
|
|
37
|
+
headers: this.headers(),
|
|
38
|
+
});
|
|
39
|
+
const data = (await res.json());
|
|
40
|
+
if (!res.ok)
|
|
41
|
+
throw new Error(data.error);
|
|
42
|
+
return data;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* End-to-end test: subscribe to SSE, send a test webhook, verify delivery.
|
|
46
|
+
*/
|
|
47
|
+
async testChannel(channelId, opts = {}) {
|
|
48
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
49
|
+
const testPayload = {
|
|
50
|
+
_hookstream_test: true,
|
|
51
|
+
ts: Date.now(),
|
|
52
|
+
nonce: crypto.randomUUID(),
|
|
53
|
+
};
|
|
54
|
+
const sseUrl = `${this.url}/${channelId}/events`;
|
|
55
|
+
const webhookUrl = `${this.url}/${channelId}`;
|
|
56
|
+
// 1. Connect to SSE
|
|
57
|
+
const sseHeaders = {};
|
|
58
|
+
if (opts.token)
|
|
59
|
+
sseHeaders.Authorization = `Bearer ${opts.token}`;
|
|
60
|
+
const sseRes = await fetch(sseUrl, { headers: sseHeaders });
|
|
61
|
+
if (!sseRes.ok) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
channelId,
|
|
65
|
+
webhookStatus: 0,
|
|
66
|
+
eventReceived: false,
|
|
67
|
+
error: `SSE connect failed: HTTP ${sseRes.status}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const reader = sseRes.body?.getReader();
|
|
71
|
+
if (!reader) {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
channelId,
|
|
75
|
+
webhookStatus: 0,
|
|
76
|
+
eventReceived: false,
|
|
77
|
+
error: "SSE response has no readable body",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const decoder = new TextDecoder();
|
|
81
|
+
const startMs = Date.now();
|
|
82
|
+
// 2. Send test webhook
|
|
83
|
+
const webhookRes = await fetch(webhookUrl, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/json" },
|
|
86
|
+
body: JSON.stringify(testPayload),
|
|
87
|
+
});
|
|
88
|
+
if (!webhookRes.ok) {
|
|
89
|
+
reader.cancel().catch(() => { });
|
|
90
|
+
const body = await webhookRes.text().catch(() => "");
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
channelId,
|
|
94
|
+
webhookStatus: webhookRes.status,
|
|
95
|
+
eventReceived: false,
|
|
96
|
+
error: `Webhook POST failed: HTTP ${webhookRes.status} ${body}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const webhookData = (await webhookRes.json());
|
|
100
|
+
// 3. Read SSE stream until we see our test event or timeout
|
|
101
|
+
let buffer = "";
|
|
102
|
+
const deadline = Date.now() + timeoutMs;
|
|
103
|
+
while (Date.now() < deadline) {
|
|
104
|
+
const remaining = deadline - Date.now();
|
|
105
|
+
const readPromise = reader.read();
|
|
106
|
+
const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve({ done: true, value: undefined }), remaining));
|
|
107
|
+
const { done, value } = await Promise.race([readPromise, timeoutPromise]);
|
|
108
|
+
if (done && !value)
|
|
109
|
+
break;
|
|
110
|
+
if (value)
|
|
111
|
+
buffer += decoder.decode(value, { stream: true });
|
|
112
|
+
// Parse SSE frames from buffer
|
|
113
|
+
const frames = buffer.split("\n\n");
|
|
114
|
+
buffer = frames.pop() ?? "";
|
|
115
|
+
for (const frame of frames) {
|
|
116
|
+
const dataLine = frame.split("\n").find((l) => l.startsWith("data: "));
|
|
117
|
+
if (!dataLine)
|
|
118
|
+
continue;
|
|
119
|
+
try {
|
|
120
|
+
const event = JSON.parse(dataLine.slice(6));
|
|
121
|
+
if (event.id === webhookData.id) {
|
|
122
|
+
const roundTripMs = Date.now() - startMs;
|
|
123
|
+
reader.cancel().catch(() => { });
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
channelId,
|
|
127
|
+
webhookStatus: webhookRes.status,
|
|
128
|
+
eventReceived: true,
|
|
129
|
+
eventId: event.id,
|
|
130
|
+
roundTripMs,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// not our event, continue
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
reader.cancel().catch(() => { });
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
channelId,
|
|
143
|
+
webhookStatus: webhookRes.status,
|
|
144
|
+
eventReceived: false,
|
|
145
|
+
error: `Timeout: event not received within ${timeoutMs}ms`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Subscribe to a channel's SSE stream. Calls `onEvent` for each received
|
|
150
|
+
* event. Returns when the stream closes or `signal` is aborted.
|
|
151
|
+
*/
|
|
152
|
+
async subscribe(channelId, opts) {
|
|
153
|
+
const sseUrl = `${this.url}/${channelId}/events`;
|
|
154
|
+
const headers = {};
|
|
155
|
+
if (opts.token)
|
|
156
|
+
headers.Authorization = `Bearer ${opts.token}`;
|
|
157
|
+
if (opts.lastEventId)
|
|
158
|
+
headers["Last-Event-ID"] = opts.lastEventId;
|
|
159
|
+
const res = await fetch(sseUrl, { headers, signal: opts.signal });
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
const body = await res.text().catch(() => "");
|
|
162
|
+
throw new Error(`SSE connect failed: HTTP ${res.status} ${body}`);
|
|
163
|
+
}
|
|
164
|
+
const reader = res.body?.getReader();
|
|
165
|
+
if (!reader)
|
|
166
|
+
throw new Error("SSE response has no readable body");
|
|
167
|
+
const decoder = new TextDecoder();
|
|
168
|
+
let buffer = "";
|
|
169
|
+
try {
|
|
170
|
+
while (true) {
|
|
171
|
+
if (opts.signal?.aborted)
|
|
172
|
+
break;
|
|
173
|
+
const { done, value } = await reader.read();
|
|
174
|
+
if (done)
|
|
175
|
+
break;
|
|
176
|
+
buffer += decoder.decode(value, { stream: true });
|
|
177
|
+
const frames = buffer.split("\n\n");
|
|
178
|
+
buffer = frames.pop() ?? "";
|
|
179
|
+
for (const frame of frames) {
|
|
180
|
+
// Keepalive
|
|
181
|
+
if (frame.trim() === ":keepalive") {
|
|
182
|
+
opts.onKeepalive?.();
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const dataLine = frame
|
|
186
|
+
.split("\n")
|
|
187
|
+
.find((l) => l.startsWith("data: "));
|
|
188
|
+
if (!dataLine)
|
|
189
|
+
continue;
|
|
190
|
+
try {
|
|
191
|
+
const event = JSON.parse(dataLine.slice(6));
|
|
192
|
+
opts.onEvent(event);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// skip malformed frames
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
if (opts.signal?.aborted)
|
|
202
|
+
return;
|
|
203
|
+
if (opts.onError)
|
|
204
|
+
opts.onError(err);
|
|
205
|
+
else
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
reader.cancel().catch(() => { });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type Profile = {
|
|
2
|
+
url: string;
|
|
3
|
+
adminKey: string;
|
|
4
|
+
};
|
|
5
|
+
export type Config = Record<string, Profile>;
|
|
6
|
+
export declare function loadConfig(): Config;
|
|
7
|
+
export declare function saveConfig(config: Config): void;
|
|
8
|
+
export declare function getProfile(name?: string): Profile | undefined;
|
|
9
|
+
export declare function runConfigure(profileName?: string): Promise<void>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
const CONFIG_DIR = join(homedir(), ".config", "hookstream-cli");
|
|
6
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
7
|
+
export function loadConfig() {
|
|
8
|
+
if (!existsSync(CONFIG_FILE))
|
|
9
|
+
return {};
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function saveConfig(config) {
|
|
18
|
+
if (!existsSync(CONFIG_DIR))
|
|
19
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
20
|
+
writeFileSync(CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
|
21
|
+
}
|
|
22
|
+
export function getProfile(name = "default") {
|
|
23
|
+
return loadConfig()[name];
|
|
24
|
+
}
|
|
25
|
+
export async function runConfigure(profileName = "default") {
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
const existing = config[profileName];
|
|
28
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
29
|
+
console.log(profileName === "default"
|
|
30
|
+
? "Configure hookstream CLI (default profile)"
|
|
31
|
+
: `Configure hookstream CLI (profile: ${profileName})`);
|
|
32
|
+
const url = await rl.question(`Worker URL${existing?.url ? ` [${existing.url}]` : ""}: `);
|
|
33
|
+
const adminKey = await rl.question(`Admin key${existing?.adminKey ? " [****]" : ""}: `);
|
|
34
|
+
rl.close();
|
|
35
|
+
config[profileName] = {
|
|
36
|
+
url: url.trim() || existing?.url || "",
|
|
37
|
+
adminKey: adminKey.trim() || existing?.adminKey || "",
|
|
38
|
+
};
|
|
39
|
+
if (!config[profileName].url || !config[profileName].adminKey) {
|
|
40
|
+
console.error("Error: URL and admin key are required.");
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
saveConfig(config);
|
|
44
|
+
console.log(`\nSaved profile '${profileName}' to ${CONFIG_FILE}`);
|
|
45
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
3
|
+
import { HookstreamClient } from "./api.js";
|
|
4
|
+
import { getProfile, runConfigure } from "./config.js";
|
|
5
|
+
const program = new Command();
|
|
6
|
+
program
|
|
7
|
+
.name("hookstream")
|
|
8
|
+
.description("CLI for managing hookstream channels")
|
|
9
|
+
.option("-p, --profile <name>", "Config profile to use", "default")
|
|
10
|
+
.option("-u, --url <url>", "Worker URL (overrides profile)")
|
|
11
|
+
.option("-k, --admin-key <key>", "Admin key (overrides profile)");
|
|
12
|
+
function getClient() {
|
|
13
|
+
const opts = program.opts();
|
|
14
|
+
// Priority: flag > env var > profile
|
|
15
|
+
const url = opts.url ?? process.env.HOOKSTREAM_URL ?? getProfile(opts.profile)?.url;
|
|
16
|
+
const adminKey = opts.adminKey ??
|
|
17
|
+
process.env.HOOKSTREAM_ADMIN_KEY ??
|
|
18
|
+
getProfile(opts.profile)?.adminKey;
|
|
19
|
+
if (!url) {
|
|
20
|
+
console.error(`Error: Worker URL is required.\n Run: hookstream configure --profile ${opts.profile}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
if (!adminKey) {
|
|
24
|
+
console.error(`Error: Admin key is required.\n Run: hookstream configure --profile ${opts.profile}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
return new HookstreamClient(url.replace(/\/$/, ""), adminKey);
|
|
28
|
+
}
|
|
29
|
+
// ─── configure ───────────────────────────────────────────────────────────────
|
|
30
|
+
program
|
|
31
|
+
.command("configure")
|
|
32
|
+
.description("Save Worker URL and admin key to a config profile")
|
|
33
|
+
.action(async () => {
|
|
34
|
+
const profileName = program.opts().profile;
|
|
35
|
+
await runConfigure(profileName);
|
|
36
|
+
});
|
|
37
|
+
// ─── channels ────────────────────────────────────────────────────────────────
|
|
38
|
+
const channels = program.command("channels").description("Manage channels");
|
|
39
|
+
channels
|
|
40
|
+
.command("list")
|
|
41
|
+
.description("List all channels")
|
|
42
|
+
.action(async () => {
|
|
43
|
+
try {
|
|
44
|
+
const client = getClient();
|
|
45
|
+
const list = await client.listChannels();
|
|
46
|
+
if (list.length === 0) {
|
|
47
|
+
console.log("No channels found.");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
for (const ch of list) {
|
|
51
|
+
const sig = ch.signature
|
|
52
|
+
? ` sig:${ch.signature.algorithm}(${ch.signature.header})`
|
|
53
|
+
: "";
|
|
54
|
+
const tok = ch.token ? " token:✓" : "";
|
|
55
|
+
const ev = ch.eventHeader ? ` event:${ch.eventHeader}` : "";
|
|
56
|
+
console.log(` ${ch.id.padEnd(24)} maxHistory:${ch.maxHistory}${sig}${tok}${ev} [${ch.createdAt}]`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
console.error(`Error: ${err.message}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
channels
|
|
65
|
+
.command("create")
|
|
66
|
+
.description("Create a channel")
|
|
67
|
+
.requiredOption("--id <id>", "Channel ID (a-z0-9_-, max 64)")
|
|
68
|
+
.option("--token <token>", "Bearer token for SSE access")
|
|
69
|
+
.option("--event-header <header>", "Header to read event type from")
|
|
70
|
+
.option("--max-history <n>", "Ring buffer size for reconnect replay", (v) => {
|
|
71
|
+
const n = parseInt(v, 10);
|
|
72
|
+
if (Number.isNaN(n) || n < 1)
|
|
73
|
+
throw new InvalidArgumentError("Must be a positive integer.");
|
|
74
|
+
return n;
|
|
75
|
+
}, 50)
|
|
76
|
+
.option("--sig-header <header>", "Signature header name")
|
|
77
|
+
.option("--sig-algorithm <alg>", "Signature algorithm (hmac-sha256-hex|hmac-sha256-base64)")
|
|
78
|
+
.option("--sig-secret <secret>", "HMAC secret")
|
|
79
|
+
.option("--sig-prefix <prefix>", "Prefix to strip before comparing (e.g. sha256=)")
|
|
80
|
+
.action(async (opts) => {
|
|
81
|
+
try {
|
|
82
|
+
const client = getClient();
|
|
83
|
+
// Build signature config if provided
|
|
84
|
+
let signature;
|
|
85
|
+
if (opts.sigHeader || opts.sigAlgorithm || opts.sigSecret) {
|
|
86
|
+
if (!opts.sigHeader || !opts.sigAlgorithm || !opts.sigSecret) {
|
|
87
|
+
console.error("Error: --sig-header, --sig-algorithm, and --sig-secret are all required when configuring signature verification.");
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
if (opts.sigAlgorithm !== "hmac-sha256-hex" &&
|
|
91
|
+
opts.sigAlgorithm !== "hmac-sha256-base64") {
|
|
92
|
+
console.error("Error: --sig-algorithm must be 'hmac-sha256-hex' or 'hmac-sha256-base64'.");
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
signature = {
|
|
96
|
+
header: opts.sigHeader,
|
|
97
|
+
algorithm: opts.sigAlgorithm,
|
|
98
|
+
secret: opts.sigSecret,
|
|
99
|
+
prefix: opts.sigPrefix,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const ch = await client.createChannel({
|
|
103
|
+
id: opts.id,
|
|
104
|
+
token: opts.token,
|
|
105
|
+
eventHeader: opts.eventHeader,
|
|
106
|
+
maxHistory: opts.maxHistory,
|
|
107
|
+
signature,
|
|
108
|
+
});
|
|
109
|
+
console.log(`Channel created: ${ch.id}`);
|
|
110
|
+
console.log(JSON.stringify(ch, null, 2));
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
console.error(`Error: ${err.message}`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
channels
|
|
118
|
+
.command("delete <id>")
|
|
119
|
+
.description("Delete a channel")
|
|
120
|
+
.action(async (id) => {
|
|
121
|
+
try {
|
|
122
|
+
const client = getClient();
|
|
123
|
+
const result = await client.deleteChannel(id);
|
|
124
|
+
console.log(`Deleted: ${result.deleted}`);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
console.error(`Error: ${err.message}`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
channels
|
|
132
|
+
.command("subscribe <id>")
|
|
133
|
+
.description("Subscribe to a channel's SSE stream and print events in real-time")
|
|
134
|
+
.option("--token <token>", "Bearer token for SSE (if channel requires auth)")
|
|
135
|
+
.option("--last-event-id <id>", "Resume from a specific event ID")
|
|
136
|
+
.option("--json", "Output raw JSON per event (one line per event)")
|
|
137
|
+
.action(async (id, opts) => {
|
|
138
|
+
const client = getClient();
|
|
139
|
+
const baseUrl = program.opts().url ?? getProfile(program.opts().profile)?.url ?? "";
|
|
140
|
+
if (!opts.json) {
|
|
141
|
+
console.log(`Subscribing to: ${baseUrl}/${id}/events`);
|
|
142
|
+
if (opts.lastEventId)
|
|
143
|
+
console.log(` Last-Event-ID: ${opts.lastEventId}`);
|
|
144
|
+
console.log(" Press Ctrl+C to stop\n");
|
|
145
|
+
}
|
|
146
|
+
const ac = new AbortController();
|
|
147
|
+
process.on("SIGINT", () => ac.abort());
|
|
148
|
+
process.on("SIGTERM", () => ac.abort());
|
|
149
|
+
try {
|
|
150
|
+
await client.subscribe(id, {
|
|
151
|
+
token: opts.token,
|
|
152
|
+
lastEventId: opts.lastEventId,
|
|
153
|
+
signal: ac.signal,
|
|
154
|
+
onEvent: (event) => {
|
|
155
|
+
if (opts.json) {
|
|
156
|
+
console.log(JSON.stringify(event));
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const time = new Date(event.timestamp).toLocaleTimeString();
|
|
160
|
+
console.log(`[${time}] event=${event.event} id=${event.id}`);
|
|
161
|
+
console.log(` ${JSON.stringify(event.payload, null, 2).split("\n").join("\n ")}`);
|
|
162
|
+
console.log();
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
onKeepalive: () => {
|
|
166
|
+
if (!opts.json) {
|
|
167
|
+
process.stdout.write(".");
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
if (!opts.json)
|
|
172
|
+
console.log("\nStream closed.");
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
if (ac.signal.aborted) {
|
|
176
|
+
if (!opts.json)
|
|
177
|
+
console.log("\nDisconnected.");
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
console.error(`Error: ${err.message}`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
channels
|
|
186
|
+
.command("test <id>")
|
|
187
|
+
.description("End-to-end test: subscribe SSE → send test webhook → verify delivery")
|
|
188
|
+
.option("--token <token>", "Bearer token for SSE (if channel requires auth)")
|
|
189
|
+
.option("--timeout <ms>", "Timeout in milliseconds", (v) => {
|
|
190
|
+
const n = parseInt(v, 10);
|
|
191
|
+
if (Number.isNaN(n) || n < 1)
|
|
192
|
+
throw new InvalidArgumentError("Must be a positive integer.");
|
|
193
|
+
return n;
|
|
194
|
+
}, 10_000)
|
|
195
|
+
.action(async (id, opts) => {
|
|
196
|
+
try {
|
|
197
|
+
const client = getClient();
|
|
198
|
+
console.log(`Testing channel: ${id}`);
|
|
199
|
+
console.log(` SSE: ${program.opts().url ?? getProfile(program.opts().profile)?.url}/${id}/events`);
|
|
200
|
+
console.log(` Webhook: ${program.opts().url ?? getProfile(program.opts().profile)?.url}/${id}`);
|
|
201
|
+
console.log();
|
|
202
|
+
const result = await client.testChannel(id, {
|
|
203
|
+
token: opts.token,
|
|
204
|
+
timeoutMs: opts.timeout,
|
|
205
|
+
});
|
|
206
|
+
if (result.ok) {
|
|
207
|
+
console.log(`✅ PASS`);
|
|
208
|
+
console.log(` Webhook POST: HTTP ${result.webhookStatus}`);
|
|
209
|
+
console.log(` SSE received: ${result.eventId}`);
|
|
210
|
+
console.log(` Round-trip: ${result.roundTripMs}ms`);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
console.log(`❌ FAIL`);
|
|
214
|
+
if (result.webhookStatus)
|
|
215
|
+
console.log(` Webhook POST: HTTP ${result.webhookStatus}`);
|
|
216
|
+
console.log(` Error: ${result.error}`);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
console.error(`Error: ${err.message}`);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
program.parse();
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type SignatureAlgorithm = "hmac-sha256-hex" | "hmac-sha256-base64";
|
|
2
|
+
export type SignatureConfig = {
|
|
3
|
+
header: string;
|
|
4
|
+
algorithm: SignatureAlgorithm;
|
|
5
|
+
prefix?: string;
|
|
6
|
+
secret: string;
|
|
7
|
+
};
|
|
8
|
+
export type ChannelConfig = {
|
|
9
|
+
id: string;
|
|
10
|
+
signature?: Omit<SignatureConfig, "secret">;
|
|
11
|
+
token?: string;
|
|
12
|
+
eventHeader?: string;
|
|
13
|
+
maxHistory: number;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
};
|
|
16
|
+
export type RelayEvent = {
|
|
17
|
+
id: string;
|
|
18
|
+
channel: string;
|
|
19
|
+
event: string;
|
|
20
|
+
timestamp: string;
|
|
21
|
+
source?: string;
|
|
22
|
+
payload: unknown;
|
|
23
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mcnekoneko/hookstream-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for managing hookstream channels",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hookstream": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"dev": "tsx src/index.ts",
|
|
17
|
+
"lint": "biome check ./src",
|
|
18
|
+
"lint:fix": "biome check --write ./src",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"prepack": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"hookstream",
|
|
24
|
+
"cli",
|
|
25
|
+
"webhook",
|
|
26
|
+
"sse",
|
|
27
|
+
"cloudflare-workers"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"homepage": "https://github.com/mc-nekoneko/hookstream/tree/main/cli#readme",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/mc-nekoneko/hookstream.git",
|
|
34
|
+
"directory": "cli"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/mc-nekoneko/hookstream/issues"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=20"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"commander": "^13.1.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@biomejs/biome": "^2.4.6",
|
|
50
|
+
"@types/node": "^22.0.0",
|
|
51
|
+
"tsx": "^4.19.0",
|
|
52
|
+
"typescript": "^5.8.0"
|
|
53
|
+
}
|
|
54
|
+
}
|