@anonymilyhq/cli 1.1.1 → 1.3.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
@@ -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,30 +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 (50 requests, 24h retention).
42
-
43
43
  ```bash
44
- # Start listening — generates a random 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
- # 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...
52
51
  ```
53
52
 
54
53
  Send a test payload:
55
54
  ```bash
56
55
  curl -X POST https://api.anonymily.com/h/xk92bzte \
57
56
  -H "Content-Type: application/json" \
58
- -d '{"event": "payment.success"}'
57
+ -d '{"event": "payment.succeeded", "amount": 9900}'
59
58
  ```
60
59
 
61
- You'll see in the CLI:
60
+ CLI output:
62
61
  ```
63
- [12:01:45] Incoming POST request...
64
- └─ Forwarded to localhost | Status: 200
62
+ [12:01:45] Incoming POST → localhost:3000 | 200 OK | 11ms
65
63
  ```
66
64
 
67
65
  ---
@@ -70,182 +68,147 @@ You'll see in the CLI:
70
68
 
71
69
  ### `listen <port>`
72
70
 
73
- Listen for incoming webhooks and forward them to a local port.
71
+ Listen for incoming webhooks and forward them to your local port.
74
72
 
75
73
  ```bash
76
- anonymily listen <port> [options]
74
+ npx @anonymilyhq/cli listen <port> [options]
77
75
  ```
78
76
 
79
77
  | 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 |
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 (ephemeral)
86
+ # Free tier — random endpoint, no auth
88
87
  npx @anonymilyhq/cli listen 3000
89
88
 
90
- # Custom named endpoint api.anonymily.com/h/stripe-dev
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
- # Pro-tier named endpoint (legacy hook-based Pro)
94
- npx @anonymilyhq/cli listen 3000 --id stripe-dev --pro
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
- ---
98
-
99
- ### `config set-key <key>`
97
+ ### `replay <hookId> <requestId>`
100
98
 
101
- Save your Pro API key locally so you don't need to export it every session.
99
+ 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).
102
100
 
103
101
  ```bash
104
- anonymily config set-key <your-pro-api-key>
102
+ npx @anonymilyhq/cli replay <hookId> <requestId> [options]
105
103
  ```
106
104
 
107
- The key is stored in `~/.anonymily/config.json`. You can also set it via environment variable:
105
+ | Option | Description |
106
+ |---|---|
107
+ | `<hookId>` | **Required.** The hook ID that captured the request. |
108
+ | `<requestId>` | **Required.** The request ID to replay (visible in the dashboard). |
109
+ | `-t, --token <token>` | **Required.** PAT — or set `ANONYMILY_TOKEN`. |
110
+ | `-m, --method <method>` | Override the HTTP method (e.g. `POST`, `GET`). |
111
+ | `-b, --body <json>` | Override the request body (JSON string). |
112
+ | `--resign` | Re-sign the payload using the hook's stored secret. |
113
+
114
+ **Examples:**
108
115
 
109
116
  ```bash
110
- export PRO_API_KEY=<your-pro-api-key>
111
- ```
117
+ # Basic replay
118
+ npx @anonymilyhq/cli replay stripe-dev abc-123 --token pat_example123
112
119
 
113
- ---
120
+ # Replay with body override and re-signing
121
+ npx @anonymilyhq/cli replay stripe-dev abc-123 \
122
+ --body '{"amount":5000}' --resign --token pat_example123
123
+ ```
114
124
 
115
125
  ### `feedback <rating> [message]`
116
126
 
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
+ Submit feedback from the terminal.
127
128
 
128
- **Examples:**
129
129
  ```bash
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!"
130
+ npx @anonymilyhq/cli feedback 5 "Works perfectly with Shopify webhooks"
138
131
  ```
139
132
 
140
- Your feedback helps us improve Anonymily. All submissions are anonymous unless you're logged in via the web dashboard.
141
-
142
- ---
143
-
144
- ## Upgrading to Pro (₹750/month)
145
-
146
- To get Pro limits (500 requests, 30-day retention), you need a **paid Pro subscription** at [anonymily.com/upgrade](https://anonymily.com/upgrade). This is an account-level subscription — all your claimed hooks get Pro limits automatically.
147
-
148
- **Workflow:**
149
- 1. Create an account at [anonymily.com/signup](https://anonymily.com/signup) (free)
150
- 2. Subscribe to Pro at [anonymily.com/upgrade](https://anonymily.com/upgrade) (₹750/month)
151
- 3. Open the dashboard, generate or use a custom hook ID
152
- 4. Click "Claim for Pro" to associate the hook with your account
153
- 5. Run the CLI with the same hook ID:
154
- ```bash
155
- npx @anonymilyhq/cli listen 3000 --id your-claimed-hook
156
- ```
157
- 6. All Pro limits apply automatically — 500 requests, 30-day history
158
-
159
- **Note:** Account creation is free, but Pro features require a paid subscription.
160
-
161
133
  ---
162
134
 
163
135
  ## How It Works
164
136
 
165
137
  ```
166
- External Service (Stripe, GitHub, etc.)
138
+ External Service (Stripe, GitHub, Shopify, …)
167
139
 
168
140
  │ POST https://api.anonymily.com/h/<hookId>
169
141
 
170
- Anonymily Backend (NestJS + Redis + Supabase)
142
+ Anonymily Backend captures, verifies signature, stores in Redis
171
143
 
172
144
  │ SSE broadcast via /stream/<hookId>
173
145
 
174
146
  @anonymilyhq/cli ──forward──► http://localhost:<port>
147
+
148
+ │ Response (status, latency, body) streamed back to dashboard
149
+
150
+ Anonymily Dashboard — shows full exchange in real time
175
151
  ```
176
152
 
177
- 1. The CLI opens a persistent SSE connection to `/stream/<hookId>`
178
- 2. When a webhook arrives, the backend broadcasts it instantly
179
- 3. The CLI receives the event and re-issues the exact same HTTP request to your local port
180
- 4. Method, headers, query params, and body are all preserved 1:1
153
+ 1. The CLI opens a persistent SSE connection to the backend.
154
+ 2. When a webhook arrives, it is broadcast instantly.
155
+ 3. The CLI re-issues the exact HTTP request to your local port, preserving method, headers, query params, and body.
156
+ 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.
181
157
 
182
158
  ---
183
159
 
184
- ## Tier Comparison
160
+ ## Tiers
185
161
 
186
162
  | Feature | Free | Pro |
187
- |---------|------|-----|
188
- | Requests stored per hook | 50 | 500 |
189
- | History retention | 24 hours | 30 days |
190
- | Custom endpoint ID | | |
191
- | Persistent history in dashboard | | |
192
- | Price | Free | ₹750/month |
163
+ |---|---|---|
164
+ | Endpoint ID | Random 8-char | Custom named (`--id`) |
165
+ | Requests per hook | 200 | 2,000 |
166
+ | History retention | 48 hours | 30 days |
167
+ | Named (persistent) endpoints | | Unlimited |
168
+ | Replay (`replay` command) | 5/day | Unlimited |
169
+ | Signature verification UI | — | ✓ |
170
+ | AI diagnosis + handler gen | — | ✓ (dashboard) |
171
+ | AI edge-case events | — | ✓ (dashboard) |
172
+ | Price | Free | $9 / ₹750 per month |
173
+
174
+ Upgrade at [anonymily.com/upgrade](https://anonymily.com/upgrade).
193
175
 
194
176
  ---
195
177
 
196
- ## Testing Locally
178
+ ## MCP Server
197
179
 
198
- A sample target server is included to test forwarding without a real local app:
199
-
200
- ```bash
201
- node sample-target-server.js
202
- # Listens on http://localhost:4000 and logs all incoming requests
203
- ```
204
-
205
- Then in another terminal:
206
- ```bash
207
- npx @anonymilyhq/cli listen 4000
208
- ```
180
+ If you use Claude Code, you can drive Anonymily entirely from your AI assistant using the `@anonymilyhq/mcp-server` package. See the [MCP Server README](../../packages/mcp-server/README.md) for setup.
209
181
 
210
182
  ---
211
183
 
212
184
  ## Troubleshooting
213
185
 
214
186
  **`Connection refused` when forwarding:**
215
- - Make sure your local server is actually running on the specified port
216
- - Test with: `curl http://localhost:<port>`
187
+ Ensure your local server is running on the specified port. Test with `curl http://localhost:<port>`.
217
188
 
218
- **Requests arrive in CLI but local server returns errors:**
219
- - Check the status code logged: `└─ Forwarded to localhost | Status: 500`
220
- - Review your local server logs for the underlying cause
189
+ **Requests arrive but server returns errors:**
190
+ Check your server logs. The CLI shows `Status: 5xx` look at the response body in the dashboard for the error detail.
221
191
 
222
192
  **CLI doesn't reconnect after network drop:**
223
- - The CLI uses `eventsource` which auto-reconnects on drops
224
- - You'll see: `[Network] Connection dropped. Automatically reconnecting...`
193
+ The CLI uses `eventsource` which auto-reconnects. You'll see: `Connection dropped. Reconnecting...`
225
194
 
226
- **SSE stream appears stuck (no events):**
227
- - Confirm the hook ID exists: `curl https://api.anonymily.com/history/<hookId>`
228
- - Ensure you're sending requests to the correct endpoint URL shown in the CLI output
195
+ **401 on stream connection:**
196
+ Named endpoints require a valid PAT. Pass `--token <your-pat>` or set `ANONYMILY_TOKEN`.
197
+
198
+ **403 when using `--id` on a free account:**
199
+ 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).
229
200
 
230
201
  ---
231
202
 
232
203
  ## Environment Variables
233
204
 
234
205
  | Variable | Description |
235
- |----------|-------------|
236
- | `PRO_API_KEY` | Legacy Pro API key (overrides `~/.anonymily/config.json`) |
206
+ |---|---|
207
+ | `ANONYMILY_TOKEN` | Personal Access Token for named endpoint authentication |
237
208
  | `ANONYMILY_API_URL` | Override the backend URL (default: `https://api.anonymily.com`) |
238
209
 
239
210
  ---
240
211
 
241
- ## Changelog
242
-
243
- See [CHANGELOG.md](CHANGELOG.md) for a detailed history of changes.
244
-
245
- ## Contributing
246
-
247
- Issues and pull requests are welcome. Open an issue first to discuss any significant changes.
248
-
249
212
  ## License
250
213
 
251
214
  MIT © [Anonymily](https://anonymily.com)
package/bin/cli.js CHANGED
@@ -4,15 +4,9 @@ import { program } from 'commander';
4
4
  import pc from 'picocolors';
5
5
  import { generateHookId } from '../lib/utils.js';
6
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
7
  import { logger } from '../lib/logger.js';
15
8
  import { submitFeedback } from '../lib/feedback.js';
9
+ import { listEvents, fireEvent, printEventList } from '../lib/trigger.js';
16
10
 
17
11
  // Point this to your live production backend URL
18
12
  const API_URL = process.env.ANONYMILY_API_URL || 'https://api.anonymily.com';
@@ -20,7 +14,7 @@ const API_URL = process.env.ANONYMILY_API_URL || 'https://api.anonymily.com';
20
14
  program
21
15
  .name('anonymily')
22
16
  .description('Forward webhooks from Anonymily directly to your local machine.')
23
- .version('1.1.1');
17
+ .version('1.2.0');
24
18
 
25
19
  // ---------------------------------------------------------------------------
26
20
  // listen
@@ -29,65 +23,177 @@ program
29
23
  .command('listen')
30
24
  .description('Listen for incoming webhooks and forward them to a local port')
31
25
  .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
- )
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)')
37
29
  .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
- );
30
+ const hookId = options.id || generateHookId();
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);
34
+ });
35
+
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');
45
61
  process.exit(1);
46
62
  }
47
63
 
48
- const hookId = options.id || generateHookId();
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
+ }
49
69
 
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
- }
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);
65
75
  }
66
76
 
67
- startForwarding(API_URL, hookId, parseInt(port, 10));
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
+ }
68
98
  });
69
99
 
70
100
  // ---------------------------------------------------------------------------
71
- // config
101
+ // replay
72
102
  // ---------------------------------------------------------------------------
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.');
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}`);
85
195
  process.exit(1);
86
196
  }
87
- writeConfig({ proApiKey: key.trim() });
88
- logger.success(
89
- `Pro API key saved to ${CONFIG_PATH}`,
90
- );
91
197
  });
92
198
 
93
199
  // ---------------------------------------------------------------------------
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 500 requests + 30-day retention 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. Consider upgrading to Pro for 500 requests.\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.1.1",
3
+ "version": "1.3.0",
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
  },
package/lib/config.js DELETED
@@ -1,70 +0,0 @@
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
-
@@ -1,100 +0,0 @@
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
-