@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 +83 -120
- package/bin/cli.js +160 -54
- package/lib/forwarder.js +200 -26
- package/lib/trigger.js +76 -0
- package/lib/utils.js +6 -1
- package/package.json +2 -1
- package/lib/config.js +0 -70
- package/lib/proRegister.js +0 -100
package/README.md
CHANGED
|
@@ -14,17 +14,19 @@
|
|
|
14
14
|
|
|
15
15
|
## What is it?
|
|
16
16
|
|
|
17
|
-
The Anonymily CLI connects to your
|
|
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
|
|
19
|
+
**No account required for the free tier. One command.**
|
|
20
20
|
|
|
21
|
-
|
|
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
|
|
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
|
-
#
|
|
49
|
-
# Forwarding: https://api.anonymily.com/h/xk92bzte
|
|
50
|
-
# Tier:
|
|
51
|
-
# Waiting for requests
|
|
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.
|
|
57
|
+
-d '{"event": "payment.succeeded", "amount": 9900}'
|
|
59
58
|
```
|
|
60
59
|
|
|
61
|
-
|
|
60
|
+
CLI output:
|
|
62
61
|
```
|
|
63
|
-
[12:01:45]
|
|
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
|
|
71
|
+
Listen for incoming webhooks and forward them to your local port.
|
|
74
72
|
|
|
75
73
|
```bash
|
|
76
|
-
|
|
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
|
|
82
|
-
| `-i, --id <id>` | Use a
|
|
83
|
-
|
|
|
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
|
-
#
|
|
86
|
+
# Free tier — random endpoint, no auth
|
|
88
87
|
npx @anonymilyhq/cli listen 3000
|
|
89
88
|
|
|
90
|
-
#
|
|
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
|
-
#
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
+
npx @anonymilyhq/cli replay <hookId> <requestId> [options]
|
|
105
103
|
```
|
|
106
104
|
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
138
|
+
External Service (Stripe, GitHub, Shopify, …)
|
|
167
139
|
│
|
|
168
140
|
│ POST https://api.anonymily.com/h/<hookId>
|
|
169
141
|
▼
|
|
170
|
-
Anonymily Backend
|
|
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
|
|
178
|
-
2. When a webhook arrives,
|
|
179
|
-
3. The CLI
|
|
180
|
-
4.
|
|
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
|
-
##
|
|
160
|
+
## Tiers
|
|
185
161
|
|
|
186
162
|
| Feature | Free | Pro |
|
|
187
|
-
|
|
188
|
-
|
|
|
189
|
-
|
|
|
190
|
-
|
|
|
191
|
-
|
|
|
192
|
-
|
|
|
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
|
-
##
|
|
178
|
+
## MCP Server
|
|
197
179
|
|
|
198
|
-
|
|
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
|
-
|
|
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
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
| `
|
|
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.
|
|
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>', '
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
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 (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
101
|
+
// replay
|
|
72
102
|
// ---------------------------------------------------------------------------
|
|
73
|
-
|
|
74
|
-
.command('
|
|
75
|
-
.description('
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
.
|
|
79
|
-
.
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
.action((
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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.
|
|
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(` └─
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
package/lib/proRegister.js
DELETED
|
@@ -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
|
-
|