@mcnekoneko/hookstream-cli 0.1.1 → 0.1.3

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/dist/api.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { ChannelConfig, RelayEvent, SignatureAlgorithm } from "./types";
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";
4
+ import { getProfile, runConfigure } from "./config";
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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcnekoneko/hookstream-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "CLI for managing hookstream channels",
5
5
  "type": "module",
6
6
  "bin": {