@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 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
+ }
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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();
@@ -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
+ }