@anonymilyhq/cli 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,54 +1,248 @@
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?
4
16
 
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.
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 signup. No account required.** 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.
22
+
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
40
+
41
+ **No account or signup required** — works immediately with free tier (50 requests, 24h retention).
42
+
43
+ ```bash
44
+ # Start listening — generates a random endpoint ID automatically
45
+ npx @anonymilyhq/cli listen 3000
46
+
47
+ # Output:
48
+ # 🚀 Anonymily CLI is running!
49
+ # Forwarding: https://api.anonymily.com/h/xk92bzte ➔ http://127.0.0.1:3000
50
+ # Tier: 🆓 FREE | Storage: 0/50 | Retention: 24 hours
51
+ # Waiting for requests to arrive...
52
+ ```
53
+
54
+ Send a test payload:
55
+ ```bash
56
+ curl -X POST https://api.anonymily.com/h/xk92bzte \
57
+ -H "Content-Type: application/json" \
58
+ -d '{"event": "payment.success"}'
59
+ ```
60
+
61
+ You'll see in the CLI:
62
+ ```
63
+ [12:01:45] ⚡ Incoming POST request...
64
+ └─ Forwarded to localhost | Status: 200
65
+ ```
66
+
67
+ ---
68
+
69
+ ## Commands
70
+
71
+ ### `listen <port>`
72
+
73
+ Listen for incoming webhooks and forward them to a local port.
74
+
75
+ ```bash
76
+ anonymily listen <port> [options]
77
+ ```
78
+
79
+ | Option | Description |
80
+ |--------|-------------|
81
+ | `<port>` | **Required.** Local port to forward to (e.g. `3000`, `8080`) |
82
+ | `-i, --id <id>` | Use a custom, memorable endpoint name (e.g. `stripe-test`) |
83
+ | `--pro` | Register the hook as Pro-tier (legacy). Requires `--id` and a configured Pro API key |
16
84
 
85
+ **Examples:**
17
86
  ```bash
18
- npx @anonymilyhq/cli listen 8080
87
+ # Random endpoint ID (ephemeral)
88
+ npx @anonymilyhq/cli listen 3000
89
+
90
+ # Custom named endpoint → api.anonymily.com/h/stripe-dev
91
+ npx @anonymilyhq/cli listen 3000 --id stripe-dev
92
+
93
+ # Pro-tier named endpoint (legacy hook-based Pro)
94
+ npx @anonymilyhq/cli listen 3000 --id stripe-dev --pro
19
95
  ```
20
96
 
21
- ## Usage
97
+ ---
98
+
99
+ ### `config set-key <key>`
100
+
101
+ Save your Pro API key locally so you don't need to export it every session.
102
+
103
+ ```bash
104
+ anonymily config set-key <your-pro-api-key>
105
+ ```
22
106
 
23
- Start listening for incoming webhooks and forward them to a specific port on your localhost:
107
+ The key is stored in `~/.anonymily/config.json`. You can also set it via environment variable:
24
108
 
25
109
  ```bash
26
- anonymily listen <port>
110
+ export PRO_API_KEY=<your-pro-api-key>
27
111
  ```
28
112
 
29
- ### Example
113
+ ---
30
114
 
31
- To forward webhooks to your local development server running on port `3000` with a randomly generated endpoint ID:
115
+ ### `feedback <rating> [message]`
32
116
 
117
+ Submit feedback about the Anonymily CLI directly from your terminal.
118
+
119
+ ```bash
120
+ anonymily feedback <rating> [message]
121
+ ```
122
+
123
+ | Parameter | Description |
124
+ |-----------|-------------|
125
+ | `<rating>` | **Required.** Rating from 1-5 (1=Poor, 5=Excellent) |
126
+ | `[message]` | Optional feedback message |
127
+
128
+ **Examples:**
33
129
  ```bash
34
- anonymily listen 3000
130
+ # Quick rating
131
+ anonymily feedback 5
132
+
133
+ # Rating with message
134
+ anonymily feedback 4 "Great tool! Would love to see request filtering."
135
+
136
+ # Detailed feedback
137
+ anonymily feedback 5 "The SSE reconnection works perfectly. No issues so far!"
138
+ ```
139
+
140
+ Your feedback helps us improve Anonymily. All submissions are anonymous unless you're logged in via the web dashboard.
141
+
142
+ ---
143
+
144
+ ## Account-Based Pro (Recommended)
145
+
146
+ 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.
147
+
148
+ **Workflow:**
149
+ 1. Sign up at [anonymily.com](https://anonymily.com)
150
+ 2. Subscribe to Pro at [anonymily.com/upgrade](https://anonymily.com/upgrade)
151
+ 3. Open the dashboard, copy your endpoint URL, and claim it to your account
152
+ 4. Run the CLI with the same hook ID:
153
+ ```bash
154
+ npx @anonymilyhq/cli listen 3000 --id your-claimed-hook
155
+ ```
156
+ 5. All Pro limits apply automatically — 500 requests, 30-day history
157
+
158
+ ---
159
+
160
+ ## How It Works
161
+
35
162
  ```
163
+ External Service (Stripe, GitHub, etc.)
164
+
165
+ │ POST https://api.anonymily.com/h/<hookId>
166
+
167
+ Anonymily Backend (NestJS + Redis + Supabase)
168
+
169
+ │ SSE broadcast via /stream/<hookId>
170
+
171
+ @anonymilyhq/cli ──forward──► http://localhost:<port>
172
+ ```
173
+
174
+ 1. The CLI opens a persistent SSE connection to `/stream/<hookId>`
175
+ 2. When a webhook arrives, the backend broadcasts it instantly
176
+ 3. The CLI receives the event and re-issues the exact same HTTP request to your local port
177
+ 4. Method, headers, query params, and body are all preserved 1:1
36
178
 
37
- To specify a custom endpoint ID (e.g., `stripe-test`) so that your URL is consistently `api.anonymily.com/h/stripe-test`:
179
+ ---
180
+
181
+ ## Tier Comparison
182
+
183
+ | Feature | Free | Pro |
184
+ |---------|------|-----|
185
+ | Requests stored per hook | 50 | 500 |
186
+ | History retention | 24 hours | 30 days |
187
+ | Custom endpoint ID | ✅ | ✅ |
188
+ | Persistent history in dashboard | ✅ | ✅ |
189
+ | Price | Free | ₹750/month |
190
+
191
+ ---
192
+
193
+ ## Testing Locally
194
+
195
+ A sample target server is included to test forwarding without a real local app:
38
196
 
39
197
  ```bash
40
- anonymily listen 3000 --id stripe-test
198
+ node sample-target-server.js
199
+ # Listens on http://localhost:4000 and logs all incoming requests
41
200
  ```
42
201
 
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.
202
+ Then in another terminal:
203
+ ```bash
204
+ npx @anonymilyhq/cli listen 4000
205
+ ```
206
+
207
+ ---
208
+
209
+ ## Troubleshooting
210
+
211
+ **`Connection refused` when forwarding:**
212
+ - Make sure your local server is actually running on the specified port
213
+ - Test with: `curl http://localhost:<port>`
214
+
215
+ **Requests arrive in CLI but local server returns errors:**
216
+ - Check the status code logged: `└─ Forwarded to localhost | Status: 500`
217
+ - Review your local server logs for the underlying cause
218
+
219
+ **CLI doesn't reconnect after network drop:**
220
+ - The CLI uses `eventsource` which auto-reconnects on drops
221
+ - You'll see: `[Network] Connection dropped. Automatically reconnecting...`
222
+
223
+ **SSE stream appears stuck (no events):**
224
+ - Confirm the hook ID exists: `curl https://api.anonymily.com/history/<hookId>`
225
+ - Ensure you're sending requests to the correct endpoint URL shown in the CLI output
226
+
227
+ ---
228
+
229
+ ## Environment Variables
230
+
231
+ | Variable | Description |
232
+ |----------|-------------|
233
+ | `PRO_API_KEY` | Legacy Pro API key (overrides `~/.anonymily/config.json`) |
234
+ | `ANONYMILY_API_URL` | Override the backend URL (default: `https://api.anonymily.com`) |
235
+
236
+ ---
237
+
238
+ ## Changelog
46
239
 
240
+ See [CHANGELOG.md](CHANGELOG.md) for a detailed history of changes.
47
241
 
48
- ## Issues
242
+ ## Contributing
49
243
 
50
- If you encounter a bug, please create an issue in the main repository or contact our support.
244
+ Issues and pull requests are welcome. Open an issue first to discuss any significant changes.
51
245
 
52
246
  ## License
53
247
 
54
- MIT
248
+ MIT © [Anonymily](https://anonymily.com)
package/bin/cli.js CHANGED
@@ -1,25 +1,126 @@
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';
15
+ import { submitFeedback } from '../lib/feedback.js';
6
16
 
7
17
  // Point this to your live production backend URL
8
18
  const API_URL = process.env.ANONYMILY_API_URL || 'https://api.anonymily.com';
9
19
 
10
20
  program
11
- .name('anonymily')
12
- .description('Forward webhooks from Anonymily directly to your local machine.')
13
- .version('1.0.0');
21
+ .name('anonymily')
22
+ .description('Forward webhooks from Anonymily directly to your local machine.')
23
+ .version('1.1.0');
14
24
 
25
+ // ---------------------------------------------------------------------------
26
+ // listen
27
+ // ---------------------------------------------------------------------------
15
28
  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();
29
+ .command('listen')
30
+ .description('Listen for incoming webhooks and forward them to a local port')
31
+ .argument('<port>', 'Local port to forward to (e.g., 8080)')
32
+ .option('-i, --id <id>', 'Provide a custom endpoint ID to listen to')
33
+ .option(
34
+ '--pro',
35
+ 'Register the hook as Pro-tier (requires --id and PRO_API_KEY)',
36
+ )
37
+ .action(async (port, options) => {
38
+ // --pro requires --id
39
+ if (options.pro && !options.id) {
40
+ logger.error(
41
+ pc.bold(
42
+ '--pro requires --id to specify a persistent hook name.',
43
+ ),
44
+ );
45
+ process.exit(1);
46
+ }
47
+
48
+ const hookId = options.id || generateHookId();
49
+
50
+ if (options.pro) {
51
+ try {
52
+ await registerProHook(API_URL, hookId);
53
+ } catch (err) {
54
+ if (
55
+ err instanceof ProApiKeyMissingError ||
56
+ err instanceof ProApiAuthError ||
57
+ err instanceof ProApiError
58
+ ) {
59
+ logger.error(err.message);
60
+ } else {
61
+ logger.error(`Unexpected error during Pro registration: ${err.message}`);
62
+ }
63
+ process.exit(1);
64
+ }
65
+ }
66
+
67
+ startForwarding(API_URL, hookId, parseInt(port, 10));
68
+ });
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // config
72
+ // ---------------------------------------------------------------------------
73
+ const configCmd = program
74
+ .command('config')
75
+ .description('Manage CLI configuration');
76
+
77
+ configCmd
78
+ .command('set-key <key>')
79
+ .description(
80
+ 'Save your Pro API key to ~/.anonymily/config.json',
81
+ )
82
+ .action((key) => {
83
+ if (!key || !key.trim()) {
84
+ logger.error('API key cannot be empty.');
85
+ process.exit(1);
86
+ }
87
+ writeConfig({ proApiKey: key.trim() });
88
+ logger.success(
89
+ `Pro API key saved to ${CONFIG_PATH}`,
90
+ );
91
+ });
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // feedback
95
+ // ---------------------------------------------------------------------------
96
+ program
97
+ .command('feedback')
98
+ .description('Submit feedback about Anonymily CLI')
99
+ .argument('<rating>', 'Rating from 1-5 (1=Poor, 5=Excellent)')
100
+ .argument('[message]', 'Optional feedback message')
101
+ .action(async (rating, message) => {
102
+ const numRating = parseInt(rating, 10);
103
+
104
+ if (isNaN(numRating) || numRating < 1 || numRating > 5) {
105
+ logger.error('Rating must be a number between 1 and 5.');
106
+ process.exit(1);
107
+ }
108
+
109
+ logger.dim('Submitting feedback...');
110
+ const result = await submitFeedback(API_URL, numRating, message);
111
+
112
+ if (result.success) {
113
+ logger.success(
114
+ `\n✅ Thank you for your feedback! (ID: ${result.id || 'N/A'})\n`,
115
+ );
116
+ logger.raw(
117
+ 'Your input helps us improve Anonymily. 🙏\n',
118
+ );
119
+ } else {
120
+ logger.error('Failed to submit feedback. Please try again later.');
121
+ process.exit(1);
122
+ }
123
+ });
124
+
125
+ program.parse();
126
+
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
+
@@ -0,0 +1,36 @@
1
+ import { logger } from './logger.js';
2
+
3
+ /**
4
+ * Submit feedback to the Anonymily backend.
5
+ *
6
+ * @param {string} apiUrl - Base API URL
7
+ * @param {number} rating - Rating from 1-5
8
+ * @param {string} message - Optional feedback message
9
+ * @returns {Promise<{success: boolean, id?: string}>}
10
+ */
11
+ export async function submitFeedback(apiUrl, rating, message = '') {
12
+ try {
13
+ const response = await fetch(`${apiUrl}/feedback`, {
14
+ method: 'POST',
15
+ headers: {
16
+ 'Content-Type': 'application/json',
17
+ },
18
+ body: JSON.stringify({
19
+ rating,
20
+ message: message || undefined,
21
+ pagePath: 'cli',
22
+ }),
23
+ });
24
+
25
+ if (!response.ok) {
26
+ const errorText = await response.text();
27
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
28
+ }
29
+
30
+ const result = await response.json();
31
+ return result;
32
+ } catch (error) {
33
+ logger.error(`Failed to submit feedback: ${error.message}`);
34
+ return { success: false };
35
+ }
36
+ }
package/lib/forwarder.js CHANGED
@@ -2,39 +2,124 @@ import { EventSource as EventSourceConstructor } from 'eventsource';
2
2
  import pc from 'picocolors';
3
3
  import { logger } from './logger.js';
4
4
 
5
- export function startForwarding(apiUrl, hookId, port) {
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
+
36
+ /**
37
+ * Fetch tier information for a hook ID from the backend.
38
+ */
39
+ async function fetchTierInfo(apiUrl, hookId) {
40
+ try {
41
+ const response = await fetch(`${apiUrl}/history/${hookId}`);
42
+ if (!response.ok) return null;
43
+
44
+ const data = await response.json();
45
+ return {
46
+ tier: data.tier || 'free',
47
+ requestCount: data.requests?.length || 0,
48
+ maxRequests: data.tier === 'pro' ? 500 : 50,
49
+ retention: data.tier === 'pro' ? '30 days' : '24 hours',
50
+ };
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export async function startForwarding(apiUrl, hookId, port) {
6
57
  const webhookUrl = `${apiUrl}/h/${hookId}`;
7
58
  const localUrl = `http://127.0.0.1:${port}`;
8
59
 
9
60
  logger.success(pc.bold(`\n🚀 Anonymily CLI is running!`));
10
61
  logger.raw(`\nForwarding: ${pc.cyan(webhookUrl)} ➔ ${pc.cyan(localUrl)}`);
11
- logger.dim(`Waiting for requests to arrive...\n`);
62
+
63
+ // Fetch and display tier information
64
+ const tierInfo = await fetchTierInfo(apiUrl, hookId);
65
+ if (tierInfo) {
66
+ const tierColor = tierInfo.tier === 'pro' ? pc.green : pc.yellow;
67
+ const tierBadge = tierInfo.tier === 'pro' ? '✨ PRO' : '🆓 FREE';
68
+ logger.raw(`Tier: ${tierColor(pc.bold(tierBadge))} | Storage: ${tierInfo.requestCount}/${tierInfo.maxRequests} | Retention: ${tierInfo.retention}`);
69
+
70
+ if (tierInfo.tier === 'free') {
71
+ logger.dim(`\n💡 Upgrade to Pro for 500 requests + 30-day retention at https://anonymily.com/upgrade`);
72
+ }
73
+ }
74
+
75
+ logger.dim(`Waiting for requests to arrive...`);
76
+ logger.dim(`💬 Enjoying the CLI? Run 'anonymily feedback 5' to let us know!\n`);
12
77
 
13
78
  const es = new EventSourceConstructor(`${apiUrl}/stream/${hookId}`);
14
79
 
80
+ let requestCount = tierInfo?.requestCount || 0;
81
+ const maxRequests = tierInfo?.maxRequests || 50;
82
+
15
83
  es.onmessage = async (event) => {
16
84
  try {
17
85
  const reqData = JSON.parse(event.data);
18
86
  const { method, headers, query, body } = reqData;
19
87
 
88
+ // Strip hop-by-hop headers that must not be forwarded
20
89
  const forwardHeaders = { ...headers };
21
90
  delete forwardHeaders.host;
22
91
  delete forwardHeaders.connection;
23
92
  delete forwardHeaders['content-length'];
93
+ delete forwardHeaders['transfer-encoding'];
94
+
95
+ const serializedBody = serializeBody(method, body, forwardHeaders);
96
+ const finalHeaders = ensureContentType(forwardHeaders, body);
24
97
 
25
98
  const queryString = new URLSearchParams(query || {}).toString();
26
99
  const fullLocalUrl = queryString ? `${localUrl}?${queryString}` : localUrl;
27
100
 
28
- logger.warn(`[${new Date().toLocaleTimeString()}] Incoming ${method} request...`);
101
+ // Increment request count and show progress
102
+ requestCount++;
103
+ const progress = `[${requestCount}/${maxRequests}]`;
104
+ const progressColor = requestCount > maxRequests * 0.8 ? pc.red : pc.dim;
105
+
106
+ logger.warn(`[${new Date().toLocaleTimeString()}] ⚡ Incoming ${method} request ${progressColor(progress)}...`);
29
107
 
30
108
  const response = await fetch(fullLocalUrl, {
31
- method: method,
32
- headers: forwardHeaders,
33
- body: ['GET', 'HEAD'].includes(method) ? undefined : JSON.stringify(body)
109
+ method,
110
+ headers: finalHeaders,
111
+ body: serializedBody,
34
112
  });
35
113
 
36
114
  const statusColor = response.ok ? pc.green : pc.red;
37
115
  logger.raw(` └─ Forwarded to localhost | Status: ${statusColor(response.status)}\n`);
116
+
117
+ // Warn when approaching tier limit
118
+ if (requestCount === maxRequests) {
119
+ logger.warn(`⚠️ Tier limit reached (${maxRequests} requests). Older requests will be purged.\n`);
120
+ } else if (requestCount === Math.floor(maxRequests * 0.9)) {
121
+ logger.warn(`⚠️ 90% of tier limit reached. Consider upgrading to Pro for 500 requests.\n`);
122
+ }
38
123
  } catch (err) {
39
124
  logger.error(` └─ Error forwarding: ${err.message}`);
40
125
  logger.dim(` Make sure your local server is actually running on port ${port}\n`);
@@ -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.1.0",
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
+ }