@toolrelay/cli 1.0.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 ADDED
@@ -0,0 +1,461 @@
1
+ # @toolrelay/cli
2
+
3
+ CLI for [ToolRelay](https://toolrelay.io) — define your API tools in a `toolrelay.json` config file, validate it, serve a local MCP server, and deploy to the cloud. Every tool call produces a detailed audit trace.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ # Interactive wizard — walks you through app and tools
9
+ npx @toolrelay/cli init
10
+
11
+ # Or skip prompts with a starter template
12
+ npx @toolrelay/cli init --yes
13
+
14
+ # Validate config
15
+ npx @toolrelay/cli validate toolrelay.json
16
+
17
+ # Start a local MCP server for Claude Desktop
18
+ npx @toolrelay/cli serve toolrelay.json
19
+
20
+ # Deploy to ToolRelay
21
+ npx @toolrelay/cli login
22
+ npx @toolrelay/cli publish toolrelay.json
23
+ ```
24
+
25
+ ## Config file format (`toolrelay.json`)
26
+
27
+ The `init` wizard generates this file for you, or you can create it manually:
28
+
29
+ ```json
30
+ {
31
+ "app": {
32
+ "name": "My API",
33
+ "base_url": "http://localhost:3000",
34
+ "auth_type": "static_token",
35
+ "auth_config": { "token": "my-dev-key" }
36
+ },
37
+ "tools": [
38
+ {
39
+ "name": "get_user",
40
+ "description": "Get a user by ID",
41
+ "http_method": "GET",
42
+ "endpoint_path": "/api/users/{id}",
43
+ "parameter_mapping": [
44
+ { "name": "id", "type": "string", "required": true, "target": "path", "description": "The user ID" }
45
+ ]
46
+ }
47
+ ]
48
+ }
49
+ ```
50
+
51
+ > **Tip:** `description` on both tools and parameters is how AI agents understand what your API does. Always include clear descriptions — they directly affect how well agents use your tools.
52
+
53
+ ### Environment variable interpolation
54
+
55
+ Use `${VAR_NAME}` syntax to reference environment variables in any string field. This keeps secrets out of your config file:
56
+
57
+ ```json
58
+ {
59
+ "app": {
60
+ "base_url": "${API_BASE_URL}",
61
+ "auth_type": "static_token",
62
+ "auth_config": { "token": "${API_TOKEN}" }
63
+ }
64
+ }
65
+ ```
66
+
67
+ ```bash
68
+ export API_BASE_URL=http://localhost:3000
69
+ export API_TOKEN=my-secret-key
70
+ npx @toolrelay/cli serve toolrelay.json
71
+ ```
72
+
73
+ If a referenced variable is missing, the CLI fails with a clear error listing the missing variable names. This works for `base_url`, `auth_config` values, `headers_template`, and any other string field.
74
+
75
+ ### Auth types
76
+
77
+ | `auth_type` | `auth_config` fields | Description |
78
+ |---|---|---|
79
+ | `none` | — | No authentication |
80
+ | `static_token` | `{ "token": "..." }` | Bearer token in Authorization header |
81
+ | `api_key_relay` | `{ "header_name": "X-API-Key" }` | Consumer API keys relayed to backend |
82
+ | `custom_header` | `{ "headers": { "X-Custom": "value" } }` | Custom headers with static values |
83
+ | `oauth2` | `{ "authorize_url", "token_url", "client_id"?, "client_secret"?, "scopes"?, "use_pkce"? }` | OAuth 2.0 authorization code flow (see [OAuth2 section](#oauth2-local-testing)) |
84
+
85
+ ### Parameter mapping
86
+
87
+ Each parameter in `parameter_mapping` specifies:
88
+
89
+ | Field | Required | Description |
90
+ |---|---|---|
91
+ | `name` | yes | Parameter name (what the AI agent sends) |
92
+ | `type` | yes | `string`, `number`, `boolean`, `object`, or `array` |
93
+ | `required` | yes | Whether the parameter is required |
94
+ | `target` | yes | Where the parameter goes: `path`, `query`, `body`, or `header` |
95
+ | `backend_key` | no | Backend field name if different from `name` |
96
+ | `default` | no | Default value when not provided |
97
+ | `description` | no | Human-readable description (shown to AI agents) |
98
+
99
+ #### Path parameters
100
+
101
+ Path parameters replace `{placeholder}` segments in the `endpoint_path`. The parameter `name` (or `backend_key` if set) must match the placeholder name.
102
+
103
+ ```json
104
+ {
105
+ "name": "create-order",
106
+ "http_method": "POST",
107
+ "endpoint_path": "/api/customers/{customer_id}/orders",
108
+ "parameter_mapping": [
109
+ { "name": "customer_id", "type": "string", "required": true, "target": "path" }
110
+ ]
111
+ }
112
+ ```
113
+
114
+ Agent sends `{ "customer_id": "cust_42" }` → request goes to `/api/customers/cust_42/orders`.
115
+
116
+ #### Query parameters
117
+
118
+ Query parameters are appended as `?key=value` to the URL. Use `backend_key` when the backend expects a different name than what the agent sends, and `default` for optional filters.
119
+
120
+ ```json
121
+ {
122
+ "name": "search_products",
123
+ "http_method": "GET",
124
+ "endpoint_path": "/api/products",
125
+ "parameter_mapping": [
126
+ { "name": "query", "type": "string", "required": true, "target": "query", "backend_key": "q" },
127
+ { "name": "category", "type": "string", "required": false, "target": "query" },
128
+ { "name": "page", "type": "number", "required": false, "target": "query", "default": 1 },
129
+ { "name": "limit", "type": "number", "required": false, "target": "query", "default": 20 }
130
+ ]
131
+ }
132
+ ```
133
+
134
+ Agent sends `{ "query": "bluetooth speaker", "category": "electronics" }` → request goes to `/api/products?q=bluetooth+speaker&category=electronics&page=1&limit=20`.
135
+
136
+ #### Body parameters
137
+
138
+ Body parameters are sent as JSON in the request body. Use dot-notation in `backend_key` to create nested objects (e.g., `address.city` becomes `{ "address": { "city": "..." } }`).
139
+
140
+ ```json
141
+ {
142
+ "name": "create_contact",
143
+ "http_method": "POST",
144
+ "endpoint_path": "/api/contacts",
145
+ "parameter_mapping": [
146
+ { "name": "name", "type": "string", "required": true, "target": "body" },
147
+ { "name": "email", "type": "string", "required": true, "target": "body" },
148
+ { "name": "phone", "type": "string", "required": false, "target": "body" },
149
+ { "name": "city", "type": "string", "required": false, "target": "body", "backend_key": "address.city" },
150
+ { "name": "state", "type": "string", "required": false, "target": "body", "backend_key": "address.state" },
151
+ { "name": "tags", "type": "array", "required": false, "target": "body", "default": [] }
152
+ ]
153
+ }
154
+ ```
155
+
156
+ Agent sends `{ "name": "Alice", "email": "alice@example.com", "city": "Austin", "state": "TX" }` → request body becomes:
157
+
158
+ ```json
159
+ {
160
+ "name": "Alice",
161
+ "email": "alice@example.com",
162
+ "address": { "city": "Austin", "state": "TX" },
163
+ "tags": []
164
+ }
165
+ ```
166
+
167
+ #### Mixing targets
168
+
169
+ A single tool can combine path, query, body, and header parameters:
170
+
171
+ ```json
172
+ {
173
+ "name": "update_item",
174
+ "http_method": "PUT",
175
+ "endpoint_path": "/api/items/{item_id}",
176
+ "parameter_mapping": [
177
+ { "name": "item_id", "type": "string", "required": true, "target": "path" },
178
+ { "name": "dry_run", "type": "boolean", "required": false, "target": "query", "default": false },
179
+ { "name": "title", "type": "string", "required": true, "target": "body" },
180
+ { "name": "price", "type": "number", "required": true, "target": "body" },
181
+ { "name": "idempotency_key", "type": "string", "required": false, "target": "header", "backend_key": "Idempotency-Key" }
182
+ ]
183
+ }
184
+ ```
185
+
186
+ Agent sends `{ "item_id": "abc", "title": "Widget", "price": 9.99, "idempotency_key": "req-1" }` → `PUT /api/items/abc?dry_run=false` with body `{ "title": "Widget", "price": 9.99 }` and header `Idempotency-Key: req-1`.
187
+
188
+ ## Commands
189
+
190
+ ### `init` — Generate a config file
191
+
192
+ ```bash
193
+ npx @toolrelay/cli init # Interactive wizard
194
+ npx @toolrelay/cli init --yes # Starter template (no prompts)
195
+ ```
196
+
197
+ Walks you through configuring your app (name, base URL, auth type) and defining tools (endpoints, parameters).
198
+
199
+ ### `validate` — Check config
200
+
201
+ ```bash
202
+ npx @toolrelay/cli validate toolrelay.json
203
+ ```
204
+
205
+ Validates the config without making any HTTP calls. Catches:
206
+
207
+ - **Schema errors**: Missing required fields, invalid enum values, malformed URLs
208
+ - **Path parameter mismatches**: `{placeholder}` in `endpoint_path` without a matching `target: "path"` parameter (and vice versa)
209
+ - **Auth config**: Required fields per auth type (e.g., `token` for `static_token`, `authorize_url` for `oauth2`)
210
+
211
+ Run this in CI to catch config issues before deploying.
212
+
213
+ ### `serve` — Local MCP server
214
+
215
+ ```bash
216
+ npx @toolrelay/cli serve toolrelay.json
217
+ npx @toolrelay/cli serve toolrelay.json --port 8787
218
+ ```
219
+
220
+ Starts a local MCP Streamable HTTP server that Claude Desktop (or any MCP client) can connect to. Every `tools/call` produces a full audit trace in your terminal.
221
+
222
+ | Option | Description |
223
+ |---|---|
224
+ | `--base-url <url>` | Override `base_url` from config |
225
+ | `-p, --port <number>` | Port to listen on (default: 8787) |
226
+ | `-v, --verbose` | Show response headers and extra detail |
227
+
228
+ On startup it prints the Claude Desktop config snippet:
229
+
230
+ ```json
231
+ {
232
+ "mcpServers": {
233
+ "my-api": {
234
+ "url": "http://localhost:8787/mcp"
235
+ }
236
+ }
237
+ }
238
+ ```
239
+
240
+ The server implements the full MCP Streamable HTTP protocol (`initialize`, `tools/list`, `tools/call`, `ping`, SSE, session management).
241
+
242
+ **Endpoints:**
243
+
244
+ | Endpoint | Description |
245
+ |---|---|
246
+ | `POST /mcp` | MCP JSON-RPC endpoint |
247
+ | `GET /mcp` | SSE stream (with `Mcp-Session-Id` header) |
248
+ | `GET /health` | Health check (tool count, call count, OAuth status) |
249
+ | `GET /timeline` | Call timeline as JSON |
250
+ | `GET /oauth/start` | Trigger OAuth flow (OAuth2 apps only) |
251
+ | `GET /oauth/status` | Check OAuth state (OAuth2 apps only) |
252
+
253
+ **Call timeline:** Every tool invocation is tracked with sequence numbers, timestamps, arguments, status, latency, and the gap between calls (how long the AI spent "thinking"). On shutdown (Ctrl+C), a formatted timeline and audit summary are printed.
254
+
255
+ **Session persistence:** Sessions are saved to `.toolrelay/sessions/` on shutdown. Browse them with `toolrelay ui`.
256
+
257
+ ### `ui` — Session viewer
258
+
259
+ ```bash
260
+ npx @toolrelay/cli ui
261
+ npx @toolrelay/cli ui --port 8788 --no-open
262
+ ```
263
+
264
+ Opens a local web UI to browse past `serve` sessions. Inspect call timelines, drill into individual tool calls to see parameter resolution, request/response bodies, diagnostics, and errors.
265
+
266
+ | Option | Description |
267
+ |---|---|
268
+ | `-p, --port <number>` | Port to listen on (default: 8788) |
269
+ | `--no-open` | Don't auto-open the browser |
270
+
271
+ ### `login` — Authenticate with ToolRelay
272
+
273
+ ```bash
274
+ npx @toolrelay/cli login
275
+ ```
276
+
277
+ Opens your browser to sign in to [toolrelay.io](https://toolrelay.io). On success, your credentials are saved to `~/.toolrelay/credentials.json` (file mode `0600`).
278
+
279
+ For CI/CD pipelines, set the `TOOLRELAY_DEPLOY_TOKEN` environment variable instead — it takes priority over the credentials file.
280
+
281
+ ### `logout` — Clear saved credentials
282
+
283
+ ```bash
284
+ npx @toolrelay/cli logout
285
+ ```
286
+
287
+ Removes `~/.toolrelay/credentials.json`. Does not revoke the token server-side.
288
+
289
+ ### `whoami` — Show current user
290
+
291
+ ```bash
292
+ npx @toolrelay/cli whoami
293
+ ```
294
+
295
+ Prints the authenticated user's email, name, and account tier. Useful for verifying which account the CLI is using.
296
+
297
+ ### `publish` — Deploy to ToolRelay
298
+
299
+ ```bash
300
+ npx @toolrelay/cli publish toolrelay.json
301
+ npx @toolrelay/cli publish toolrelay.json --dry-run
302
+ npx @toolrelay/cli publish toolrelay.json --prune
303
+ ```
304
+
305
+ Deploys your `toolrelay.json` config to the ToolRelay platform. The command is **idempotent** — it creates the app and tools on first run, then updates only what changed on subsequent runs.
306
+
307
+ | Option | Description |
308
+ |---|---|
309
+ | `--dry-run` | Show what would change without making any API calls |
310
+ | `--prune` | Delete remote tools that are no longer in the config file |
311
+
312
+ **How it works:**
313
+
314
+ 1. Validates your config for production readiness (see [Pre-publish checks](#pre-publish-checks) below)
315
+ 2. Authenticates using `TOOLRELAY_DEPLOY_TOKEN` (env var) or `~/.toolrelay/credentials.json` (from `login`)
316
+ 3. Matches your local app to a remote app by slug (derived from `app.name`)
317
+ 4. Creates the app if it doesn't exist, or updates it if the config changed
318
+ 5. Syncs tools — creates new ones, updates changed ones, and optionally prunes removed ones
319
+ 6. Auto-publishes the app if it hasn't been published yet
320
+
321
+ **What you get after publishing:**
322
+
323
+ - A hosted MCP endpoint at `https://proxy.toolrelay.io/mcp/<your-slug>` — connect any AI agent
324
+ - An API portal at `https://toolrelay.io/portal/<your-slug>` — consumers can request API keys
325
+ - Auto-generated OpenAPI spec, SKILL.md, and landing page
326
+
327
+ **Tier limits** are enforced by the API. If your plan doesn't support the number of apps or tools you're deploying, the command will fail with a clear error message.
328
+
329
+ #### Pre-publish checks
330
+
331
+ Before making any API calls, `publish` validates your config to catch common mistakes:
332
+
333
+ | Check | Severity | Description |
334
+ |---|---|---|
335
+ | Localhost / private IP | Error | `base_url` cannot point to `localhost`, `127.0.0.1`, `192.168.*`, etc. |
336
+ | HTTP base URL | Warning | Production APIs should use HTTPS |
337
+ | Missing tool descriptions | Warning | AI agents rely on descriptions to pick the right tool |
338
+ | OAuth2 required fields | Error | `authorize_url` and `token_url` must be set (`client_id`/`client_secret` are optional for public client PKCE) |
339
+ | OAuth2 localhost URLs | Error | OAuth URLs must be publicly reachable |
340
+ | Missing static token | Error | `static_token` auth requires a `token` in `auth_config` |
341
+ | Duplicate tool names | Error | Each tool must have a unique name |
342
+
343
+ Errors block the deploy. Warnings are printed but don't prevent publishing.
344
+
345
+ **Authentication:**
346
+
347
+ | Method | Use case |
348
+ |---|---|
349
+ | `toolrelay login` | Local development — opens browser, saves credentials to `~/.toolrelay/` |
350
+ | `TOOLRELAY_DEPLOY_TOKEN` | CI/CD — set as environment variable, takes priority over credentials file |
351
+
352
+ Generate a deploy token at **[toolrelay.io/dashboard/settings](https://toolrelay.io/dashboard/settings)** (Settings > Deploy Tokens). The token is shown once — copy it and store it as a CI secret.
353
+
354
+ **Environment variables:**
355
+
356
+ | Variable | Description |
357
+ |---|---|
358
+ | `TOOLRELAY_DEPLOY_TOKEN` | Deploy token for authentication (takes priority over credentials file) |
359
+ | `TOOLRELAY_API_URL` | Override the API endpoint (default: `https://api.toolrelay.io`) |
360
+
361
+ ## OAuth2 local testing
362
+
363
+ When your app uses `auth_type: "oauth2"`, the `serve` command runs a full browser-based OAuth2 authorization code flow locally.
364
+
365
+ ```json
366
+ {
367
+ "app": {
368
+ "name": "My OAuth API",
369
+ "base_url": "http://localhost:3000",
370
+ "auth_type": "oauth2",
371
+ "auth_config": {
372
+ "authorize_url": "https://provider.com/oauth/authorize",
373
+ "token_url": "https://provider.com/oauth/token",
374
+ "client_id": "${OAUTH_CLIENT_ID}",
375
+ "client_secret": "${OAUTH_CLIENT_SECRET}",
376
+ "scopes": "read write"
377
+ }
378
+ },
379
+ "tools": [...]
380
+ }
381
+ ```
382
+
383
+ **How it works:**
384
+
385
+ 1. On startup, a temporary HTTP server starts on a random port for the OAuth callback
386
+ 2. Your browser opens to the upstream provider's authorize URL (with PKCE by default)
387
+ 3. After you authorize, the provider redirects to `http://localhost:<port>/callback`
388
+ 4. The CLI exchanges the authorization code for access/refresh tokens
389
+ 5. Tokens are stored in memory — every tool call gets `Authorization: Bearer <token>` injected automatically
390
+ 6. If the access token expires and a refresh token is available, it's refreshed automatically
391
+
392
+ **Important:** Register `http://localhost` (any port) as an allowed redirect URI in your OAuth provider's settings.
393
+
394
+ If the browser can't auto-open (headless environments, SSH), the authorize URL is printed to the terminal. You can also manually trigger the flow at any time via `GET /oauth/start`.
395
+
396
+ ### Disabling PKCE
397
+
398
+ PKCE (Proof Key for Code Exchange) is enabled by default. Some providers — like AWS Cognito configured as a confidential client, or certain custom OAuth servers — may reject the extra `code_challenge` / `code_verifier` parameters. Set `"use_pkce": false` in `auth_config` to disable it:
399
+
400
+ ```json
401
+ {
402
+ "app": {
403
+ "name": "Cognito API",
404
+ "base_url": "https://api.example.com",
405
+ "auth_type": "oauth2",
406
+ "auth_config": {
407
+ "authorize_url": "https://mypool.auth.us-east-1.amazoncognito.com/oauth2/authorize",
408
+ "token_url": "https://mypool.auth.us-east-1.amazoncognito.com/oauth2/token",
409
+ "client_id": "${COGNITO_CLIENT_ID}",
410
+ "client_secret": "${COGNITO_CLIENT_SECRET}",
411
+ "scopes": "openid profile",
412
+ "use_pkce": false
413
+ }
414
+ },
415
+ "tools": [...]
416
+ }
417
+ ```
418
+
419
+ | Provider | PKCE support | Recommendation |
420
+ |---|---|---|
421
+ | Auth0 | Yes | Leave default (`use_pkce: true`) |
422
+ | Supabase | Yes (default since 2024) | Leave default |
423
+ | Okta | Yes | Leave default |
424
+ | Google | Yes | Leave default |
425
+ | AWS Cognito (public client) | Yes | Leave default |
426
+ | AWS Cognito (confidential client) | May reject PKCE | Set `"use_pkce": false` |
427
+ | Custom OAuth servers | Varies | Try default first; set `false` if you get errors about unknown parameters |
428
+
429
+ ## Local to production
430
+
431
+ The typical workflow:
432
+
433
+ ```
434
+ 1. toolrelay init → Generate toolrelay.json
435
+ 2. toolrelay serve → Test locally with Claude Desktop
436
+ 3. (iterate on config)
437
+ 4. toolrelay login → Authenticate with toolrelay.io
438
+ 5. toolrelay publish → Deploy to production
439
+ ```
440
+
441
+ Once published, your app gets:
442
+
443
+ - **Hosted MCP endpoint** (`/mcp/<slug>`) — any AI agent can connect
444
+ - **Consumer portal** (`/portal/<slug>`) — API key self-service for developers
445
+ - **Auto-generated docs** — OpenAPI spec, SKILL.md, landing page
446
+ - **Usage analytics** — call volume, latency, error rates per tool
447
+ - **Rate limiting and caching** — per-consumer, per-tier controls
448
+
449
+ Sign up at [toolrelay.io](https://toolrelay.io) to get started.
450
+
451
+ ### File locations
452
+
453
+ | Path | Purpose |
454
+ |---|---|
455
+ | `toolrelay.json` | Your app + tool config (project root, committed to git) |
456
+ | `.toolrelay/sessions/` | Local MCP session logs (project-local, auto-gitignored) |
457
+ | `~/.toolrelay/credentials.json` | Login credentials (home directory, mode `0600`) |
458
+
459
+ ## License
460
+
461
+ Proprietary — see LICENSE for details.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ export { ToolRelayApiClient } from './chunk-3LR6JESE.js';
3
+ //# sourceMappingURL=api-client-7MTO2YNV.js.map
4
+ //# sourceMappingURL=api-client-7MTO2YNV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"api-client-7MTO2YNV.js"}
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ import { getApiUrl, saveCredentials } from './chunk-CTTPIXB3.js';
3
+ import { ToolRelayApiClient } from './chunk-3LR6JESE.js';
4
+ import { createServer } from 'http';
5
+ import crypto from 'crypto';
6
+ import { exec } from 'child_process';
7
+
8
+ var BOLD = "\x1B[1m";
9
+ var DIM = "\x1B[2m";
10
+ var RESET = "\x1B[0m";
11
+ var GREEN = "\x1B[32m";
12
+ function openBrowser(url) {
13
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? 'start ""' : "xdg-open";
14
+ exec(`${cmd} "${url}"`, (err) => {
15
+ if (err) {
16
+ console.log(`
17
+ ${DIM}Could not open browser automatically.${RESET}`);
18
+ console.log(`${DIM}Open this URL in your browser:${RESET}
19
+ `);
20
+ console.log(` ${url}
21
+ `);
22
+ }
23
+ });
24
+ }
25
+ async function loginFlow() {
26
+ const apiUrl = getApiUrl();
27
+ const webUrl = process.env.TOOLRELAY_WEB_URL || apiUrl.replace(/\/\/api\./, "//");
28
+ return new Promise((resolve, reject) => {
29
+ const state = crypto.randomBytes(16).toString("hex");
30
+ let server;
31
+ const timeout = setTimeout(() => {
32
+ server?.close();
33
+ reject(new Error("Login timed out after 5 minutes. Try again."));
34
+ }, 5 * 60 * 1e3);
35
+ server = createServer((req, res) => {
36
+ const url = new URL(req.url ?? "/", `http://localhost`);
37
+ if (url.pathname === "/callback") {
38
+ const token = url.searchParams.get("token");
39
+ const returnedState = url.searchParams.get("state");
40
+ const error = url.searchParams.get("error");
41
+ if (error) {
42
+ res.writeHead(200, { "Content-Type": "text/html" });
43
+ res.end("<html><body><h2>Login failed</h2><p>You can close this tab.</p></body></html>");
44
+ clearTimeout(timeout);
45
+ server.close();
46
+ reject(new Error(`Login failed: ${error}`));
47
+ return;
48
+ }
49
+ if (!token || returnedState !== state) {
50
+ res.writeHead(400, { "Content-Type": "text/html" });
51
+ res.end("<html><body><h2>Invalid callback</h2><p>Missing token or state mismatch.</p></body></html>");
52
+ return;
53
+ }
54
+ res.writeHead(200, { "Content-Type": "text/html" });
55
+ res.end("<html><body><h2>Logged in!</h2><p>You can close this tab and return to your terminal.</p></body></html>");
56
+ clearTimeout(timeout);
57
+ server.close();
58
+ verifyAndSave(token, apiUrl).then(resolve).catch(reject);
59
+ } else {
60
+ res.writeHead(404);
61
+ res.end();
62
+ }
63
+ });
64
+ server.listen(0, "127.0.0.1", () => {
65
+ const port = server.address().port;
66
+ const callbackUrl = `http://localhost:${port}/callback`;
67
+ const loginUrl = `${webUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}&state=${state}`;
68
+ console.log(`
69
+ ${BOLD}Opening browser to log in...${RESET}
70
+ `);
71
+ openBrowser(loginUrl);
72
+ if (!process.stdout.isTTY) {
73
+ console.log(`${DIM}Open this URL to log in:${RESET}`);
74
+ console.log(` ${loginUrl}
75
+ `);
76
+ }
77
+ console.log(`${DIM}Waiting for authentication...${RESET}`);
78
+ });
79
+ server.on("error", (err) => {
80
+ clearTimeout(timeout);
81
+ reject(err);
82
+ });
83
+ });
84
+ }
85
+ async function verifyAndSave(token, apiUrl) {
86
+ const client = new ToolRelayApiClient({ token, api_url: apiUrl, source: "env" });
87
+ const user = await client.whoami();
88
+ saveCredentials({
89
+ token,
90
+ email: user.email,
91
+ api_url: apiUrl,
92
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
93
+ });
94
+ console.log(`
95
+ ${GREEN}\u2713${RESET} Logged in as ${BOLD}${user.email}${RESET} (${user.tier} tier)`);
96
+ console.log(`${DIM}Credentials saved to ~/.toolrelay/credentials.json${RESET}
97
+ `);
98
+ return { email: user.email, token };
99
+ }
100
+
101
+ export { loginFlow };
102
+ //# sourceMappingURL=auth-flow-VQXXGXIV.js.map
103
+ //# sourceMappingURL=auth-flow-VQXXGXIV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/auth-flow.ts"],"names":[],"mappings":";;;;;;;AAQA,IAAM,IAAA,GAAO,SAAA;AACb,IAAM,GAAA,GAAM,SAAA;AACZ,IAAM,KAAA,GAAQ,SAAA;AACd,IAAM,KAAA,GAAQ,UAAA;AAKd,SAAS,YAAY,GAAA,EAAmB;AACtC,EAAA,MAAM,GAAA,GACJ,QAAQ,QAAA,KAAa,QAAA,GACjB,SACA,OAAA,CAAQ,QAAA,KAAa,UACnB,UAAA,GACA,UAAA;AAER,EAAA,IAAA,CAAK,GAAG,GAAG,CAAA,EAAA,EAAK,GAAG,CAAA,CAAA,CAAA,EAAK,CAAC,GAAA,KAAQ;AAC/B,IAAA,IAAI,GAAA,EAAK;AACP,MAAA,OAAA,CAAQ,GAAA,CAAI;AAAA,EAAK,GAAG,CAAA,qCAAA,EAAwC,KAAK,CAAA,CAAE,CAAA;AACnE,MAAA,OAAA,CAAQ,GAAA,CAAI,CAAA,EAAG,GAAG,CAAA,8BAAA,EAAiC,KAAK;AAAA,CAAI,CAAA;AAC5D,MAAA,OAAA,CAAQ,GAAA,CAAI,KAAK,GAAG;AAAA,CAAI,CAAA;AAAA,IAC1B;AAAA,EACF,CAAC,CAAA;AACH;AAUA,eAAsB,SAAA,GAAuD;AAC3E,EAAA,MAAM,SAAS,SAAA,EAAU;AAIzB,EAAA,MAAM,SAAS,OAAA,CAAQ,GAAA,CAAI,qBACtB,MAAA,CAAO,OAAA,CAAQ,aAAa,IAAI,CAAA;AAErC,EAAA,OAAO,IAAI,OAAA,CAAQ,CAAC,OAAA,EAAS,MAAA,KAAW;AACtC,IAAA,MAAM,QAAQ,MAAA,CAAO,WAAA,CAAY,EAAE,CAAA,CAAE,SAAS,KAAK,CAAA;AACnD,IAAA,IAAI,MAAA;AAEJ,IAAA,MAAM,OAAA,GAAU,WAAW,MAAM;AAC/B,MAAA,MAAA,EAAQ,KAAA,EAAM;AACd,MAAA,MAAA,CAAO,IAAI,KAAA,CAAM,6CAA6C,CAAC,CAAA;AAAA,IACjE,CAAA,EAAG,CAAA,GAAI,EAAA,GAAK,GAAI,CAAA;AAEhB,IAAA,MAAA,GAAS,YAAA,CAAa,CAAC,GAAA,EAAK,GAAA,KAAQ;AAClC,MAAA,MAAM,MAAM,IAAI,GAAA,CAAI,GAAA,CAAI,GAAA,IAAO,KAAK,CAAA,gBAAA,CAAkB,CAAA;AAEtD,MAAA,IAAI,GAAA,CAAI,aAAa,WAAA,EAAa;AAChC,QAAA,MAAM,KAAA,GAAQ,GAAA,CAAI,YAAA,CAAa,GAAA,CAAI,OAAO,CAAA;AAC1C,QAAA,MAAM,aAAA,GAAgB,GAAA,CAAI,YAAA,CAAa,GAAA,CAAI,OAAO,CAAA;AAClD,QAAA,MAAM,KAAA,GAAQ,GAAA,CAAI,YAAA,CAAa,GAAA,CAAI,OAAO,CAAA;AAE1C,QAAA,IAAI,KAAA,EAAO;AACT,UAAA,GAAA,CAAI,SAAA,CAAU,GAAA,EAAK,EAAE,cAAA,EAAgB,aAAa,CAAA;AAClD,UAAA,GAAA,CAAI,IAAI,+EAA+E,CAAA;AACvF,UAAA,YAAA,CAAa,OAAO,CAAA;AACpB,UAAA,MAAA,CAAO,KAAA,EAAM;AACb,UAAA,MAAA,CAAO,IAAI,KAAA,CAAM,CAAA,cAAA,EAAiB,KAAK,EAAE,CAAC,CAAA;AAC1C,UAAA;AAAA,QACF;AAEA,QAAA,IAAI,CAAC,KAAA,IAAS,aAAA,KAAkB,KAAA,EAAO;AACrC,UAAA,GAAA,CAAI,SAAA,CAAU,GAAA,EAAK,EAAE,cAAA,EAAgB,aAAa,CAAA;AAClD,UAAA,GAAA,CAAI,IAAI,4FAA4F,CAAA;AACpG,UAAA;AAAA,QACF;AAEA,QAAA,GAAA,CAAI,SAAA,CAAU,GAAA,EAAK,EAAE,cAAA,EAAgB,aAAa,CAAA;AAClD,QAAA,GAAA,CAAI,IAAI,yGAAyG,CAAA;AAEjH,QAAA,YAAA,CAAa,OAAO,CAAA;AACpB,QAAA,MAAA,CAAO,KAAA,EAAM;AAGb,QAAA,aAAA,CAAc,OAAO,MAAM,CAAA,CACxB,KAAK,OAAO,CAAA,CACZ,MAAM,MAAM,CAAA;AAAA,MACjB,CAAA,MAAO;AACL,QAAA,GAAA,CAAI,UAAU,GAAG,CAAA;AACjB,QAAA,GAAA,CAAI,GAAA,EAAI;AAAA,MACV;AAAA,IACF,CAAC,CAAA;AAED,IAAA,MAAA,CAAO,MAAA,CAAO,CAAA,EAAG,WAAA,EAAa,MAAM;AAClC,MAAA,MAAM,IAAA,GAAQ,MAAA,CAAO,OAAA,EAAQ,CAAuB,IAAA;AACpD,MAAA,MAAM,WAAA,GAAc,oBAAoB,IAAI,CAAA,SAAA,CAAA;AAC5C,MAAA,MAAM,QAAA,GAAW,GAAG,MAAM,CAAA,mBAAA,EAAsB,mBAAmB,WAAW,CAAC,UAAU,KAAK,CAAA,CAAA;AAE9F,MAAA,OAAA,CAAQ,GAAA,CAAI;AAAA,EAAK,IAAI,+BAA+B,KAAK;AAAA,CAAI,CAAA;AAC7D,MAAA,WAAA,CAAY,QAAQ,CAAA;AAEpB,MAAA,IAAI,CAAC,OAAA,CAAQ,MAAA,CAAO,KAAA,EAAO;AACzB,QAAA,OAAA,CAAQ,GAAA,CAAI,CAAA,EAAG,GAAG,CAAA,wBAAA,EAA2B,KAAK,CAAA,CAAE,CAAA;AACpD,QAAA,OAAA,CAAQ,GAAA,CAAI,KAAK,QAAQ;AAAA,CAAI,CAAA;AAAA,MAC/B;AAEA,MAAA,OAAA,CAAQ,GAAA,CAAI,CAAA,EAAG,GAAG,CAAA,6BAAA,EAAgC,KAAK,CAAA,CAAE,CAAA;AAAA,IAC3D,CAAC,CAAA;AAED,IAAA,MAAA,CAAO,EAAA,CAAG,OAAA,EAAS,CAAC,GAAA,KAAQ;AAC1B,MAAA,YAAA,CAAa,OAAO,CAAA;AACpB,MAAA,MAAA,CAAO,GAAG,CAAA;AAAA,IACZ,CAAC,CAAA;AAAA,EACH,CAAC,CAAA;AACH;AAEA,eAAe,aAAA,CAAc,OAAe,MAAA,EAA2D;AACrG,EAAA,MAAM,MAAA,GAAS,IAAI,kBAAA,CAAmB,EAAE,OAAO,OAAA,EAAS,MAAA,EAAQ,MAAA,EAAQ,KAAA,EAAO,CAAA;AAE/E,EAAA,MAAM,IAAA,GAAO,MAAM,MAAA,CAAO,MAAA,EAAO;AAEjC,EAAA,eAAA,CAAgB;AAAA,IACd,KAAA;AAAA,IACA,OAAO,IAAA,CAAK,KAAA;AAAA,IACZ,OAAA,EAAS,MAAA;AAAA,IACT,UAAA,EAAA,iBAAY,IAAI,IAAA,EAAK,EAAE,WAAA;AAAY,GACpC,CAAA;AAED,EAAA,OAAA,CAAQ,GAAA,CAAI;AAAA,EAAK,KAAK,CAAA,MAAA,EAAI,KAAK,CAAA,cAAA,EAAiB,IAAI,CAAA,EAAG,IAAA,CAAK,KAAK,CAAA,EAAG,KAAK,CAAA,EAAA,EAAK,IAAA,CAAK,IAAI,CAAA,MAAA,CAAQ,CAAA;AAC/F,EAAA,OAAA,CAAQ,GAAA,CAAI,CAAA,EAAG,GAAG,CAAA,kDAAA,EAAqD,KAAK;AAAA,CAAI,CAAA;AAEhF,EAAA,OAAO,EAAE,KAAA,EAAO,IAAA,CAAK,KAAA,EAAO,KAAA,EAAM;AACpC","file":"auth-flow-VQXXGXIV.js","sourcesContent":["import { createServer } from 'node:http';\nimport type { Server } from 'node:http';\nimport crypto from 'node:crypto';\nimport { exec } from 'node:child_process';\nimport { saveCredentials, getApiUrl } from './credentials.js';\nimport { ToolRelayApiClient } from './api-client.js';\n\n// ANSI helpers\nconst BOLD = '\\x1b[1m';\nconst DIM = '\\x1b[2m';\nconst RESET = '\\x1b[0m';\nconst GREEN = '\\x1b[32m';\nconst RED = '\\x1b[31m';\n\n// ─── Browser opener (reused from oauth-handler) ─────────────────────────────\n\nfunction openBrowser(url: string): void {\n const cmd =\n process.platform === 'darwin'\n ? 'open'\n : process.platform === 'win32'\n ? 'start \"\"'\n : 'xdg-open';\n\n exec(`${cmd} \"${url}\"`, (err) => {\n if (err) {\n console.log(`\\n${DIM}Could not open browser automatically.${RESET}`);\n console.log(`${DIM}Open this URL in your browser:${RESET}\\n`);\n console.log(` ${url}\\n`);\n }\n });\n}\n\n// ─── Login flow ─────────────────────────────────────────────────────────────\n\n/**\n * Starts a local HTTP server, opens the ToolRelay login page in the browser,\n * and waits for the callback with a JWT token.\n *\n * The web dashboard redirects to: http://localhost:<port>/callback?token=<jwt>\n */\nexport async function loginFlow(): Promise<{ email: string; token: string }> {\n const apiUrl = getApiUrl();\n // Derive the web (frontend) URL by stripping the \"api.\" subdomain.\n // TOOLRELAY_WEB_URL is a dev-only override for local development\n // where the API runs on a plain localhost port.\n const webUrl = process.env.TOOLRELAY_WEB_URL\n || apiUrl.replace(/\\/\\/api\\./, '//');\n\n return new Promise((resolve, reject) => {\n const state = crypto.randomBytes(16).toString('hex');\n let server: Server;\n\n const timeout = setTimeout(() => {\n server?.close();\n reject(new Error('Login timed out after 5 minutes. Try again.'));\n }, 5 * 60 * 1000);\n\n server = createServer((req, res) => {\n const url = new URL(req.url ?? '/', `http://localhost`);\n\n if (url.pathname === '/callback') {\n const token = url.searchParams.get('token');\n const returnedState = url.searchParams.get('state');\n const error = url.searchParams.get('error');\n\n if (error) {\n res.writeHead(200, { 'Content-Type': 'text/html' });\n res.end('<html><body><h2>Login failed</h2><p>You can close this tab.</p></body></html>');\n clearTimeout(timeout);\n server.close();\n reject(new Error(`Login failed: ${error}`));\n return;\n }\n\n if (!token || returnedState !== state) {\n res.writeHead(400, { 'Content-Type': 'text/html' });\n res.end('<html><body><h2>Invalid callback</h2><p>Missing token or state mismatch.</p></body></html>');\n return;\n }\n\n res.writeHead(200, { 'Content-Type': 'text/html' });\n res.end('<html><body><h2>Logged in!</h2><p>You can close this tab and return to your terminal.</p></body></html>');\n\n clearTimeout(timeout);\n server.close();\n\n // Verify the token works and get user info\n verifyAndSave(token, apiUrl)\n .then(resolve)\n .catch(reject);\n } else {\n res.writeHead(404);\n res.end();\n }\n });\n\n server.listen(0, '127.0.0.1', () => {\n const port = (server.address() as { port: number }).port;\n const callbackUrl = `http://localhost:${port}/callback`;\n const loginUrl = `${webUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}&state=${state}`;\n\n console.log(`\\n${BOLD}Opening browser to log in...${RESET}\\n`);\n openBrowser(loginUrl);\n\n if (!process.stdout.isTTY) {\n console.log(`${DIM}Open this URL to log in:${RESET}`);\n console.log(` ${loginUrl}\\n`);\n }\n\n console.log(`${DIM}Waiting for authentication...${RESET}`);\n });\n\n server.on('error', (err) => {\n clearTimeout(timeout);\n reject(err);\n });\n });\n}\n\nasync function verifyAndSave(token: string, apiUrl: string): Promise<{ email: string; token: string }> {\n const client = new ToolRelayApiClient({ token, api_url: apiUrl, source: 'env' });\n\n const user = await client.whoami();\n\n saveCredentials({\n token,\n email: user.email,\n api_url: apiUrl,\n created_at: new Date().toISOString(),\n });\n\n console.log(`\\n${GREEN}✓${RESET} Logged in as ${BOLD}${user.email}${RESET} (${user.tier} tier)`);\n console.log(`${DIM}Credentials saved to ~/.toolrelay/credentials.json${RESET}\\n`);\n\n return { email: user.email, token };\n}\n"]}
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ // src/api-client.ts
3
+ var ToolRelayApiClient = class {
4
+ baseUrl;
5
+ token;
6
+ constructor(auth) {
7
+ this.baseUrl = auth.api_url.replace(/\/$/, "");
8
+ this.token = auth.token;
9
+ }
10
+ // ─── Auth ───────────────────────────────────────────────────────────
11
+ async whoami() {
12
+ return this.request("GET", "/api/auth/me");
13
+ }
14
+ // ─── Apps ───────────────────────────────────────────────────────────
15
+ async listApps() {
16
+ return this.request("GET", "/api/apps");
17
+ }
18
+ async getApp(appId) {
19
+ return this.request("GET", `/api/apps/${appId}`);
20
+ }
21
+ async createApp(body) {
22
+ return this.request("POST", "/api/apps", body);
23
+ }
24
+ async updateApp(appId, body) {
25
+ return this.request("PUT", `/api/apps/${appId}`, body);
26
+ }
27
+ async publishApp(appId) {
28
+ return this.request("POST", `/api/apps/${appId}/publish`);
29
+ }
30
+ // ─── Tools ──────────────────────────────────────────────────────────
31
+ async listTools(appId) {
32
+ return this.request("GET", `/api/apps/${appId}/tools`);
33
+ }
34
+ async createTool(appId, body) {
35
+ return this.request("POST", `/api/apps/${appId}/tools`, body);
36
+ }
37
+ async updateTool(appId, toolId, body) {
38
+ return this.request("PUT", `/api/apps/${appId}/tools/${toolId}`, body);
39
+ }
40
+ async deleteTool(appId, toolId) {
41
+ await this.request("DELETE", `/api/apps/${appId}/tools/${toolId}`);
42
+ }
43
+ // ─── HTTP ───────────────────────────────────────────────────────────
44
+ async request(method, path, body) {
45
+ const url = `${this.baseUrl}${path}`;
46
+ const headers = {
47
+ "Authorization": `Bearer ${this.token}`,
48
+ "Content-Type": "application/json",
49
+ "User-Agent": "toolrelay-cli/0.1.0"
50
+ };
51
+ const response = await fetch(url, {
52
+ method,
53
+ headers,
54
+ body: body ? JSON.stringify(body) : void 0
55
+ });
56
+ if (!response.ok) {
57
+ let errorMessage;
58
+ try {
59
+ const errorBody = await response.json();
60
+ errorMessage = errorBody.error || errorBody.message || response.statusText;
61
+ } catch {
62
+ errorMessage = response.statusText;
63
+ }
64
+ const err = {
65
+ status: response.status,
66
+ message: errorMessage
67
+ };
68
+ throw err;
69
+ }
70
+ const text = await response.text();
71
+ if (!text) return {};
72
+ return JSON.parse(text);
73
+ }
74
+ };
75
+
76
+ export { ToolRelayApiClient };
77
+ //# sourceMappingURL=chunk-3LR6JESE.js.map
78
+ //# sourceMappingURL=chunk-3LR6JESE.js.map