@anonymilyhq/cli 1.2.0 → 1.3.1

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
@@ -14,17 +14,19 @@
14
14
 
15
15
  ## What is it?
16
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.
17
+ The Anonymily CLI connects to your webhook endpoint 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. It also captures your server's response and streams it back to the dashboard in real time, so you can see status codes, latency, and error bodies without leaving the UI.
18
18
 
19
- **No tunnels. No signup. No account required.** Just one command.
19
+ **No account required for the free tier. One command.**
20
20
 
21
- > **Want more?** Upgrade to Pro (₹750/month) to claim persistent hooks and unlock 500 requests + 30-day retention. Visit [anonymily.com/upgrade](https://anonymily.com/upgrade) to subscribe.
21
+ ```bash
22
+ npx @anonymilyhq/cli listen 3000
23
+ ```
22
24
 
23
25
  ---
24
26
 
25
27
  ## Installation
26
28
 
27
- **Run instantly via npx (no install required):**
29
+ **Run instantly via npx (no install):**
28
30
  ```bash
29
31
  npx @anonymilyhq/cli listen 3000
30
32
  ```
@@ -38,31 +40,26 @@ npm install -g @anonymilyhq/cli
38
40
 
39
41
  ## Quick Start
40
42
 
41
- **No account or signup required** — works immediately with free tier (random ID, 50 requests, 24h retention).
42
-
43
43
  ```bash
44
- # Start listening — generates a random 8-char endpoint ID automatically
45
44
  npx @anonymilyhq/cli listen 3000
46
45
 
47
46
  # 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
- # 💡 Upgrade to Pro for custom endpoint IDs + 500 req/30d at https://anonymily.com/upgrade
52
- # Waiting for requests to arrive...
47
+ # Anonymily CLI is running!
48
+ # Forwarding: https://api.anonymily.com/h/xk92bzte http://127.0.0.1:3000
49
+ # Tier: FREE | Storage: 0/200 | Retention: 48 hours
50
+ # Waiting for requests...
53
51
  ```
54
52
 
55
53
  Send a test payload:
56
54
  ```bash
57
55
  curl -X POST https://api.anonymily.com/h/xk92bzte \
58
56
  -H "Content-Type: application/json" \
59
- -d '{"event": "payment.success"}'
57
+ -d '{"event": "payment.succeeded", "amount": 9900}'
60
58
  ```
61
59
 
62
- You'll see in the CLI:
60
+ CLI output:
63
61
  ```
64
- [12:01:45] Incoming POST request...
65
- └─ Forwarded to localhost | Status: 200
62
+ [12:01:45] Incoming POST → localhost:3000 | 200 OK | 11ms
66
63
  ```
67
64
 
68
65
  ---
@@ -71,170 +68,175 @@ You'll see in the CLI:
71
68
 
72
69
  ### `listen <port>`
73
70
 
74
- Listen for incoming webhooks and forward them to a local port.
71
+ Listen for incoming webhooks and forward them to your local port.
75
72
 
76
73
  ```bash
77
- anonymily listen <port> [options]
74
+ npx @anonymilyhq/cli listen <port> [options]
78
75
  ```
79
76
 
80
77
  | Option | Description |
81
- |--------|-------------|
82
- | `<port>` | **Required.** Local port to forward to (e.g. `3000`, `8080`) |
83
- | `-i, --id <id>` | **Pro only.** Use a custom endpoint ID (e.g. `stripe-test`) instead of a random one. Requires Pro subscription (₹750/month). |
78
+ |---|---|
79
+ | `<port>` | **Required.** Local port to forward to (e.g. `3000`) |
80
+ | `-i, --id <id>` | **Pro.** Use a named endpoint ID (e.g. `stripe-test`) instead of a random one. |
81
+ | `-t, --token <token>` | **Pro.** Personal Access Token — authenticates to claimed endpoints. Also readable from `ANONYMILY_TOKEN` env var. |
84
82
 
85
83
  **Examples:**
84
+
86
85
  ```bash
87
- # Random endpoint ID (Free tier: 50 requests, 24h retention)
86
+ # Free tier random endpoint, no auth
88
87
  npx @anonymilyhq/cli listen 3000
89
88
 
90
- # Custom named endpoint (Pro only: requires subscription + claiming via dashboard)
91
- npx @anonymilyhq/cli listen 3000 --id stripe-dev
89
+ # Pro named endpoint with PAT
90
+ npx @anonymilyhq/cli listen 3000 --id stripe-dev --token pat_example123
92
91
 
93
- # Use your Pro-claimed endpoint (500 requests, 30 days)
94
- npx @anonymilyhq/cli listen 3000 --id my-claimed-hook
92
+ # PAT via env var
93
+ export ANONYMILY_TOKEN=pat_example123
94
+ npx @anonymilyhq/cli listen 3000 --id stripe-dev
95
95
  ```
96
96
 
97
- ---
97
+ ### `trigger <provider> <event>`
98
98
 
99
- ### `feedback <rating> [message]`
100
-
101
- Submit feedback about the Anonymily CLI directly from your terminal.
99
+ Fire a synthetic, correctly-signed provider event at your endpoint — no real charge, PR, or order required. **Pro feature** (requires a PAT).
102
100
 
103
101
  ```bash
104
- anonymily feedback <rating> [message]
102
+ npx @anonymilyhq/cli trigger <provider> <event> [options]
105
103
  ```
106
104
 
107
- | Parameter | Description |
108
- |-----------|-------------|
109
- | `<rating>` | **Required.** Rating from 1-5 (1=Poor, 5=Excellent) |
110
- | `[message]` | Optional feedback message |
105
+ | Option | Description |
106
+ |---|---|
107
+ | `<provider>` | Provider name (e.g. `github`, `stripe`, `shopify`, `razorpay`, `slack`). |
108
+ | `<event>` | Event name (e.g. `push`, `payment_intent.succeeded`, `order.created`). |
109
+ | `-i, --hook <hookId>` | Target hook ID (defaults to a random ID). |
110
+ | `-t, --token <token>` | **Required.** PAT — or set `ANONYMILY_TOKEN`. |
111
+ | `-l, --list` | List all available provider/event combinations and exit. |
111
112
 
112
113
  **Examples:**
114
+
113
115
  ```bash
114
- # Quick rating
115
- anonymily feedback 5
116
+ # List every available provider/event combination
117
+ npx @anonymilyhq/cli trigger --list
118
+
119
+ # Fire a correctly-signed Stripe event at your hook
120
+ npx @anonymilyhq/cli trigger stripe payment_intent.succeeded --hook stripe-dev --token pat_example123
121
+ ```
122
+
123
+ ---
116
124
 
117
- # Rating with message
118
- anonymily feedback 4 "Great tool! Would love to see request filtering."
125
+ ### `replay <hookId> <requestId>`
119
126
 
120
- # Detailed feedback
121
- anonymily feedback 5 "The SSE reconnection works perfectly. No issues so far!"
127
+ Re-fire a previously captured request back through your hook endpoint. Requires a PAT and Pro subscription (free users are rate-limited to 5 replays/day, enforced server-side).
128
+
129
+ ```bash
130
+ npx @anonymilyhq/cli replay <hookId> <requestId> [options]
122
131
  ```
123
132
 
124
- Your feedback helps us improve Anonymily. All submissions are anonymous unless you're logged in via the web dashboard.
133
+ | Option | Description |
134
+ |---|---|
135
+ | `<hookId>` | **Required.** The hook ID that captured the request. |
136
+ | `<requestId>` | **Required.** The request ID to replay (visible in the dashboard). |
137
+ | `-t, --token <token>` | **Required.** PAT — or set `ANONYMILY_TOKEN`. |
138
+ | `-m, --method <method>` | Override the HTTP method (e.g. `POST`, `GET`). |
139
+ | `-b, --body <json>` | Override the request body (JSON string). |
140
+ | `--resign` | Re-sign the payload using the hook's stored secret. |
125
141
 
126
- ---
142
+ **Examples:**
127
143
 
128
- ## Upgrading to Pro (₹750/month)
144
+ ```bash
145
+ # Basic replay
146
+ npx @anonymilyhq/cli replay stripe-dev abc-123 --token pat_example123
129
147
 
130
- **What Pro gives you:**
131
- - **Custom named endpoints** - Use memorable IDs like `stripe-prod` or `github-webhooks`
132
- - **Claim & manage** - Link endpoints to your account permanently via dashboard
133
- - **500 requests** per endpoint (vs 50 for Free random IDs)
134
- - **30-day retention** (vs 24 hours for Free tier)
148
+ # Replay with body override and re-signing
149
+ npx @anonymilyhq/cli replay stripe-dev abc-123 \
150
+ --body '{"amount":5000}' --resign --token pat_example123
151
+ ```
135
152
 
136
- **How to upgrade and use custom endpoints:**
137
- 1. Subscribe to Pro at [anonymily.com/upgrade](https://anonymily.com/upgrade) (₹750/month)
138
- 2. Log in to your dashboard at [anonymily.com/dashboard](https://anonymily.com/dashboard)
139
- 3. Enter your custom endpoint ID (e.g. `my-webhook`) and click "Claim"
140
- 4. Run the CLI with your claimed hook ID:
141
- ```bash
142
- npx @anonymilyhq/cli listen 3000 --id my-webhook
143
- ```
144
- 5. Enjoy Pro limits — 500 requests, 30-day history
153
+ ### `feedback <rating> [message]`
145
154
 
146
- **Important:** Custom endpoint IDs are a **Pro-only feature**. Free users can only use randomly-generated 8-character IDs with 50 requests and 24h retention.
155
+ Submit feedback from the terminal.
156
+
157
+ ```bash
158
+ npx @anonymilyhq/cli feedback 5 "Works perfectly with Shopify webhooks"
159
+ ```
147
160
 
148
161
  ---
149
162
 
150
163
  ## How It Works
151
164
 
152
165
  ```
153
- External Service (Stripe, GitHub, etc.)
166
+ External Service (Stripe, GitHub, Shopify, …)
154
167
 
155
168
  │ POST https://api.anonymily.com/h/<hookId>
156
169
 
157
- Anonymily Backend (NestJS + Redis + Supabase)
170
+ Anonymily Backend captures, verifies signature, stores in Redis
158
171
 
159
172
  │ SSE broadcast via /stream/<hookId>
160
173
 
161
174
  @anonymilyhq/cli ──forward──► http://localhost:<port>
175
+
176
+ │ Response (status, latency, body) streamed back to dashboard
177
+
178
+ Anonymily Dashboard — shows full exchange in real time
162
179
  ```
163
180
 
164
- 1. The CLI opens a persistent SSE connection to `/stream/<hookId>`
165
- 2. When a webhook arrives, the backend broadcasts it instantly
166
- 3. The CLI receives the event and re-issues the exact same HTTP request to your local port
167
- 4. Method, headers, query params, and body are all preserved 1:1
181
+ 1. The CLI opens a persistent SSE connection to the backend.
182
+ 2. When a webhook arrives, it is broadcast instantly.
183
+ 3. The CLI re-issues the exact HTTP request to your local port, preserving method, headers, query params, and body.
184
+ 4. The local response (status code, latency, body) is captured and streamed back to the dashboard so you can see the full exchange without leaving the UI.
168
185
 
169
186
  ---
170
187
 
171
- ## Tier Comparison
188
+ ## Tiers
172
189
 
173
190
  | Feature | Free | Pro |
174
- |---------|------|-----|
175
- | Endpoint ID format | Random 8-char (e.g. `xk92bzte`) | Custom named (e.g. `stripe-prod`) |
176
- | Requests stored per hook | 50 | 500 |
177
- | History retention | 24 hours | 30 days |
178
- | Hook claiming & management | | |
179
- | Dashboard access | | |
180
- | Price | Free | ₹750/month |
181
-
182
- **Note:** Custom endpoint IDs (via `--id` flag) are a **Pro-only feature**. Free users receive randomly-generated IDs.
191
+ |---|---|---|
192
+ | Endpoint ID | Random 8-char | Custom named (`--id`) |
193
+ | Requests per hook | 200 | 2,000 |
194
+ | History retention | 48 hours | 30 days |
195
+ | Named (persistent) endpoints | | Unlimited |
196
+ | Replay (`replay` command) | 5/day | Unlimited |
197
+ | Signature verification UI | | |
198
+ | AI diagnosis + handler gen | — | ✓ (dashboard) |
199
+ | AI edge-case events | | (dashboard) |
200
+ | Price | Free | $9 / ₹750 per month |
201
+
202
+ Upgrade at [anonymily.com/upgrade](https://anonymily.com/upgrade).
183
203
 
184
204
  ---
185
205
 
186
- ## Testing Locally
187
-
188
- A sample target server is included to test forwarding without a real local app:
206
+ ## MCP Server
189
207
 
190
- ```bash
191
- node sample-target-server.js
192
- # Listens on http://localhost:4000 and logs all incoming requests
193
- ```
194
-
195
- Then in another terminal:
196
- ```bash
197
- npx @anonymilyhq/cli listen 4000
198
- ```
208
+ If you use Claude Code or Cursor, you can drive Anonymily entirely from your AI assistant using the [`@anonymilyhq/mcp-server`](https://www.npmjs.com/package/@anonymilyhq/mcp-server) package. See the [MCP setup guide](https://anonymily.com/docs/mcp-setup) for configuration.
199
209
 
200
210
  ---
201
211
 
202
212
  ## Troubleshooting
203
213
 
204
214
  **`Connection refused` when forwarding:**
205
- - Make sure your local server is actually running on the specified port
206
- - Test with: `curl http://localhost:<port>`
215
+ Ensure your local server is running on the specified port. Test with `curl http://localhost:<port>`.
207
216
 
208
- **Requests arrive in CLI but local server returns errors:**
209
- - Check the status code logged: `└─ Forwarded to localhost | Status: 500`
210
- - Review your local server logs for the underlying cause
217
+ **Requests arrive but server returns errors:**
218
+ Check your server logs. The CLI shows `Status: 5xx` look at the response body in the dashboard for the error detail.
211
219
 
212
220
  **CLI doesn't reconnect after network drop:**
213
- - The CLI uses `eventsource` which auto-reconnects on drops
214
- - You'll see: `[Network] Connection dropped. Automatically reconnecting...`
221
+ The CLI uses `eventsource` which auto-reconnects. You'll see: `Connection dropped. Reconnecting...`
215
222
 
216
- **SSE stream appears stuck (no events):**
217
- - Confirm the hook ID exists: `curl https://api.anonymily.com/history/<hookId>`
218
- - Ensure you're sending requests to the correct endpoint URL shown in the CLI output
223
+ **401 on stream connection:**
224
+ Named endpoints require a valid PAT. Pass `--token <your-pat>` or set `ANONYMILY_TOKEN`.
225
+
226
+ **403 when using `--id` on a free account:**
227
+ Custom named endpoint IDs (`--id`) are a Pro feature. Remove `--id` to use a random free endpoint, or upgrade at [anonymily.com/upgrade](https://anonymily.com/upgrade).
219
228
 
220
229
  ---
221
230
 
222
231
  ## Environment Variables
223
232
 
224
233
  | Variable | Description |
225
- |----------|-------------|
234
+ |---|---|
235
+ | `ANONYMILY_TOKEN` | Personal Access Token for named endpoint authentication |
226
236
  | `ANONYMILY_API_URL` | Override the backend URL (default: `https://api.anonymily.com`) |
227
237
 
228
238
  ---
229
239
 
230
- ## Changelog
231
-
232
- See [CHANGELOG.md](CHANGELOG.md) for a detailed history of changes.
233
-
234
- ## Contributing
235
-
236
- Issues and pull requests are welcome. Open an issue first to discuss any significant changes.
237
-
238
240
  ## License
239
241
 
240
242
  MIT © [Anonymily](https://anonymily.com)
package/bin/cli.js CHANGED
@@ -6,6 +6,7 @@ import { generateHookId } from '../lib/utils.js';
6
6
  import { startForwarding } from '../lib/forwarder.js';
7
7
  import { logger } from '../lib/logger.js';
8
8
  import { submitFeedback } from '../lib/feedback.js';
9
+ import { listEvents, fireEvent, printEventList } from '../lib/trigger.js';
9
10
 
10
11
  // Point this to your live production backend URL
11
12
  const API_URL = process.env.ANONYMILY_API_URL || 'https://api.anonymily.com';
@@ -22,13 +23,179 @@ program
22
23
  .command('listen')
23
24
  .description('Listen for incoming webhooks and forward them to a local port')
24
25
  .argument('<port>', 'Local port to forward to (e.g., 8080)')
25
- .option('-i, --id <id>', '[Pro only] Use a custom endpoint ID instead of random (requires ₹750/month subscription)')
26
+ .option('-i, --id <id>', '[Pro] Use a custom named endpoint ID (requires Pro subscription anonymily.com/upgrade)')
27
+ // CLI-001: Accept a Personal Access Token for authenticating Pro hook streams
28
+ .option('-t, --token <token>', '[Pro only] Personal Access Token for authenticating to private hooks (or set ANONYMILY_TOKEN env var)')
26
29
  .action(async (port, options) => {
27
30
  const hookId = options.id || generateHookId();
28
- startForwarding(API_URL, hookId, parseInt(port, 10));
31
+ // CLI-001: Resolve token from --token flag or ANONYMILY_TOKEN env var
32
+ const token = options.token || process.env.ANONYMILY_TOKEN || null;
33
+ startForwarding(API_URL, hookId, parseInt(port, 10), token);
29
34
  });
30
35
 
31
36
 
37
+ // ---------------------------------------------------------------------------
38
+ // trigger
39
+ // ---------------------------------------------------------------------------
40
+ program
41
+ .command('trigger')
42
+ .description('[Pro] Fire a synthetic provider event at your webhook endpoint')
43
+ .argument('[provider]', 'Provider name (e.g. github, stripe, shopify, razorpay, slack)')
44
+ .argument('[event]', 'Event name (e.g. push, payment_intent.succeeded, order.created)')
45
+ .option('-i, --hook <hookId>', 'Target hook ID (defaults to a random ID)')
46
+ .option('-t, --token <token>', 'Personal Access Token (or set ANONYMILY_TOKEN env var)')
47
+ .option('-l, --list', 'List all available provider/event combinations and exit')
48
+ .action(async (provider, event, options) => {
49
+ const token = options.token || process.env.ANONYMILY_TOKEN || null;
50
+
51
+ // --list: show all available events
52
+ if (options.list || (!provider && !event)) {
53
+ const events = await listEvents(API_URL);
54
+ if (events) printEventList(events);
55
+ return;
56
+ }
57
+
58
+ if (!provider || !event) {
59
+ logger.error('Usage: anonymily trigger <provider> <event> --hook <hookId> [--token <pat>]');
60
+ logger.dim(' anonymily trigger --list');
61
+ process.exit(1);
62
+ }
63
+
64
+ const hookId = options.hook || null;
65
+ if (!hookId) {
66
+ logger.error('Specify a hook ID with --hook <hookId>. Example: --hook abc12345');
67
+ process.exit(1);
68
+ }
69
+
70
+ if (!token) {
71
+ logger.error('A Personal Access Token is required for the trigger command.');
72
+ logger.dim(' Use --token <pat> or set the ANONYMILY_TOKEN environment variable.');
73
+ logger.dim(' Generate a PAT from your dashboard at https://anonymily.com/account');
74
+ process.exit(1);
75
+ }
76
+
77
+ logger.dim(`Firing ${pc.bold(provider)} ${pc.bold(event)} at ${pc.cyan(`${API_URL}/h/${hookId}`)}...`);
78
+
79
+ try {
80
+ const result = await fireEvent(API_URL, hookId, provider, event, token);
81
+ logger.success(`\n✅ Event fired successfully!`);
82
+ logger.raw(` Provider: ${pc.bold(provider)}`);
83
+ logger.raw(` Event: ${pc.bold(event)}`);
84
+ logger.raw(` Hook: ${pc.cyan(`${API_URL}/h/${hookId}`)}`);
85
+ if (result.signatureAdded) {
86
+ logger.raw(` Signature: ${pc.green('✓ computed and attached')}`);
87
+ } else {
88
+ logger.dim(` Signature: not added (no signing secret configured for this hook)`);
89
+ }
90
+ logger.raw('');
91
+ } catch (err) {
92
+ logger.error(`\n❌ Failed to fire event: ${err.message}`);
93
+ if (err.message?.includes('Pro')) {
94
+ logger.dim(' Upgrade to Pro at https://anonymily.com/upgrade to use the trigger command.');
95
+ }
96
+ process.exit(1);
97
+ }
98
+ });
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // replay
102
+ // ---------------------------------------------------------------------------
103
+ program
104
+ .command('replay')
105
+ .description('[Pro] Re-fire a previously captured request back through your hook endpoint')
106
+ .argument('<hookId>', 'The hook ID that captured the request')
107
+ .argument('<requestId>', 'The request ID to replay (from the dashboard or anonymily requests <hookId>)')
108
+ .option('-t, --token <token>', 'Personal Access Token (or set ANONYMILY_TOKEN env var)')
109
+ .option('-m, --method <method>', 'Override the HTTP method (e.g. POST, GET)')
110
+ .option('-b, --body <json>', 'Override the request body (JSON string)')
111
+ .option('--resign', "Re-sign the payload with the hook's stored secret")
112
+ .action(async (hookId, requestId, options) => {
113
+ const token = options.token || process.env.ANONYMILY_TOKEN || null;
114
+ if (!token) {
115
+ logger.error('A Personal Access Token is required for the replay command.');
116
+ logger.dim(' Use --token <pat> or set the ANONYMILY_TOKEN environment variable.');
117
+ logger.dim(' Generate a PAT from your dashboard at https://anonymily.com/account');
118
+ process.exit(1);
119
+ }
120
+
121
+ let body;
122
+ if (options.body) {
123
+ try {
124
+ body = JSON.parse(options.body);
125
+ } catch {
126
+ logger.error("--body must be valid JSON. Example: --body '{\"event\":\"payment\"}'");
127
+ process.exit(1);
128
+ }
129
+ }
130
+
131
+ logger.dim(`Replaying request ${pc.bold(requestId)} on hook ${pc.cyan(`${API_URL}/h/${hookId}`)}...`);
132
+
133
+ // BUG-022: Look up the original captured request so we can replay its actual
134
+ // method/body/headers. Without this, the replay POST body was empty.
135
+ let originalReq = null;
136
+ try {
137
+ const reqRes = await fetch(`${API_URL}/v1/hooks/${hookId}/requests/${requestId}`, {
138
+ headers: { Authorization: `Bearer ${token}` },
139
+ });
140
+ if (reqRes.ok) {
141
+ originalReq = await reqRes.json();
142
+ } else {
143
+ logger.warn(`⚠️ Could not load original request (${reqRes.status}) — replay may have empty body.`);
144
+ }
145
+ } catch (fetchErr) {
146
+ logger.warn(`⚠️ Could not load original request: ${fetchErr.message} — replay may have empty body.`);
147
+ }
148
+
149
+ // CLI flags override stored values; stored values serve as defaults.
150
+ const replayMethod = options.method ?? originalReq?.method ?? undefined;
151
+ const replayBody = body !== undefined ? body : (originalReq?.body ?? undefined);
152
+ const replayHeaders = originalReq?.headers ?? undefined;
153
+
154
+ try {
155
+ const res = await fetch(`${API_URL}/v1/hooks/${hookId}/replay`, {
156
+ method: 'POST',
157
+ headers: {
158
+ 'Content-Type': 'application/json',
159
+ Authorization: `Bearer ${token}`,
160
+ },
161
+ body: JSON.stringify({
162
+ ...(replayMethod ? { method: replayMethod } : {}),
163
+ ...(replayBody !== undefined ? { body: replayBody } : {}),
164
+ ...(replayHeaders ? { headers: replayHeaders } : {}),
165
+ resign: !!options.resign,
166
+ }),
167
+ });
168
+
169
+ if (res.status === 401) {
170
+ logger.error('❌ Unauthorized: Invalid or expired Personal Access Token.');
171
+ process.exit(1);
172
+ }
173
+ if (res.status === 403) {
174
+ const data = await res.json().catch(() => ({}));
175
+ logger.error(`❌ ${data.message ?? 'Free tier allows 5 replays per day. Upgrade to Pro for unlimited replays.'}`);
176
+ logger.dim(' Upgrade at https://anonymily.com/upgrade');
177
+ process.exit(1);
178
+ }
179
+ if (!res.ok) {
180
+ const data = await res.json().catch(() => ({}));
181
+ logger.error(`❌ Replay failed: ${data.message ?? res.statusText}`);
182
+ process.exit(1);
183
+ }
184
+
185
+ const result = await res.json();
186
+ logger.success(`\n✅ Request replayed successfully!`);
187
+ logger.raw(` Hook: ${pc.cyan(`${API_URL}/h/${hookId}`)}`);
188
+ logger.raw(` Request: ${requestId}`);
189
+ if (result.signatureAdded) {
190
+ logger.raw(` Signature: ${pc.green('✓ re-signed with hook secret')}`);
191
+ }
192
+ logger.raw('');
193
+ } catch (err) {
194
+ logger.error(`\n❌ Failed to replay request: ${err.message}`);
195
+ process.exit(1);
196
+ }
197
+ });
198
+
32
199
  // ---------------------------------------------------------------------------
33
200
  // feedback
34
201
  // ---------------------------------------------------------------------------
package/lib/forwarder.js CHANGED
@@ -33,56 +33,163 @@ function ensureContentType(headers, body) {
33
33
  return headers;
34
34
  }
35
35
 
36
+ /**
37
+ * P1-05: Fetch the stable signing secret for a claimed hook.
38
+ * Requires a valid PAT (token). Returns null on any error.
39
+ */
40
+ async function fetchSigningSecret(apiUrl, hookId, token) {
41
+ try {
42
+ const res = await fetch(`${apiUrl}/users/hooks/${hookId}/secret`, {
43
+ headers: { Authorization: `Bearer ${token}` },
44
+ });
45
+ if (!res.ok) return null;
46
+ const { secret } = await res.json();
47
+ return secret ?? null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
36
53
  /**
37
54
  * Fetch tier information for a hook ID from the backend.
55
+ * Limits (maxRequests, retention) are derived from the live /pricing endpoint
56
+ * so the CLI stays in sync with server-side config without a reinstall.
38
57
  */
39
- async function fetchTierInfo(apiUrl, hookId) {
58
+ async function fetchTierInfo(apiUrl, hookId, token) {
40
59
  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
- };
60
+ // 1. Fetch hook history for current request count + tier.
61
+ // Custom hooks require auth after BUG-003 fix.
62
+ const historyHeaders = { 'Content-Type': 'application/json' };
63
+ if (token) historyHeaders['Authorization'] = `Bearer ${token}`;
64
+
65
+ const historyRes = await fetch(`${apiUrl}/history/${hookId}`, { headers: historyHeaders });
66
+ if (!historyRes.ok) return null;
67
+ const data = await historyRes.json();
68
+
69
+ // History returns a plain array; _tier is written per-item by the API.
70
+ const requestCount = Array.isArray(data) ? data.length : 0;
71
+ let tier = (Array.isArray(data) && data.length > 0 && data[0]._tier) || 'free';
72
+
73
+ // When history is empty, _tier can't be read from requests — fall back to /users/me
74
+ if (tier === 'free' && token && requestCount === 0) {
75
+ try {
76
+ const meRes = await fetch(`${apiUrl}/users/me`, {
77
+ headers: { Authorization: `Bearer ${token}` },
78
+ });
79
+ if (meRes.ok) {
80
+ const me = await meRes.json();
81
+ if (me.isPro) tier = 'pro';
82
+ }
83
+ } catch { /* keep 'free' as safe fallback */ }
84
+ }
85
+
86
+ // 2. Fetch live limits from /pricing endpoint
87
+ let maxRequests = tier === 'pro' ? 2000 : 200; // conservative fallback
88
+ let retention = tier === 'pro' ? '30 days' : '48 hours';
89
+ let proMaxRequests = 2000; // used in upgrade messaging
90
+
91
+ try {
92
+ const pricingRes = await fetch(`${apiUrl}/pricing`);
93
+ if (pricingRes.ok) {
94
+ const pricing = await pricingRes.json();
95
+ proMaxRequests = pricing.pro?.features?.requestCap ?? proMaxRequests;
96
+ if (tier === 'pro') {
97
+ maxRequests = proMaxRequests;
98
+ const hours = pricing.pro?.features?.retentionHours ?? 720;
99
+ retention = `${hours / 24} days`;
100
+ } else {
101
+ maxRequests = pricing.free?.features?.requestCap ?? maxRequests;
102
+ const hours = pricing.free?.features?.retentionHours ?? 48;
103
+ retention = `${hours} hours`;
104
+ }
105
+ }
106
+ } catch { /* silently keep fallback values */ }
107
+
108
+ return { tier, requestCount, maxRequests, retention, proMaxRequests };
51
109
  } catch {
52
110
  return null;
53
111
  }
54
112
  }
55
113
 
56
- export async function startForwarding(apiUrl, hookId, port) {
114
+
115
+ /**
116
+ * CLI-001: Build EventSource constructor options from an optional PAT.
117
+ *
118
+ * Returns an options object with an `Authorization: Bearer <token>` header
119
+ * when `token` is a non-empty string, or an empty object otherwise.
120
+ * Exported for unit testing.
121
+ *
122
+ * @param {string|null} token - Personal Access Token, or null/undefined.
123
+ * @returns {object} EventSource options (may have a `headers` field).
124
+ */
125
+ export function buildEventSourceOptions(token) {
126
+ if (token) {
127
+ return { headers: { Authorization: `Bearer ${token}` } };
128
+ }
129
+ return {};
130
+ }
131
+
132
+ /**
133
+ * CLI-001: Start forwarding webhooks from the Anonymily SSE stream to a local port.
134
+ *
135
+ * @param {string} apiUrl - Base URL of the Anonymily backend API.
136
+ * @param {string} hookId - The webhook endpoint ID to listen on.
137
+ * @param {number} port - Local port to forward received requests to.
138
+ * @param {string|null} token - Optional Personal Access Token for Pro hook authentication.
139
+ * When provided, sent as `Authorization: Bearer <token>`.
140
+ * Required for privately-claimed Pro hooks; optional for public hooks.
141
+ */
142
+ export async function startForwarding(apiUrl, hookId, port, token = null) {
57
143
  const webhookUrl = `${apiUrl}/h/${hookId}`;
58
144
  const localUrl = `http://127.0.0.1:${port}`;
59
145
 
60
146
  logger.success(pc.bold(`\n🚀 Anonymily CLI is running!`));
61
147
  logger.raw(`\nForwarding: ${pc.cyan(webhookUrl)} ➔ ${pc.cyan(localUrl)}`);
62
148
 
149
+ // CLI-001: Inform user about authentication status
150
+ if (token) {
151
+ logger.dim(`🔐 Authenticated with Personal Access Token`);
152
+ }
153
+
154
+ // P1-05: Display stable signing secret for claimed (custom) hooks
155
+ const isCustomEndpoint = !/^[a-z0-9]{8}$/.test(hookId);
156
+ if (token && isCustomEndpoint) {
157
+ const secret = await fetchSigningSecret(apiUrl, hookId, token);
158
+ if (secret) {
159
+ logger.raw(`\n${pc.bold('Signing Secret')} (use in your app to verify requests):`);
160
+ logger.raw(` ${pc.cyan(`whsec_${secret}`)}\n`);
161
+ }
162
+ }
163
+
63
164
  // Fetch and display tier information
64
- const tierInfo = await fetchTierInfo(apiUrl, hookId);
165
+ const tierInfo = await fetchTierInfo(apiUrl, hookId, token);
65
166
  if (tierInfo) {
66
167
  const tierColor = tierInfo.tier === 'pro' ? pc.green : pc.yellow;
67
168
  const tierBadge = tierInfo.tier === 'pro' ? '✨ PRO' : '🆓 FREE';
68
169
  logger.raw(`Tier: ${tierColor(pc.bold(tierBadge))} | Storage: ${tierInfo.requestCount}/${tierInfo.maxRequests} | Retention: ${tierInfo.retention}`);
69
170
 
70
171
  if (tierInfo.tier === 'free') {
71
- logger.dim(`\n💡 Upgrade to Pro for custom endpoint IDs + 500 req/30d at https://anonymily.com/upgrade`);
172
+ logger.dim(`\n💡 Upgrade to Pro for custom endpoint IDs + ${tierInfo.maxRequests} req/${tierInfo.retention} at https://anonymily.com/upgrade`);
72
173
  }
73
174
  }
74
175
 
75
176
  logger.dim(`Waiting for requests to arrive...`);
76
177
  logger.dim(`💬 Enjoying the CLI? Run 'anonymily feedback 5' to let us know!\n`);
77
178
 
78
- const es = new EventSourceConstructor(`${apiUrl}/stream/${hookId}`);
179
+ // CLI-001: Build EventSource options — attach Authorization header when token is provided
180
+ const esOptions = buildEventSourceOptions(token);
181
+
182
+ const es = new EventSourceConstructor(`${apiUrl}/stream/${hookId}`, esOptions);
79
183
 
80
184
  let requestCount = tierInfo?.requestCount || 0;
81
185
  const maxRequests = tierInfo?.maxRequests || 50;
186
+ const proMaxRequests = tierInfo?.proMaxRequests ?? 2000;
82
187
 
83
188
  es.onmessage = async (event) => {
84
189
  try {
85
190
  const reqData = JSON.parse(event.data);
191
+ // Skip internal SSE events (e.g. response_update from P1-03 capture)
192
+ if (reqData._type) return;
86
193
  const { method, headers, query, body } = reqData;
87
194
 
88
195
  // Strip hop-by-hop headers that must not be forwarded
@@ -105,30 +212,97 @@ export async function startForwarding(apiUrl, hookId, port) {
105
212
 
106
213
  logger.warn(`[${new Date().toLocaleTimeString()}] ⚡ Incoming ${method} request ${progressColor(progress)}...`);
107
214
 
108
- const response = await fetch(fullLocalUrl, {
109
- method,
110
- headers: finalHeaders,
111
- body: serializedBody,
112
- });
215
+ const t0 = Date.now();
216
+ let response;
217
+ let captureError = null;
218
+ try {
219
+ response = await fetch(fullLocalUrl, {
220
+ method,
221
+ headers: finalHeaders,
222
+ body: serializedBody,
223
+ signal: AbortSignal.timeout(30_000),
224
+ });
225
+ } catch (fetchErr) {
226
+ captureError = fetchErr.name === 'TimeoutError' ? 'timeout' : 'unreachable';
227
+ }
228
+ const latencyMs = Date.now() - t0;
229
+
230
+ if (captureError) {
231
+ logger.error(` └─ ${captureError === 'timeout' ? 'Timeout' : 'Unreachable'} after ${latencyMs}ms. Make sure your local server is running on port ${port}\n`);
232
+ // P1-03: report connection failure
233
+ if (reqData.id) {
234
+ fetch(`${apiUrl}/response/${reqData.id}`, {
235
+ method: 'POST',
236
+ headers: { 'Content-Type': 'application/json' },
237
+ body: JSON.stringify({ error: captureError, latencyMs }),
238
+ }).catch(() => {});
239
+ }
240
+ } else {
241
+ const statusColor = response.ok ? pc.green : pc.red;
242
+ logger.raw(` └─ Forwarded to localhost | Status: ${statusColor(response.status)} | ${latencyMs}ms\n`);
243
+
244
+ // P1-03: capture response details and send back to API
245
+ if (reqData.id) {
246
+ let responseBody = '';
247
+ try {
248
+ const clone = response.clone();
249
+ const buf = await clone.arrayBuffer();
250
+ const bytes = buf.byteLength;
251
+ if (bytes <= 100_000) {
252
+ const ct = response.headers.get('content-type') ?? '';
253
+ responseBody = ct.includes('text') || ct.includes('json')
254
+ ? await response.text()
255
+ : `[binary ${bytes} bytes]`;
256
+ } else {
257
+ responseBody = `[truncated — ${bytes} bytes]`;
258
+ }
259
+ } catch {}
113
260
 
114
- const statusColor = response.ok ? pc.green : pc.red;
115
- logger.raw(` └─ Forwarded to localhost | Status: ${statusColor(response.status)}\n`);
261
+ const responseHeaders = {};
262
+ response.headers.forEach((v, k) => { responseHeaders[k] = v; });
263
+
264
+ fetch(`${apiUrl}/response/${reqData.id}`, {
265
+ method: 'POST',
266
+ headers: { 'Content-Type': 'application/json' },
267
+ body: JSON.stringify({
268
+ status: response.status,
269
+ headers: responseHeaders,
270
+ body: responseBody,
271
+ latencyMs,
272
+ }),
273
+ }).catch(() => {});
274
+ }
275
+ }
116
276
 
117
277
  // Warn when approaching tier limit
118
278
  if (requestCount === maxRequests) {
119
279
  logger.warn(`⚠️ Tier limit reached (${maxRequests} requests). Older requests will be purged.\n`);
120
280
  } else if (requestCount === Math.floor(maxRequests * 0.9)) {
121
- logger.warn(`⚠️ 90% of tier limit reached. Upgrade to Pro for 500 requests at https://anonymily.com/upgrade\n`);
281
+ logger.warn(`⚠️ 90% of tier limit reached. Upgrade to Pro for ${proMaxRequests} requests at https://anonymily.com/upgrade\n`);
122
282
  }
123
283
  } catch (err) {
124
- logger.error(` └─ Error forwarding: ${err.message}`);
125
- logger.dim(` Make sure your local server is actually running on port ${port}\n`);
284
+ logger.error(` └─ Unexpected error: ${err.message}\n`);
126
285
  }
127
286
  };
128
287
 
129
- es.onerror = () => {
288
+ es.onerror = (err) => {
289
+ // eventsource v4 uses err.code for HTTP status, not err.status
290
+ if (err && err.code === 401) {
291
+ logger.error(`\n❌ Unauthorized: This hook requires a valid Personal Access Token.`);
292
+ logger.dim(` Use --token <your-pat> or set the ANONYMILY_TOKEN environment variable.`);
293
+ logger.dim(` Generate a PAT from your dashboard at https://anonymily.com/account\n`);
294
+ es.close();
295
+ process.exit(1);
296
+ }
297
+ if (err && err.code === 403) {
298
+ logger.error(`\n❌ Forbidden: Custom endpoint IDs require a Pro subscription.`);
299
+ logger.dim(` Remove --id to use a random free endpoint, or upgrade at https://anonymily.com/upgrade\n`);
300
+ es.close();
301
+ process.exit(1);
302
+ }
130
303
  logger.dim(`\n[Network] Connection dropped. Automatically reconnecting...`);
131
304
  };
132
305
 
133
306
  return es;
134
307
  }
308
+
package/lib/trigger.js ADDED
@@ -0,0 +1,76 @@
1
+ import pc from 'picocolors';
2
+ import { logger } from './logger.js';
3
+
4
+ /**
5
+ * P2-02: List available synthetic event templates from the API.
6
+ * @returns {Promise<Array<{provider: string, event: string, description: string}>>}
7
+ */
8
+ export async function listEvents(apiUrl) {
9
+ try {
10
+ const res = await fetch(`${apiUrl}/trigger/events`);
11
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
12
+ return await res.json();
13
+ } catch (err) {
14
+ logger.error(`Failed to fetch event list: ${err.message}`);
15
+ return null;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * P2-02: Fire a synthetic event at a hook endpoint.
21
+ * @param {string} apiUrl
22
+ * @param {string} hookId
23
+ * @param {string} provider e.g. 'github'
24
+ * @param {string} event e.g. 'push'
25
+ * @param {string|null} token PAT for Pro auth
26
+ */
27
+ export async function fireEvent(apiUrl, hookId, provider, event, token) {
28
+ const res = await fetch(`${apiUrl}/trigger/${hookId}`, {
29
+ method: 'POST',
30
+ headers: {
31
+ 'Content-Type': 'application/json',
32
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
33
+ },
34
+ body: JSON.stringify({ provider, event }),
35
+ signal: AbortSignal.timeout(20_000),
36
+ });
37
+
38
+ if (!res.ok) {
39
+ let errMsg = `HTTP ${res.status}`;
40
+ try {
41
+ const body = await res.json();
42
+ errMsg = body.message ?? errMsg;
43
+ } catch {}
44
+ throw new Error(errMsg);
45
+ }
46
+
47
+ return res.json();
48
+ }
49
+
50
+ /**
51
+ * Pretty-print the event list in a table grouped by provider.
52
+ */
53
+ export function printEventList(events) {
54
+ if (!events || events.length === 0) {
55
+ logger.error('No events available.');
56
+ return;
57
+ }
58
+
59
+ // Group by provider
60
+ const byProvider = {};
61
+ for (const e of events) {
62
+ (byProvider[e.provider] ??= []).push(e);
63
+ }
64
+
65
+ logger.raw(`\n${pc.bold('Available synthetic events')}\n`);
66
+ logger.raw(` Usage: ${pc.cyan('anonymily trigger <provider> <event> --hook <hookId> --token <pat>')}\n`);
67
+
68
+ for (const [provider, evts] of Object.entries(byProvider)) {
69
+ logger.raw(` ${pc.bold(pc.green(provider))}`);
70
+ for (const e of evts) {
71
+ const paddedEvent = e.event.padEnd(36);
72
+ logger.raw(` ${pc.cyan(paddedEvent)} ${pc.dim(e.description)}`);
73
+ }
74
+ logger.raw('');
75
+ }
76
+ }
package/lib/utils.js CHANGED
@@ -1,3 +1,8 @@
1
1
  export function generateHookId() {
2
- return Math.random().toString(36).substring(2, 10);
2
+ const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
3
+ let id = '';
4
+ for (let i = 0; i < 8; i++) {
5
+ id += chars[Math.floor(Math.random() * chars.length)];
6
+ }
7
+ return id;
3
8
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anonymilyhq/cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "CLI for Anonymily platform",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node ./bin/cli.js",
11
+ "build": "echo 'CLI built successfully' && ls ./bin/cli.js",
11
12
  "test": "node --experimental-vm-modules node_modules/.bin/jest --no-coverage",
12
13
  "test:coverage": "node --experimental-vm-modules node_modules/.bin/jest --coverage"
13
14
  },