@anonymilyhq/cli 1.0.3 → 1.0.4

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 CHANGED
@@ -1,54 +1,212 @@
1
+ <div align="center">
2
+
1
3
  # @anonymilyhq/cli
2
4
 
3
- The official CLI for [Anonymily](https://anonymily.com), a simple and powerful webhook relayer.
5
+ **The official CLI for [Anonymily](https://anonymily.com) forward webhooks from the cloud straight to your localhost.**
6
+
7
+ [![npm version](https://img.shields.io/npm/v/@anonymilyhq/cli.svg?color=emerald&label=npm)](https://www.npmjs.com/package/@anonymilyhq/cli)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
9
+ [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-brightgreen.svg?logo=node.js)](https://nodejs.org/)
10
+
11
+ </div>
12
+
13
+ ---
14
+
15
+ ## What is it?
16
+
17
+ The Anonymily CLI connects to your live webhook endpoint on `api.anonymily.com` via a Server-Sent Events (SSE) stream and forwards every incoming request — with the exact same method, headers, query params, and body — to a port on your local machine.
18
+
19
+ No tunnels, no sign-ups required to start. Just one command.
20
+
21
+ > **Pro tip:** Create a free account at [anonymily.com](https://anonymily.com) and claim your endpoint to keep the same URL across sessions and unlock 500 requests / 30-day history retention with a Pro subscription.
4
22
 
5
- With the Anonymily CLI, you can forward webhooks directly from Anonymily to your local machine, allowing you to easily test, inspect, and debug incoming webhooks in real-time.
23
+ ---
6
24
 
7
25
  ## Installation
8
26
 
9
- You can install the CLI globally using npm:
27
+ **Run instantly via npx (no install required):**
28
+ ```bash
29
+ npx @anonymilyhq/cli listen 3000
30
+ ```
10
31
 
32
+ **Or install globally:**
11
33
  ```bash
12
34
  npm install -g @anonymilyhq/cli
13
35
  ```
14
36
 
15
- Alternatively, you can run it directly using `npx`:
37
+ ---
38
+
39
+ ## Quick Start
16
40
 
17
41
  ```bash
18
- npx @anonymilyhq/cli listen 8080
42
+ # Start listening — generates a random endpoint ID automatically
43
+ npx @anonymilyhq/cli listen 3000
44
+
45
+ # Output:
46
+ # 🚀 Anonymily CLI is running!
47
+ # Forwarding: https://api.anonymily.com/h/xk92bzte ➔ http://127.0.0.1:3000
48
+ # Waiting for requests to arrive...
19
49
  ```
20
50
 
21
- ## Usage
51
+ Send a test payload:
52
+ ```bash
53
+ curl -X POST https://api.anonymily.com/h/xk92bzte \
54
+ -H "Content-Type: application/json" \
55
+ -d '{"event": "payment.success"}'
56
+ ```
22
57
 
23
- Start listening for incoming webhooks and forward them to a specific port on your localhost:
58
+ You'll see in the CLI:
59
+ ```
60
+ [12:01:45] ⚡ Incoming POST request...
61
+ └─ Forwarded to localhost | Status: 200
62
+ ```
63
+
64
+ ---
65
+
66
+ ## Commands
67
+
68
+ ### `listen <port>`
69
+
70
+ Listen for incoming webhooks and forward them to a local port.
71
+
72
+ ```bash
73
+ anonymily listen <port> [options]
74
+ ```
75
+
76
+ | Option | Description |
77
+ |--------|-------------|
78
+ | `<port>` | **Required.** Local port to forward to (e.g. `3000`, `8080`) |
79
+ | `-i, --id <id>` | Use a custom, memorable endpoint name (e.g. `stripe-test`) |
80
+ | `--pro` | Register the hook as Pro-tier (legacy). Requires `--id` and a configured Pro API key |
24
81
 
82
+ **Examples:**
25
83
  ```bash
26
- anonymily listen <port>
84
+ # Random endpoint ID (ephemeral)
85
+ npx @anonymilyhq/cli listen 3000
86
+
87
+ # Custom named endpoint → api.anonymily.com/h/stripe-dev
88
+ npx @anonymilyhq/cli listen 3000 --id stripe-dev
89
+
90
+ # Pro-tier named endpoint (legacy hook-based Pro)
91
+ npx @anonymilyhq/cli listen 3000 --id stripe-dev --pro
27
92
  ```
28
93
 
29
- ### Example
94
+ ---
30
95
 
31
- To forward webhooks to your local development server running on port `3000` with a randomly generated endpoint ID:
96
+ ### `config set-key <key>`
97
+
98
+ Save your Pro API key locally so you don't need to export it every session.
32
99
 
33
100
  ```bash
34
- anonymily listen 3000
101
+ anonymily config set-key <your-pro-api-key>
35
102
  ```
36
103
 
37
- To specify a custom endpoint ID (e.g., `stripe-test`) so that your URL is consistently `api.anonymily.com/h/stripe-test`:
104
+ The key is stored in `~/.anonymily/config.json`. You can also set it via environment variable:
38
105
 
39
106
  ```bash
40
- anonymily listen 3000 --id stripe-test
107
+ export PRO_API_KEY=<your-pro-api-key>
41
108
  ```
42
109
 
43
- 1. The CLI will output a webhook connection URL and automatically connect via Server-Sent Events.
44
- 2. Send your webhooks to the provided URL.
45
- 3. The CLI will receive the requests natively and forward them precisely to `http://localhost:3000`, preserving exactly the same method, body, queries, and headers.
110
+ ---
111
+
112
+ ## Account-Based Pro (Recommended)
113
+
114
+ The modern way to get Pro limits is via an **account subscription** at [anonymily.com/upgrade](https://anonymily.com/upgrade). This gives your entire account Pro limits (₹750/month) — no per-hook configuration needed.
115
+
116
+ **Workflow:**
117
+ 1. Sign up at [anonymily.com](https://anonymily.com)
118
+ 2. Subscribe to Pro at [anonymily.com/upgrade](https://anonymily.com/upgrade)
119
+ 3. Open the dashboard, copy your endpoint URL, and claim it to your account
120
+ 4. Run the CLI with the same hook ID:
121
+ ```bash
122
+ npx @anonymilyhq/cli listen 3000 --id your-claimed-hook
123
+ ```
124
+ 5. All Pro limits apply automatically — 500 requests, 30-day history
125
+
126
+ ---
127
+
128
+ ## How It Works
129
+
130
+ ```
131
+ External Service (Stripe, GitHub, etc.)
132
+
133
+ │ POST https://api.anonymily.com/h/<hookId>
134
+
135
+ Anonymily Backend (NestJS + Redis + Supabase)
136
+
137
+ │ SSE broadcast via /stream/<hookId>
138
+
139
+ @anonymilyhq/cli ──forward──► http://localhost:<port>
140
+ ```
141
+
142
+ 1. The CLI opens a persistent SSE connection to `/stream/<hookId>`
143
+ 2. When a webhook arrives, the backend broadcasts it instantly
144
+ 3. The CLI receives the event and re-issues the exact same HTTP request to your local port
145
+ 4. Method, headers, query params, and body are all preserved 1:1
146
+
147
+ ---
148
+
149
+ ## Tier Comparison
150
+
151
+ | Feature | Free | Pro |
152
+ |---------|------|-----|
153
+ | Requests stored per hook | 50 | 500 |
154
+ | History retention | 24 hours | 30 days |
155
+ | Custom endpoint ID | ✅ | ✅ |
156
+ | Persistent history in dashboard | ✅ | ✅ |
157
+ | Price | Free | ₹750/month |
158
+
159
+ ---
160
+
161
+ ## Testing Locally
162
+
163
+ A sample target server is included to test forwarding without a real local app:
164
+
165
+ ```bash
166
+ node sample-target-server.js
167
+ # Listens on http://localhost:4000 and logs all incoming requests
168
+ ```
169
+
170
+ Then in another terminal:
171
+ ```bash
172
+ npx @anonymilyhq/cli listen 4000
173
+ ```
174
+
175
+ ---
176
+
177
+ ## Troubleshooting
178
+
179
+ **`Connection refused` when forwarding:**
180
+ - Make sure your local server is actually running on the specified port
181
+ - Test with: `curl http://localhost:<port>`
182
+
183
+ **Requests arrive in CLI but local server returns errors:**
184
+ - Check the status code logged: `└─ Forwarded to localhost | Status: 500`
185
+ - Review your local server logs for the underlying cause
186
+
187
+ **CLI doesn't reconnect after network drop:**
188
+ - The CLI uses `eventsource` which auto-reconnects on drops
189
+ - You'll see: `[Network] Connection dropped. Automatically reconnecting...`
190
+
191
+ **SSE stream appears stuck (no events):**
192
+ - Confirm the hook ID exists: `curl https://api.anonymily.com/history/<hookId>`
193
+ - Ensure you're sending requests to the correct endpoint URL shown in the CLI output
194
+
195
+ ---
196
+
197
+ ## Environment Variables
198
+
199
+ | Variable | Description |
200
+ |----------|-------------|
201
+ | `PRO_API_KEY` | Legacy Pro API key (overrides `~/.anonymily/config.json`) |
202
+ | `ANONYMILY_API_URL` | Override the backend URL (default: `https://api.anonymily.com`) |
46
203
 
204
+ ---
47
205
 
48
- ## Issues
206
+ ## Contributing
49
207
 
50
- If you encounter a bug, please create an issue in the main repository or contact our support.
208
+ Issues and pull requests are welcome. Open an issue first to discuss any significant changes.
51
209
 
52
210
  ## License
53
211
 
54
- MIT
212
+ MIT © [Anonymily](https://anonymily.com)
package/bin/cli.js CHANGED
@@ -1,25 +1,93 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { program } from 'commander';
4
+ import pc from 'picocolors';
4
5
  import { generateHookId } from '../lib/utils.js';
5
6
  import { startForwarding } from '../lib/forwarder.js';
7
+ import { writeConfig, CONFIG_PATH } from '../lib/config.js';
8
+ import {
9
+ registerProHook,
10
+ ProApiKeyMissingError,
11
+ ProApiAuthError,
12
+ ProApiError,
13
+ } from '../lib/proRegister.js';
14
+ import { logger } from '../lib/logger.js';
6
15
 
7
16
  // Point this to your live production backend URL
8
17
  const API_URL = process.env.ANONYMILY_API_URL || 'https://api.anonymily.com';
9
18
 
10
19
  program
11
- .name('anonymily')
12
- .description('Forward webhooks from Anonymily directly to your local machine.')
13
- .version('1.0.0');
20
+ .name('anonymily')
21
+ .description('Forward webhooks from Anonymily directly to your local machine.')
22
+ .version('1.0.3');
14
23
 
24
+ // ---------------------------------------------------------------------------
25
+ // listen
26
+ // ---------------------------------------------------------------------------
15
27
  program
16
- .command('listen')
17
- .description('Listen for incoming webhooks and forward them to a local port')
18
- .argument('<port>', 'Local port to forward to (e.g., 8080)')
19
- .option('-i, --id <id>', 'Provide a custom endpoint ID to listen to')
20
- .action((port, options) => {
21
- const hookId = options.id || generateHookId();
22
- startForwarding(API_URL, hookId, parseInt(port, 10));
23
- });
24
-
25
- program.parse();
28
+ .command('listen')
29
+ .description('Listen for incoming webhooks and forward them to a local port')
30
+ .argument('<port>', 'Local port to forward to (e.g., 8080)')
31
+ .option('-i, --id <id>', 'Provide a custom endpoint ID to listen to')
32
+ .option(
33
+ '--pro',
34
+ 'Register the hook as Pro-tier (requires --id and PRO_API_KEY)',
35
+ )
36
+ .action(async (port, options) => {
37
+ // --pro requires --id
38
+ if (options.pro && !options.id) {
39
+ logger.error(
40
+ pc.bold(
41
+ '--pro requires --id to specify a persistent hook name.',
42
+ ),
43
+ );
44
+ process.exit(1);
45
+ }
46
+
47
+ const hookId = options.id || generateHookId();
48
+
49
+ if (options.pro) {
50
+ try {
51
+ await registerProHook(API_URL, hookId);
52
+ } catch (err) {
53
+ if (
54
+ err instanceof ProApiKeyMissingError ||
55
+ err instanceof ProApiAuthError ||
56
+ err instanceof ProApiError
57
+ ) {
58
+ logger.error(err.message);
59
+ } else {
60
+ logger.error(`Unexpected error during Pro registration: ${err.message}`);
61
+ }
62
+ process.exit(1);
63
+ }
64
+ }
65
+
66
+ startForwarding(API_URL, hookId, parseInt(port, 10));
67
+ });
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // config
71
+ // ---------------------------------------------------------------------------
72
+ const configCmd = program
73
+ .command('config')
74
+ .description('Manage CLI configuration');
75
+
76
+ configCmd
77
+ .command('set-key <key>')
78
+ .description(
79
+ 'Save your Pro API key to ~/.anonymily/config.json',
80
+ )
81
+ .action((key) => {
82
+ if (!key || !key.trim()) {
83
+ logger.error('API key cannot be empty.');
84
+ process.exit(1);
85
+ }
86
+ writeConfig({ proApiKey: key.trim() });
87
+ logger.success(
88
+ `Pro API key saved to ${CONFIG_PATH}`,
89
+ );
90
+ });
91
+
92
+ program.parse();
93
+
package/lib/config.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * lib/config.js
3
+ *
4
+ * Read and write the Anonymily CLI config file at ~/.anonymily/config.json.
5
+ * The config file stores user-scoped settings such as the Pro API key.
6
+ *
7
+ * Schema:
8
+ * {
9
+ * "proApiKey": "<string>" // Pro subscriber API key
10
+ * }
11
+ */
12
+
13
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join, dirname } from 'node:path';
16
+
17
+ /** Absolute path to the config directory. */
18
+ export const CONFIG_DIR = join(homedir(), '.anonymily');
19
+
20
+ /** Absolute path to the config JSON file. */
21
+ export const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
22
+
23
+ /**
24
+ * Read and parse the config file.
25
+ * Returns an empty object if the file doesn't exist yet.
26
+ *
27
+ * @returns {Record<string, unknown>}
28
+ */
29
+ export function readConfig() {
30
+ if (!existsSync(CONFIG_PATH)) return {};
31
+ try {
32
+ const raw = readFileSync(CONFIG_PATH, 'utf8');
33
+ return JSON.parse(raw);
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Write (merge) key-value pairs into the config file.
41
+ * Creates the config directory and file if they don't exist.
42
+ *
43
+ * @param {Record<string, unknown>} updates
44
+ */
45
+ export function writeConfig(updates) {
46
+ const existing = readConfig();
47
+ const merged = { ...existing, ...updates };
48
+
49
+ if (!existsSync(CONFIG_DIR)) {
50
+ mkdirSync(CONFIG_DIR, { recursive: true });
51
+ }
52
+
53
+ writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2) + '\n', 'utf8');
54
+ }
55
+
56
+ /**
57
+ * Retrieve the user's Pro API key.
58
+ *
59
+ * Resolution order (highest precedence first):
60
+ * 1. PRO_API_KEY environment variable
61
+ * 2. proApiKey field in ~/.anonymily/config.json
62
+ *
63
+ * @returns {string | undefined}
64
+ */
65
+ export function getProApiKey() {
66
+ if (process.env.PRO_API_KEY) return process.env.PRO_API_KEY;
67
+ const cfg = readConfig();
68
+ return typeof cfg.proApiKey === 'string' ? cfg.proApiKey : undefined;
69
+ }
70
+
package/lib/forwarder.js CHANGED
@@ -2,6 +2,37 @@ import { EventSource as EventSourceConstructor } from 'eventsource';
2
2
  import pc from 'picocolors';
3
3
  import { logger } from './logger.js';
4
4
 
5
+ /**
6
+ * Serialize the body for the forwarded request.
7
+ *
8
+ * The backend stores the body as a parsed JSON object (even for non-JSON
9
+ * payloads, it gets wrapped). We need to re-serialize appropriately:
10
+ * - No body for GET/HEAD
11
+ * - null/undefined body → no body sent
12
+ * - Object/array body → JSON.stringify (with matching Content-Type)
13
+ * - Primitive (string/number) → convert to string (e.g. plain text bodies)
14
+ */
15
+ function serializeBody(method, body, headers) {
16
+ if (['GET', 'HEAD'].includes(method)) return undefined;
17
+ if (body === null || body === undefined) return undefined;
18
+ if (typeof body === 'string') return body;
19
+ if (typeof body === 'object') return JSON.stringify(body);
20
+ return String(body);
21
+ }
22
+
23
+ /**
24
+ * Ensure Content-Type is set for bodies that need it.
25
+ * If the original request had application/json, preserve it.
26
+ */
27
+ function ensureContentType(headers, body) {
28
+ if (body === undefined || body === null) return headers;
29
+ if (headers['content-type']) return headers;
30
+ if (typeof body === 'object') {
31
+ return { ...headers, 'content-type': 'application/json' };
32
+ }
33
+ return headers;
34
+ }
35
+
5
36
  export function startForwarding(apiUrl, hookId, port) {
6
37
  const webhookUrl = `${apiUrl}/h/${hookId}`;
7
38
  const localUrl = `http://127.0.0.1:${port}`;
@@ -17,10 +48,15 @@ export function startForwarding(apiUrl, hookId, port) {
17
48
  const reqData = JSON.parse(event.data);
18
49
  const { method, headers, query, body } = reqData;
19
50
 
51
+ // Strip hop-by-hop headers that must not be forwarded
20
52
  const forwardHeaders = { ...headers };
21
53
  delete forwardHeaders.host;
22
54
  delete forwardHeaders.connection;
23
55
  delete forwardHeaders['content-length'];
56
+ delete forwardHeaders['transfer-encoding'];
57
+
58
+ const serializedBody = serializeBody(method, body, forwardHeaders);
59
+ const finalHeaders = ensureContentType(forwardHeaders, body);
24
60
 
25
61
  const queryString = new URLSearchParams(query || {}).toString();
26
62
  const fullLocalUrl = queryString ? `${localUrl}?${queryString}` : localUrl;
@@ -28,9 +64,9 @@ export function startForwarding(apiUrl, hookId, port) {
28
64
  logger.warn(`[${new Date().toLocaleTimeString()}] ⚡ Incoming ${method} request...`);
29
65
 
30
66
  const response = await fetch(fullLocalUrl, {
31
- method: method,
32
- headers: forwardHeaders,
33
- body: ['GET', 'HEAD'].includes(method) ? undefined : JSON.stringify(body)
67
+ method,
68
+ headers: finalHeaders,
69
+ body: serializedBody,
34
70
  });
35
71
 
36
72
  const statusColor = response.ok ? pc.green : pc.red;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * lib/proRegister.js
3
+ *
4
+ * Registers a hook ID as Pro-tier by calling the backend's
5
+ * POST /hooks/pro/register endpoint.
6
+ *
7
+ * Acceptance criteria (ANO-47):
8
+ * - Uses the PRO_API_KEY from env or ~/.anonymily/config.json
9
+ * - If the key is missing → throws ProApiKeyMissingError
10
+ * - If the API returns 401 → throws ProApiAuthError
11
+ * - If the API returns any other non-2xx → throws ProApiError
12
+ * - On success → prints confirmation message and returns the response body
13
+ */
14
+
15
+ import pc from 'picocolors';
16
+ import { logger } from './logger.js';
17
+ import { getProApiKey } from './config.js';
18
+
19
+ /** Thrown when no Pro API key is configured. */
20
+ export class ProApiKeyMissingError extends Error {
21
+ constructor() {
22
+ super(
23
+ 'Pro API key not configured. Run: anonymily config set-key <key>',
24
+ );
25
+ this.name = 'ProApiKeyMissingError';
26
+ }
27
+ }
28
+
29
+ /** Thrown when the backend returns 401 Unauthorized. */
30
+ export class ProApiAuthError extends Error {
31
+ constructor() {
32
+ super(
33
+ 'Pro API key is invalid or revoked. Please verify your key with: anonymily config set-key <key>',
34
+ );
35
+ this.name = 'ProApiAuthError';
36
+ }
37
+ }
38
+
39
+ /** Thrown when the backend returns any other non-2xx status. */
40
+ export class ProApiError extends Error {
41
+ /** @param {number} status @param {string} [body] */
42
+ constructor(status, body = '') {
43
+ super(`Pro registration failed (HTTP ${status}): ${body}`);
44
+ this.name = 'ProApiError';
45
+ this.status = status;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Register a hook ID as Pro-tier via the backend API.
51
+ *
52
+ * @param {string} apiUrl Base URL, e.g. https://api.anonymily.com
53
+ * @param {string} hookId The hook ID to register
54
+ * @returns {Promise<object>} Parsed response body from the API
55
+ *
56
+ * @throws {ProApiKeyMissingError} No API key available
57
+ * @throws {ProApiAuthError} API returned 401
58
+ * @throws {ProApiError} API returned other non-2xx
59
+ */
60
+ export async function registerProHook(apiUrl, hookId) {
61
+ const apiKey = getProApiKey();
62
+
63
+ if (!apiKey) {
64
+ throw new ProApiKeyMissingError();
65
+ }
66
+
67
+ let response;
68
+ try {
69
+ response = await fetch(`${apiUrl}/hooks/pro/register`, {
70
+ method: 'POST',
71
+ headers: {
72
+ 'Content-Type': 'application/json',
73
+ 'x-api-key': apiKey,
74
+ },
75
+ body: JSON.stringify({ hookId }),
76
+ });
77
+ } catch (networkErr) {
78
+ throw new Error(`Network error contacting Pro API: ${networkErr.message}`);
79
+ }
80
+
81
+ if (response.status === 401) {
82
+ throw new ProApiAuthError();
83
+ }
84
+
85
+ if (!response.ok) {
86
+ const body = await response.text().catch(() => '');
87
+ throw new ProApiError(response.status, body);
88
+ }
89
+
90
+ const data = await response.json().catch(() => ({}));
91
+
92
+ logger.success(
93
+ pc.bold(
94
+ `[Pro] Hook registered: persists for 30 days, up to 500 requests stored.`,
95
+ ),
96
+ );
97
+
98
+ return data;
99
+ }
100
+
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@anonymilyhq/cli",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "CLI for Anonymily platform",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "anonymily": "bin/cli.js"
8
8
  },
9
9
  "scripts": {
10
- "start": "node ./bin/cli.js"
10
+ "start": "node ./bin/cli.js",
11
+ "test": "node --experimental-vm-modules node_modules/.bin/jest --no-coverage",
12
+ "test:coverage": "node --experimental-vm-modules node_modules/.bin/jest --coverage"
11
13
  },
12
14
  "keywords": [
13
15
  "cli",
@@ -27,5 +29,12 @@
27
29
  "commander": "^14.0.3",
28
30
  "eventsource": "^4.1.0",
29
31
  "picocolors": "^1.1.1"
32
+ },
33
+ "devDependencies": {
34
+ "jest": "^30.3.0"
35
+ },
36
+ "jest": {
37
+ "testEnvironment": "node",
38
+ "transform": {}
30
39
  }
31
- }
40
+ }