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