@@ -18,3 +18,3 @@ export interface DaemonLaunchOptions { | ||
| }; | ||
| /** Keep captures in portal history too (webhook only; off by default). */ | ||
| /** Keep captures in console history too (webhook only; off by default). */ | ||
| store?: boolean; | ||
@@ -21,0 +21,0 @@ /** Local delivery (webhook only): mirror captures to this base URL. */ |
@@ -22,5 +22,10 @@ /** | ||
| const expiresAt = ttlMs === null ? 'never' : new Date(Date.now() + ttlMs).toISOString(); | ||
| const workerPath = join(__dirname, 'daemon-worker.js'); | ||
| // In a Bun-compiled binary (Mac app sidecar) __dirname is a virtual embedded | ||
| // path and process.execPath is the compiled CLI itself - re-exec it with the | ||
| // "__daemon-worker" marker (dispatched at the top of index.ts) instead of | ||
| // spawning the worker file by path. Inert under Node. | ||
| const isCompiled = typeof globalThis.Bun !== 'undefined'; | ||
| const workerEntry = isCompiled ? '__daemon-worker' : join(__dirname, 'daemon-worker.js'); | ||
| // ttlMs 0 = no client-side auto-exit (the server owns lifecycle for 'never'). | ||
| const args = [workerPath, connectUrl, options.host, String(port), String(ttlMs ?? 0), subdomain]; | ||
| const args = [workerEntry, connectUrl, options.host, String(port), String(ttlMs ?? 0), subdomain]; | ||
| if (capture) | ||
@@ -27,0 +32,0 @@ args.push('--capture'); |
+3
-0
@@ -23,2 +23,5 @@ /** | ||
| deliver?: boolean; | ||
| /** Interactive terminal session (not a daemon) - registered so `status` | ||
| * and the Mac app see it; the entry dies with the process. */ | ||
| foreground?: boolean; | ||
| /** Config profile name this daemon was started from (`otterkit up`). */ | ||
@@ -25,0 +28,0 @@ profile?: string; |
+7
-1
@@ -13,3 +13,9 @@ /** | ||
| /** Headers recomputed by fetch or meaningless outside the original hop. */ | ||
| const STRIP_HEADERS = new Set(['host', 'connection', 'upgrade', 'transfer-encoding', 'content-length']); | ||
| const STRIP_HEADERS = new Set([ | ||
| 'host', | ||
| 'connection', | ||
| 'upgrade', | ||
| 'transfer-encoding', | ||
| 'content-length', | ||
| ]); | ||
| const HTTP_TIMEOUT_MS = 10_000; | ||
@@ -16,0 +22,0 @@ const HTTP_RETRY_DELAY_MS = 500; |
+3
-3
@@ -5,5 +5,5 @@ /** | ||
| * Where `otterkit inspect` reads the LOCAL capture log (this machine's | ||
| * sessions), this reads the history OtterKit stored server-side: portal | ||
| * sessions), this reads the history OtterKit stored server-side: console | ||
| * endpoints (always stored) and CLI webhooks provisioned with --store. | ||
| * Talks to the portal API (OTTERKIT_API_URL) with the login token, so it | ||
| * Talks to the console API (OTTERKIT_API_URL) with the login token, so it | ||
| * works from any machine on the account - and gives agents the same | ||
@@ -138,3 +138,3 @@ * "what arrived?" read the API exposes at /api/me/webhooks. | ||
| console.log(`\n ${dim('No stored requests for')} ${c.bold}${subdomain}${c.reset}`); | ||
| console.log(` ${dim('Portal endpoints store automatically; CLI webhooks need --store. Local captures: otterkit inspect.')}\n`); | ||
| console.log(` ${dim('Console endpoints store automatically; CLI webhooks need --store. Local captures: otterkit inspect.')}\n`); | ||
| return; | ||
@@ -141,0 +141,0 @@ } |
+9
-2
@@ -1,4 +0,11 @@ | ||
| export declare function login(): Promise<void>; | ||
| export declare function logout(): void; | ||
| /** | ||
| * `--json` mode streams machine-readable JSONL events instead of ANSI text | ||
| * and does NOT open the browser (the caller owns that): one line | ||
| * {"event":"code",userCode,verificationUri,verificationUriComplete,expiresIn} | ||
| * then {"event":"approved",email} or {"event":"error",code}. Built for the | ||
| * Mac app driving the device flow, useful to any script. | ||
| */ | ||
| export declare function login(json?: boolean): Promise<void>; | ||
| export declare function logout(json?: boolean): void; | ||
| export declare function whoami(json?: boolean): Promise<void>; | ||
| export declare function balance(json?: boolean): Promise<void>; |
+71
-16
| /** | ||
| * `otterkit login` - browser device-authorization flow. | ||
| * | ||
| * 1. POST /api/device/start → prints a user code and opens the portal. | ||
| * 2. User signs in (Clerk) and approves the code in the portal. | ||
| * 1. POST /api/device/start → prints a user code and opens the console. | ||
| * 2. User signs in (Clerk) and approves the code in the console. | ||
| * 3. CLI polls /api/device/poll until approved, then saves the token. | ||
| * | ||
| * Login/balance talk to the portal API (OTTERKIT_API_URL), distinct from | ||
| * Login/balance talk to the console API (OTTERKIT_API_URL), distinct from | ||
| * tunnel provisioning which talks to OTTERKIT_TUNNEL_URL. | ||
@@ -16,4 +16,13 @@ */ | ||
| const API_SERVER = process.env.OTTERKIT_API_URL || 'https://api.otterkit.com'; | ||
| const { version: CLI_VERSION } = createRequire(import.meta.url)('../package.json'); | ||
| /** Best-effort machine identity so the portal can label the minted token. */ | ||
| // Falls back to the compile-time --define value inside a compiled binary, | ||
| // where the dynamic package.json require can't resolve. | ||
| const CLI_VERSION = (() => { | ||
| try { | ||
| return createRequire(import.meta.url)('../package.json').version; | ||
| } | ||
| catch { | ||
| return process.env.OTTERKIT_CLI_VERSION || '0.0.0'; | ||
| } | ||
| })(); | ||
| /** Best-effort machine identity so the console can label the minted token. */ | ||
| function deviceInfo() { | ||
@@ -70,5 +79,17 @@ let username; | ||
| const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); | ||
| export async function login() { | ||
| /** | ||
| * `--json` mode streams machine-readable JSONL events instead of ANSI text | ||
| * and does NOT open the browser (the caller owns that): one line | ||
| * {"event":"code",userCode,verificationUri,verificationUriComplete,expiresIn} | ||
| * then {"event":"approved",email} or {"event":"error",code}. Built for the | ||
| * Mac app driving the device flow, useful to any script. | ||
| */ | ||
| export async function login(json) { | ||
| const emit = (obj) => console.log(JSON.stringify(obj)); | ||
| const start = await api('/api/device/start', { device: deviceInfo() }); | ||
| if (!start.ok || !start.data) { | ||
| if (json) { | ||
| emit({ event: 'error', code: 'start_failed', message: start.error ?? null }); | ||
| process.exit(1); | ||
| } | ||
| console.log(`\n ${CROSS} Could not start login (${start.error || 'unknown error'})\n`); | ||
@@ -78,10 +99,21 @@ process.exit(1); | ||
| const { device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in, } = start.data; | ||
| console.log(''); | ||
| console.log(` ${c.bold}Sign in to OtterKit${c.reset}`); | ||
| console.log(''); | ||
| console.log(` Your code: ${c.bold}${c.cyan}${user_code}${c.reset}`); | ||
| console.log(` Opening: ${dim(verification_uri)}`); | ||
| console.log(''); | ||
| console.log(` ${dim('Waiting for approval in your browser…')}`); | ||
| openBrowser(verification_uri_complete); | ||
| if (json) { | ||
| emit({ | ||
| event: 'code', | ||
| userCode: user_code, | ||
| verificationUri: verification_uri, | ||
| verificationUriComplete: verification_uri_complete, | ||
| expiresIn: expires_in, | ||
| }); | ||
| } | ||
| else { | ||
| console.log(''); | ||
| console.log(` ${c.bold}Sign in to OtterKit${c.reset}`); | ||
| console.log(''); | ||
| console.log(` Your code: ${c.bold}${c.cyan}${user_code}${c.reset}`); | ||
| console.log(` Opening: ${dim(verification_uri)}`); | ||
| console.log(''); | ||
| console.log(` ${dim('Waiting for approval in your browser…')}`); | ||
| openBrowser(verification_uri_complete); | ||
| } | ||
| const deadline = Date.now() + expires_in * 1000; | ||
@@ -94,7 +126,14 @@ const intervalMs = Math.max(1, interval) * 1000; | ||
| if (status === 'approved' && poll.data?.token) { | ||
| const token = poll.data.token; | ||
| saveCredentials({ | ||
| token: poll.data.token, | ||
| token, | ||
| server: API_SERVER, | ||
| savedAt: new Date().toISOString(), | ||
| }); | ||
| if (json) { | ||
| // Best-effort email for the caller's UI; the login itself is done. | ||
| const me = await api('/api/me', undefined, token).catch(() => null); | ||
| emit({ event: 'approved', email: me?.data?.email ?? null }); | ||
| return; | ||
| } | ||
| console.log(''); | ||
@@ -107,2 +146,6 @@ console.log(` ${CHECK} ${c.bold}Logged in.${c.reset} Agents on this machine can now provision tunnels.`); | ||
| if (status === 'denied') { | ||
| if (json) { | ||
| emit({ event: 'error', code: 'denied' }); | ||
| process.exit(1); | ||
| } | ||
| console.log(`\n ${CROSS} Login was denied.\n`); | ||
@@ -112,2 +155,6 @@ process.exit(1); | ||
| if (status === 'expired') { | ||
| if (json) { | ||
| emit({ event: 'error', code: 'expired' }); | ||
| process.exit(1); | ||
| } | ||
| console.log(`\n ${CROSS} Login code expired. Run ${c.bold}otterkit login${c.reset} again.\n`); | ||
@@ -117,7 +164,15 @@ process.exit(1); | ||
| } | ||
| if (json) { | ||
| console.log(JSON.stringify({ event: 'error', code: 'timeout' })); | ||
| process.exit(1); | ||
| } | ||
| console.log(`\n ${CROSS} Login timed out. Run ${c.bold}otterkit login${c.reset} again.\n`); | ||
| process.exit(1); | ||
| } | ||
| export function logout() { | ||
| export function logout(json) { | ||
| const cleared = clearCredentials(); | ||
| if (json) { | ||
| console.log(JSON.stringify({ loggedOut: cleared })); | ||
| return; | ||
| } | ||
| console.log(''); | ||
@@ -124,0 +179,0 @@ console.log(cleared ? ` ${CHECK} Logged out.` : ` ${dim('Not logged in.')}`); |
+5
-5
@@ -46,3 +46,3 @@ /** | ||
| balance: e.balance, | ||
| topUpUrl: e.topUpUrl || 'https://app.otterkit.com/billing', | ||
| topUpUrl: e.topUpUrl || 'https://console.otterkit.com/billing', | ||
| }); | ||
@@ -132,3 +132,3 @@ } | ||
| .optional() | ||
| .describe('Also persist captures to the OtterKit portal history (server-side). Off by default: captures stay in the local log only - the private default for sensitive payloads.'), | ||
| .describe('Also persist captures to the OtterKit console history (server-side). Off by default: captures stay in the local log only - the private default for sensitive payloads.'), | ||
| verify: z | ||
@@ -467,3 +467,3 @@ .object({ | ||
| description: 'Read the requests OtterKit stored server-side for a webhook endpoint - works from any ' + | ||
| 'machine on the account, no local log needed. Portal endpoints always store; CLI ' + | ||
| 'machine on the account, no local log needed. Console endpoints always store; CLI ' + | ||
| 'webhooks only with --store (otherwise use the local capture log tools). Returns ' + | ||
@@ -505,3 +505,3 @@ 'headers, base64 body, source IP, and served status, newest first.', | ||
| 'Groups by path (and by a detected event field like type/event), reports field ' + | ||
| 'optionality as present-in-N/M counts. Needs stored history: portal endpoints always ' + | ||
| 'optionality as present-in-N/M counts. Needs stored history: console endpoints always ' + | ||
| 'store; CLI webhooks only with --store.', | ||
@@ -551,3 +551,3 @@ inputSchema: { | ||
| workspaceId: data.data?.workspace_id, | ||
| topUpUrl: 'https://app.otterkit.com/billing', | ||
| topUpUrl: 'https://console.otterkit.com/billing', | ||
| }); | ||
@@ -554,0 +554,0 @@ } |
@@ -54,3 +54,3 @@ /** | ||
| // CLI webhooks default to no-store (captures stay in the local log); | ||
| // --store opts into server-side portal history. | ||
| // --store opts into server-side console history. | ||
| if (store) | ||
@@ -57,0 +57,0 @@ target.searchParams.set('store', 'true'); |
@@ -28,2 +28,6 @@ /** | ||
| replayed?: boolean; | ||
| /** Body exceeded the capture cap - body is null, bodySize is the true size. */ | ||
| bodyTruncated?: boolean; | ||
| /** Response body exceeded the cap or was an asset content-type. */ | ||
| responseBodyTruncated?: boolean; | ||
| } | ||
@@ -30,0 +34,0 @@ export declare function ensureRequestsDir(): Promise<void>; |
+42
-1
@@ -8,3 +8,3 @@ /** | ||
| */ | ||
| import { appendFile, mkdir, unlink, readFile } from 'node:fs/promises'; | ||
| import { appendFile, mkdir, unlink, readFile, rename, writeFile } from 'node:fs/promises'; | ||
| import { existsSync, statSync, createReadStream } from 'node:fs'; | ||
@@ -19,2 +19,9 @@ import { join } from 'node:path'; | ||
| } | ||
| /** Rotate past this size... */ | ||
| const LOG_MAX_BYTES = 50 * 1024 * 1024; | ||
| /** ...keeping the newest entries up to this many bytes. */ | ||
| const LOG_KEEP_BYTES = 25 * 1024 * 1024; | ||
| /** Stat the file only every N writes - rotation is a rare event. */ | ||
| const ROTATE_CHECK_EVERY = 50; | ||
| const writesSinceCheck = new Map(); | ||
| export async function logRequest(subdomain, entry) { | ||
@@ -24,3 +31,37 @@ await ensureRequestsDir(); | ||
| await appendFile(file, JSON.stringify(entry) + '\n'); | ||
| const n = (writesSinceCheck.get(subdomain) ?? 0) + 1; | ||
| if (n < ROTATE_CHECK_EVERY) { | ||
| writesSinceCheck.set(subdomain, n); | ||
| return; | ||
| } | ||
| writesSinceCheck.set(subdomain, 0); | ||
| await rotateIfNeeded(file).catch(() => { | ||
| /* rotation is best-effort; capture keeps appending regardless */ | ||
| }); | ||
| } | ||
| /** | ||
| * Auto-rotation: when the log outgrows LOG_MAX_BYTES, rewrite it keeping the | ||
| * newest entries (~LOG_KEEP_BYTES). Written to a temp file and renamed so | ||
| * readers never see a torn file; tailers observe the size drop and re-read | ||
| * from the top. | ||
| */ | ||
| async function rotateIfNeeded(file) { | ||
| if (!existsSync(file) || statSync(file).size <= LOG_MAX_BYTES) | ||
| return; | ||
| const raw = await readFile(file, 'utf-8'); | ||
| const lines = raw.split('\n'); | ||
| let bytes = 0; | ||
| let start = lines.length; | ||
| for (let i = lines.length - 1; i >= 0; i--) { | ||
| const lineBytes = Buffer.byteLength(lines[i]) + 1; | ||
| if (bytes + lineBytes > LOG_KEEP_BYTES) | ||
| break; | ||
| bytes += lineBytes; | ||
| start = i; | ||
| } | ||
| const kept = lines.slice(start).join('\n'); | ||
| const tmp = `${file}.rotate`; | ||
| await writeFile(tmp, kept.endsWith('\n') || kept === '' ? kept : `${kept}\n`); | ||
| await rename(tmp, file); | ||
| } | ||
| export function getLogPath(subdomain) { | ||
@@ -27,0 +68,0 @@ return join(REQUESTS_DIR, `${subdomain}.jsonl`); |
| /** | ||
| * `otterkit subdomains` - manage stable, reserved tunnel subdomains. | ||
| * | ||
| * Reservations live in the OtterKit portal (workspace-scoped), so these | ||
| * commands talk to the portal API (OTTERKIT_API_URL), distinct from tunnel | ||
| * Reservations live in the OtterKit console (workspace-scoped), so these | ||
| * commands talk to the console API (OTTERKIT_API_URL), distinct from tunnel | ||
| * provisioning which talks to OTTERKIT_TUNNEL_URL. Holding a name is free; | ||
@@ -7,0 +7,0 @@ * you still pay the normal per-hour rate only while a tunnel is connected. |
@@ -35,2 +35,4 @@ /** | ||
| const MAX_RECONNECT_ATTEMPTS = 10; | ||
| /** Tunnel captures store bodies up to this size; larger = metadata only. */ | ||
| const CAPTURE_MAX_BODY_BYTES = 64 * 1024; | ||
| export class TunnelClient { | ||
@@ -280,11 +282,25 @@ config; | ||
| headers: req.headers, | ||
| body: req.body, | ||
| body: bodySize > CAPTURE_MAX_BODY_BYTES ? null : req.body, | ||
| bodySize, | ||
| }; | ||
| if (bodySize > CAPTURE_MAX_BODY_BYTES) | ||
| entry.bodyTruncated = true; | ||
| if (this.config.targetPort > 0) { | ||
| // Forward to local server and capture the full response | ||
| // Forward to local server and capture the full response. Bodies over | ||
| // the cap - and pure asset responses - are logged as metadata only, | ||
| // so a dev site's bundles/images can't balloon the capture log. | ||
| const result = await this.forwardToLocal(req); | ||
| entry.status = result.status; | ||
| entry.durationMs = result.durationMs; | ||
| entry.response = { headers: result.responseHeaders, body: result.responseBody }; | ||
| const responseSize = result.responseBody | ||
| ? Buffer.from(result.responseBody, 'base64').length | ||
| : 0; | ||
| const contentType = result.responseHeaders['content-type'] ?? ''; | ||
| const skipBody = responseSize > CAPTURE_MAX_BODY_BYTES || /^(image|font|video|audio)\//.test(contentType); | ||
| entry.response = { | ||
| headers: result.responseHeaders, | ||
| body: skipBody ? null : result.responseBody, | ||
| }; | ||
| if (skipBody && responseSize > 0) | ||
| entry.responseBodyTruncated = true; | ||
| } | ||
@@ -291,0 +307,0 @@ else { |
+1
-1
@@ -8,3 +8,3 @@ /** | ||
| * Auth is the session's connect token. For sessions this CLI started it is | ||
| * recovered from the local daemon record's connectUrl; for portal-created | ||
| * recovered from the local daemon record's connectUrl; for console-created | ||
| * endpoints it comes from the endpoint's connectUrl. | ||
@@ -11,0 +11,0 @@ */ |
+4
-4
| { | ||
| "name": "otterkit", | ||
| "version": "0.29.0", | ||
| "version": "0.30.0", | ||
| "description": "OtterKit CLI - provision and connect tunnels for AI agents", | ||
@@ -35,5 +35,5 @@ "mcpName": "io.github.useotterkit/otterkit", | ||
| "clean": "rimraf node_modules .turbo dist", | ||
| "release:patch": "npm run build && npm version patch && npm publish", | ||
| "release:minor": "npm run build && npm version minor && npm publish", | ||
| "release:major": "npm run build && npm version major && npm publish" | ||
| "release:patch": "npm run build && npm version patch --no-git-tag-version --workspaces=false && npm publish --workspaces=false", | ||
| "release:minor": "npm run build && npm version minor --no-git-tag-version --workspaces=false && npm publish --workspaces=false", | ||
| "release:major": "npm run build && npm version major --no-git-tag-version --workspaces=false && npm publish --workspaces=false" | ||
| }, | ||
@@ -40,0 +40,0 @@ "dependencies": { |
+4
-4
@@ -15,3 +15,3 @@ # otterkit | ||
| Buy credits at [app.otterkit.com](https://app.otterkit.com). Once you've logged in, | ||
| Buy credits at [console.otterkit.com](https://console.otterkit.com). Once you've logged in, | ||
| agents on the same machine provision automatically using your credits. | ||
@@ -278,3 +278,3 @@ | ||
| Metered by connected time. 1 credit = $0.01. Buy prepaid credits in the portal. | ||
| Metered by connected time. 1 credit = $0.01. Buy prepaid credits in the console. | ||
@@ -298,7 +298,7 @@ | What | Cost | | ||
| For headless/CI, set `OTTERKIT_TOKEN` (create a token at app.otterkit.com) instead of `otterkit login`. | ||
| For headless/CI, set `OTTERKIT_TOKEN` (create a token at console.otterkit.com) instead of `otterkit login`. | ||
| ## Links | ||
| - [Console](https://app.otterkit.com) | ||
| - [Console](https://console.otterkit.com) | ||
| - [Docs](https://www.otterkit.com/docs) | ||
@@ -305,0 +305,0 @@ - [Pricing API](https://otterkit.app/api/agent/pricing) |
Sorry, the diff of this file is too big to display
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
266793
3.67%6237
3.73%