diff --git a/.env.example b/.env.example index c2d4746..3a05835 100644 --- a/.env.example +++ b/.env.example @@ -3,13 +3,20 @@ BACKEND_URL=https://api.tinyhumans.ai JWT_TOKEN= # Your JWT token (app Settings > Developer) DEV_JWT_TOKEN= # Alias for JWT_TOKEN -# Gmail (live testing with OAuth) -# OAuth credentials are managed via the REPL 'oauth' command. -# No manual env vars needed for managed OAuth. +# Live test scripts — auth mode +# AUTH_MODE= # "oauth" or "self_hosted" (skips interactive prompt) # Notion (live testing) -# Uses OAuth (managed) or self-hosted auth via REPL setup. -# NOTION_API_KEY= # Only for self-hosted/text auth mode +# NOTION_API_KEY= # Self-hosted: Notion internal integration token (ntn_...) +# NOTION_INTEGRATION_ID= # Encrypted OAuth: 24-char hex integration ID from callback +# CLIENT_KEY_SHARE= # Encrypted OAuth: base64 client key share from callback + +# Gmail (live testing) +# GMAIL_CLIENT_ID= # Self-hosted: Google Cloud OAuth client ID +# GMAIL_CLIENT_SECRET= # Self-hosted: Google Cloud OAuth client secret +# GMAIL_REFRESH_TOKEN= # Self-hosted: OAuth2 refresh token +# GMAIL_INTEGRATION_ID= # Encrypted OAuth: 24-char hex integration ID from callback +# (CLIENT_KEY_SHARE is shared with Notion above) # Telegram (requires TDLib) TELEGRAM_API_ID= diff --git a/.gitignore b/.gitignore index f786789..fc6cebd 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ dev/js-harness/**/*.js # Local model files (large GGUF binaries) .models/ +.fastembed_cache/ \ No newline at end of file diff --git a/dev/test-harness/index.ts b/dev/test-harness/index.ts index 6b0daaa..13b0fe3 100644 --- a/dev/test-harness/index.ts +++ b/dev/test-harness/index.ts @@ -212,6 +212,43 @@ export async function setSetupComplete(skillId: string, complete: boolean = true }); } +/** + * Complete the auth flow for a skill (self_hosted / text mode). + * Sends `auth/complete` RPC with mode and credentials. + */ +export async function authComplete( + skillId: string, + mode: string, + credentials: Record, +): Promise { + return skillRpc(skillId, 'auth/complete', { mode, credentials }); +} + +/** + * Complete the OAuth flow for a skill (managed mode). + * Sends `oauth/complete` RPC with credential info and optional clientKeyShare + * for encrypted OAuth. + */ +export async function oauthComplete( + skillId: string, + args: { + credentialId: string; + provider: string; + grantedScopes?: string[]; + accountLabel?: string; + clientKeyShare?: string; + }, +): Promise { + return skillRpc(skillId, 'oauth/complete', args); +} + +/** + * Trigger a sync on a running skill. + */ +export async function triggerSync(skillId: string): Promise { + return skillRpc(skillId, 'sync', {}); +} + /** * Read a file from the skill's data directory. */ diff --git a/openhuman b/openhuman index ffb4c89..840b1d3 160000 --- a/openhuman +++ b/openhuman @@ -1 +1 @@ -Subproject commit ffb4c8924bb9d4af0770e574bd2e2e4cc1eacece +Subproject commit 840b1d3c52d7684d43d13bc0d31b4257f50746a8 diff --git a/package.json b/package.json index 552f519..6cc5ea0 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "lint:fix": "eslint . --ext .ts --fix", "prepare": "husky", "core:build": "cargo build --manifest-path openhuman/Cargo.toml --bin openhuman-core", + "dev:runtime": "(cd openhuman && cargo build) && node scripts/dev-runtime.mjs", "core:run": "cargo run --manifest-path openhuman/Cargo.toml --bin openhuman-core -- skills run --skills-dir ./skills", "core:list": "cargo run --manifest-path openhuman/Cargo.toml --bin openhuman-core -- skills list --skills-dir ./skills", "core:test": "cargo run --manifest-path openhuman/Cargo.toml --bin openhuman-core -- skills test --skills-dir ./skills", @@ -52,6 +53,7 @@ "prettier": "^3.4.2", "rimraf": "^6.1.2", "socket.io-client": "^4.8.3", + "ts-node": "^10.9.2", "tsx": "^4.19.0", "typescript": "~5.8.3", "ws": "^8.18.0" diff --git a/scripts/dev-runtime.mjs b/scripts/dev-runtime.mjs new file mode 100644 index 0000000..f19919d --- /dev/null +++ b/scripts/dev-runtime.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +/** + * dev-runtime.mjs — starts the skills runtime with .env loaded. + * + * Loads .env from the repo root, then spawns the openhuman-core binary + * with all env vars forwarded so skills can read BACKEND_URL, JWT_TOKEN, etc. + * + * Usage: + * node scripts/dev-runtime.mjs + * node scripts/dev-runtime.mjs --port 7799 + */ + +import { spawn, execSync } from 'child_process'; +import { existsSync, readFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = resolve(__dirname, '..'); + +// --------------------------------------------------------------------------- +// Load .env +// --------------------------------------------------------------------------- + +const envFile = resolve(rootDir, '.env'); +const envVars = { ...process.env }; + +if (existsSync(envFile)) { + const lines = readFileSync(envFile, 'utf-8').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + const value = trimmed.slice(eqIdx + 1).trim(); + // Don't override vars already set in the shell + if (!process.env[key] && value) { + envVars[key] = value; + } + } + console.log(`\x1b[2m Loaded .env from ${envFile}\x1b[0m`); +} else { + console.log(`\x1b[33m No .env file found at ${envFile}\x1b[0m`); +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); +let port = 7799; +const portIdx = args.indexOf('--port'); +if (portIdx !== -1 && args[portIdx + 1]) { + port = parseInt(args[portIdx + 1], 10); +} + +const SKILLS_DIR = resolve(rootDir, 'skills'); +const CORE_BINARY = resolve(rootDir, 'openhuman', 'target', 'debug', 'openhuman-core'); + +// --------------------------------------------------------------------------- +// Checks +// --------------------------------------------------------------------------- + +if (!existsSync(CORE_BINARY)) { + console.error(`\x1b[31m Error: openhuman-core binary not found at ${CORE_BINARY}\x1b[0m`); + console.error(` Run: cd openhuman && cargo build`); + process.exit(1); +} + +if (!existsSync(SKILLS_DIR)) { + console.error(`\x1b[31m Error: compiled skills not found at ${SKILLS_DIR}\x1b[0m`); + console.error(` Run: yarn build`); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Kill existing process on the port +// --------------------------------------------------------------------------- + +try { + execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null`, { stdio: 'ignore' }); + await new Promise((r) => setTimeout(r, 500)); +} catch { + // Nothing running on that port +} + +// --------------------------------------------------------------------------- +// Start +// --------------------------------------------------------------------------- + +console.log(`\n\x1b[36m Skills Runtime (dev)\x1b[0m`); +console.log(`\x1b[2m Port: ${port}\x1b[0m`); +console.log(`\x1b[2m Skills dir: ${SKILLS_DIR}\x1b[0m`); +console.log(`\x1b[2m Backend: ${envVars.BACKEND_URL || '(not set — will use default)'}\x1b[0m`); +console.log(`\x1b[2m JWT: ${envVars.JWT_TOKEN ? `<${envVars.JWT_TOKEN.length} chars>` : '(not set)'}\x1b[0m`); +console.log(); + +const child = spawn(CORE_BINARY, [ + 'skills', 'run', + '--skills-dir', SKILLS_DIR, + '--port', String(port), +], { + stdio: ['ignore', 'inherit', 'inherit'], + env: { + ...envVars, + RUST_LOG: envVars.RUST_LOG || 'info', + }, +}); + +child.on('exit', (code) => { + process.exit(code ?? 1); +}); + +// Forward signals for clean shutdown +process.on('SIGINT', () => child.kill('SIGINT')); +process.on('SIGTERM', () => child.kill('SIGTERM')); diff --git a/src/core/gmail/__tests__/test-gmail-live.ts b/src/core/gmail/__tests__/test-gmail-live.ts new file mode 100644 index 0000000..a6eba23 --- /dev/null +++ b/src/core/gmail/__tests__/test-gmail-live.ts @@ -0,0 +1,472 @@ +#!/usr/bin/env npx tsx + +/** + * Gmail skill live integration script. + * + * Interactive sequential script that walks through the full skill lifecycle: + * start → authenticate → verify connection → exercise tools → verify sync + * + * Env vars (required): + * JWT_TOKEN — session JWT from the OpenHuman backend + * BACKEND_URL — backend API base (default: https://api.tinyhumans.ai) + * + * Reads .env from the repo root automatically (JWT_TOKEN, BACKEND_URL, etc.). + * + * Usage: + * npx tsx src/core/gmail/__tests__/test-gmail-live.ts + */ +import * as readline from 'readline'; +import { exec } from 'child_process'; +import 'dotenv/config'; + +import { + authComplete, + callToolRaw, + getSkillStatus, + oauthComplete, + setSetupComplete, + startSkill, + stopSkill, +} from '../../../../dev/test-harness'; + +// --------------------------------------------------------------------------- +// Formatting +// --------------------------------------------------------------------------- + +const C = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + cyan: '\x1b[36m', + blue: '\x1b[34m', +}; + +function header(text: string) { + console.log(`\n${C.cyan}${'─'.repeat(60)}${C.reset}`); + console.log(`${C.cyan} ${text}${C.reset}`); + console.log(`${C.cyan}${'─'.repeat(60)}${C.reset}`); +} + +function step(label: string) { + process.stdout.write(`${C.blue} ▸ ${label}${C.reset} `); +} + +function ok(detail?: string) { + console.log(`${C.green}✓${C.reset}${detail ? ` ${C.dim}${detail}${C.reset}` : ''}`); +} + +function fail(detail: string) { + console.log(`${C.red}✗ ${detail}${C.reset}`); +} + +function info(label: string, value: unknown) { + console.log(`${C.dim} ${label}: ${C.reset}${value}`); +} + +// --------------------------------------------------------------------------- +// Prompt +// --------------------------------------------------------------------------- + +const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + +function prompt(question: string, defaultValue?: string): Promise { + const suffix = defaultValue ? ` ${C.dim}[${defaultValue}]${C.reset}` : ''; + return new Promise(resolve => { + rl.question(`${C.yellow} ? ${question}${suffix}: ${C.reset}`, answer => { + resolve(answer.trim() || defaultValue || ''); + }); + }); +} + +function promptSecret(question: string): Promise { + return new Promise(resolve => { + process.stdout.write(`${C.yellow} ? ${question}: ${C.reset}`); + const stdin = process.stdin; + const wasRaw = stdin.isRaw; + if (stdin.isTTY) stdin.setRawMode(true); + let input = ''; + const onData = (ch: Buffer) => { + const c = ch.toString(); + if (c === '\n' || c === '\r') { + if (stdin.isTTY) stdin.setRawMode(wasRaw ?? false); + stdin.removeListener('data', onData); + console.log(); + resolve(input.trim()); + } else if (c === '\x7f' || c === '\b') { + input = input.slice(0, -1); + } else if (c === '\x03') { + process.exit(1); + } else { + input += c; + } + }; + stdin.on('data', onData); + }); +} + +/** Open a URL in the default browser. */ +function openUrl(url: string) { + const cmd = + process.platform === 'darwin' + ? `open "${url}"` + : process.platform === 'win32' + ? `start "${url}"` + : `xdg-open "${url}"`; + exec(cmd, err => { + if (err) console.warn(`${C.dim} (could not open browser: ${err.message})${C.reset}`); + }); +} + +// --------------------------------------------------------------------------- +// Tool caller +// --------------------------------------------------------------------------- + +const SKILL_ID = 'gmail'; + +async function callToolSafe( + toolName: string, + args: Record = {} +): Promise<{ data?: any; error?: string }> { + try { + const result = await callToolRaw(SKILL_ID, toolName, args, 60_000); + if (result.is_error) { + return { error: result.content?.[0]?.text || 'unknown error' }; + } + const text = result.content?.[0]?.text; + if (!text) return { data: null }; + try { + return { data: JSON.parse(text) }; + } catch { + return { data: text }; + } + } catch (e: any) { + return { error: e.message }; + } +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Config from env +// --------------------------------------------------------------------------- + +const JWT_TOKEN = process.env.JWT_TOKEN || ''; +const BACKEND_URL = (process.env.BACKEND_URL || 'https://api.tinyhumans.ai').replace(/\/+$/, ''); + +// Pre-filled credentials from env — if all are set, skip interactive prompts. +// AUTH_MODE: "oauth" (default) or "self_hosted" +// For oauth: GMAIL_INTEGRATION_ID + GMAIL_CLIENT_KEY_SHARE +// For self_hosted: GMAIL_CLIENT_ID + GMAIL_CLIENT_SECRET + GMAIL_REFRESH_TOKEN +const ENV_AUTH_MODE = process.env.AUTH_MODE || ''; +const ENV_INTEGRATION_ID = process.env.GMAIL_INTEGRATION_ID || ''; +const ENV_CLIENT_KEY = process.env.GMAIL_CLIENT_KEY_SHARE || ''; +const ENV_CLIENT_ID = process.env.GMAIL_CLIENT_ID || ''; +const ENV_CLIENT_SECRET = process.env.GMAIL_CLIENT_SECRET || ''; +const ENV_REFRESH_TOKEN = process.env.GMAIL_REFRESH_TOKEN || ''; + +if (!JWT_TOKEN) { + console.error(`\n${C.red} JWT_TOKEN env var is required.${C.reset}`); + console.error( + `${C.dim} Usage: JWT_TOKEN= npx tsx src/core/gmail/__tests__/test-gmail-live.ts${C.reset}\n` + ); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + console.log(`\n${C.bold} Gmail Skill — Live Integration Script${C.reset}`); + info('Backend', BACKEND_URL); + info('JWT', `<${JWT_TOKEN.length} chars>`); + + // ── Resolve credentials (env or interactive) ───────────────────────────── + + let mode: 'self_hosted' | 'encrypted_oauth'; + let clientId = ''; + let clientSecret = ''; + let refreshToken = ''; + let integrationId = ''; + let clientKeyShare = ''; + + const hasOAuthEnv = !!(ENV_INTEGRATION_ID && ENV_CLIENT_KEY); + const hasSelfHostedEnv = !!(ENV_CLIENT_ID && ENV_CLIENT_SECRET && ENV_REFRESH_TOKEN); + + if (hasOAuthEnv || (ENV_AUTH_MODE === 'oauth' && hasOAuthEnv)) { + // All OAuth credentials provided via env — skip prompts entirely + mode = 'encrypted_oauth'; + integrationId = ENV_INTEGRATION_ID; + clientKeyShare = ENV_CLIENT_KEY; + + header('1. Credentials (from env)'); + info('Mode', 'encrypted_oauth'); + info('Integration ID', integrationId); + info('Client key', `<${clientKeyShare.length} chars>`); + } else if (hasSelfHostedEnv || ENV_AUTH_MODE === 'self_hosted') { + // Self-hosted credentials provided via env + mode = 'self_hosted'; + clientId = ENV_CLIENT_ID; + clientSecret = ENV_CLIENT_SECRET; + refreshToken = ENV_REFRESH_TOKEN; + + if (!clientId || !clientSecret || !refreshToken) { + header('1. Credentials'); + clientId = clientId || (await prompt('Google Client ID')); + clientSecret = clientSecret || (await promptSecret('Google Client Secret')); + refreshToken = refreshToken || (await promptSecret('Refresh Token')); + if (!clientId || !clientSecret || !refreshToken) { + fail('All three fields are required.'); + process.exit(1); + } + } else { + header('1. Credentials (from env)'); + } + info('Mode', 'self_hosted'); + info('Client ID', `${clientId.slice(0, 12)}...`); + } else { + // Nothing in env — interactive mode + header('1. Authentication Mode'); + + const choice = await prompt( + 'Auth mode — (1) Encrypted OAuth via browser (2) Own OAuth credentials', + '1' + ); + mode = choice === '2' ? 'self_hosted' : 'encrypted_oauth'; + + if (mode === 'self_hosted') { + clientId = await prompt('Google Client ID'); + clientSecret = await promptSecret('Google Client Secret'); + refreshToken = await promptSecret('Refresh Token'); + if (!clientId || !clientSecret || !refreshToken) { + fail('All three fields are required.'); + process.exit(1); + } + } else { + // ── Initiate OAuth via browser ───────────────────────────────────── + + header('2. OAuth Flow'); + + step('Requesting OAuth URL from backend...'); + const connectUrl = `${BACKEND_URL}/auth/gmail/connect?skillId=gmail&responseType=json&encryptionMode=encrypted`; + const connectResp = await fetch(connectUrl, { + headers: { Authorization: `Bearer ${JWT_TOKEN}` }, + }); + if (!connectResp.ok) { + const text = await connectResp.text(); + fail(`Backend returned ${connectResp.status}: ${text}`); + process.exit(1); + } + const connectData = (await connectResp.json()) as { oauthUrl?: string; success?: boolean }; + const oauthUrl = connectData.oauthUrl; + if (!oauthUrl) { + fail(`No oauthUrl in response: ${JSON.stringify(connectData)}`); + process.exit(1); + } + ok(); + + console.log(`\n${C.yellow} Opening Google OAuth page in your browser...${C.reset}`); + console.log(`${C.dim} If it doesn't open, visit this URL manually:${C.reset}`); + console.log(`${C.dim} ${oauthUrl}${C.reset}\n`); + openUrl(oauthUrl); + + console.log( + `${C.yellow} After authorizing, the backend will return a JSON response.${C.reset}` + ); + console.log(`${C.yellow} Copy the integrationId and clientKey from it.${C.reset}\n`); + + integrationId = await prompt('Integration ID (24-char hex from callback)'); + clientKeyShare = await promptSecret('Client key share (base64 from callback)'); + + if (!integrationId || !clientKeyShare) { + fail('Both integration ID and client key share are required.'); + process.exit(1); + } + } + } + + // ── Start skill ────────────────────────────────────────────────────────── + + header('3. Start Skill'); + + step('Stopping any existing instance...'); + try { + await stopSkill(SKILL_ID); + ok(); + } catch { + ok('(was not running)'); + } + + step('Starting gmail skill...'); + try { + const snap = await startSkill(SKILL_ID); + ok(`status=${snap.status}, tools=${snap.tools.length}`); + } catch (e: any) { + fail(e.message); + process.exit(1); + } + + // ── Authenticate ───────────────────────────────────────────────────────── + + header('4. Authenticate'); + + if (mode === 'self_hosted') { + step('Sending auth/complete with self-hosted credentials...'); + try { + const result = (await authComplete(SKILL_ID, 'self_hosted', { + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + })) as any; + if (result.status === 'complete') { + ok(result.message || ''); + } else { + fail(JSON.stringify(result.errors || result)); + process.exit(1); + } + } catch (e: any) { + fail(e.message); + process.exit(1); + } + } else { + step('Sending oauth/complete with encrypted credential...'); + try { + const result = await oauthComplete(SKILL_ID, { + credentialId: integrationId, + provider: 'gmail', + grantedScopes: [ + 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.labels', + ], + clientKeyShare, + }); + ok(JSON.stringify(result).slice(0, 120)); + } catch (e: any) { + fail(e.message); + process.exit(1); + } + } + + step('Marking setup complete...'); + try { + await setSetupComplete(SKILL_ID, true); + ok(); + } catch (e: any) { + fail(e.message); + } + + // ── Verify connection ──────────────────────────────────────────────────── + + header('5. Verify Connection'); + + step('Waiting for state to settle...'); + await new Promise(r => setTimeout(r, 1500)); + ok(); + + step('Checking skill status...'); + try { + const snap = await getSkillStatus(SKILL_ID); + const s = snap.state as Record | undefined; + info('connection_status', s?.connection_status ?? '(none)'); + info('auth_status', s?.auth_status ?? '(none)'); + info('userEmail', s?.userEmail ?? '(none)'); + if (s?.connection_status === 'connected') ok('connected'); + else fail(`Expected connected, got ${s?.connection_status}`); + } catch (e: any) { + fail(e.message); + } + + // ── Exercise tools ─────────────────────────────────────────────────────── + + header('6. Exercise Tools'); + + step('get-profile...'); + { + const { data, error } = await callToolSafe('get-profile', {}); + if (error) fail(error); + else ok(String(data?.emailAddress || data?.email || JSON.stringify(data).slice(0, 80))); + } + + step('get-labels...'); + { + const { data, error } = await callToolSafe('get-labels', {}); + if (error) fail(error); + else { + const labels = data?.labels || []; + ok(`${labels.length} labels`); + for (const l of labels.slice(0, 5)) info('label', l.name || l.id); + if (labels.length > 5) info('', `...and ${labels.length - 5} more`); + } + } + + step('get-emails (max 5)...'); + { + const { data, error } = await callToolSafe('get-emails', { max_results: 5 }); + if (error) fail(error); + else { + const emails = data?.emails || data?.messages || []; + ok(`${emails.length} emails`); + for (const e of emails.slice(0, 3)) info('email', `${e.subject || e.id} — ${e.from || ''}`); + } + } + + step('search-emails (in:inbox, max 3)...'); + { + const { data, error } = await callToolSafe('search-emails', { + query: 'in:inbox', + max_results: 3, + }); + if (error) fail(error); + else ok(`${(data?.emails || data?.messages || []).length} results`); + } + + // ── Verify sync (auto-triggered by Rust runtime after auth) ───────────── + + header('7. Verify Sync'); + + step('Waiting for auto-sync to complete...'); + await new Promise(r => setTimeout(r, 5000)); + ok(); + + step('Checking post-sync state...'); + try { + const snap = await getSkillStatus(SKILL_ID); + const s = snap.state as Record | undefined; + info('userEmail', s?.userEmail ?? 'N/A'); + info('totalEmails', s?.totalEmails ?? 'N/A'); + info('lastSyncTime', s?.lastSyncTime ?? 'N/A'); + info('syncInProgress', s?.syncInProgress ?? 'N/A'); + ok(); + } catch (e: any) { + fail(e.message); + } + + // ── Cleanup ────────────────────────────────────────────────────────────── + + header('8. Cleanup'); + + step('Stopping skill...'); + try { + await stopSkill(SKILL_ID); + ok(); + } catch (e: any) { + fail(e.message); + } + + console.log(`\n${C.green}${C.bold} Done.${C.reset}\n`); + rl.close(); + process.exit(0); +} + +main().catch(e => { + console.error(`\n${C.red}Fatal: ${e.message}${C.reset}`); + rl.close(); + process.exit(1); +}); diff --git a/src/core/gmail/api/index.ts b/src/core/gmail/api/index.ts index d7ae4e6..3df7699 100644 --- a/src/core/gmail/api/index.ts +++ b/src/core/gmail/api/index.ts @@ -138,33 +138,54 @@ export async function gmailFetch( timeout?: number; } = {} ): Promise> { - let accessToken = await resolveAccessToken(); + const cleanPath = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + const method = options.method || 'GET'; + const timeout = options.timeout || 10; + + // Determine whether to use direct API calls (with a resolved access token) + // or the OAuth proxy (for encrypted/managed credentials without a local token). + const accessToken = await resolveAccessToken(); + const oauthCred = oauth.getCredential(); + const useProxy = !accessToken && !!oauthCred; - if (!accessToken) { - console.log('[gmail] gmailFetch: no access token available'); + if (!accessToken && !useProxy) { + console.log('[gmail] gmailFetch: no access token and no OAuth credential'); return { success: false, error: { code: 401, message: 'Gmail not connected. Complete setup first.' }, }; } - // Build the full URL — endpoint may start with / or be relative - const cleanPath = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; - const url = `${GMAIL_BASE_URL}${cleanPath}`; - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { - console.log('[gmail] gmailFetch:', options.method || 'GET', url); - const response = await net.fetch(url, { - method: options.method || 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - ...(options.headers || {}), - }, - body: options.body, - timeout: options.timeout || 10, - }); + let response: { status: number; headers: Record; body: string }; + + if (useProxy) { + // Managed/encrypted OAuth: use the proxy which decrypts tokens server-side. + // oauth.fetch path is relative to the manifest apiBaseUrl (gmail.googleapis.com/gmail/v1), + // so pass the endpoint path directly without the /gmail/v1 prefix. + console.log('[gmail] gmailFetch (proxy):', method, cleanPath); + response = await oauth.fetch(cleanPath, { + method, + headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, + body: options.body, + timeout, + }); + } else { + // Direct API call with resolved access token + const url = `${GMAIL_BASE_URL}${cleanPath}`; + console.log('[gmail] gmailFetch (direct):', method, url); + response = await net.fetch(url, { + method, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + body: options.body, + timeout, + }); + } console.log('[gmail] gmailFetch response status:', response.status); const s = getGmailSkillState(); @@ -176,33 +197,26 @@ export async function gmailFetch( ? parseInt(retryAfter, 10) * 1000 : DEFAULT_BACKOFF_MS * (attempt + 1); console.log( - `[gmail] gmailFetch: 429 rate-limited path=${url} — retrying in ${waitMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})` + `[gmail] gmailFetch: 429 rate-limited — retrying in ${waitMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})` ); await sleep(waitMs); continue; } // -- 401 Unauthorized: invalidate cached token and retry with fresh one - - if (response.status === 401 && attempt < MAX_RETRIES) { + if (!useProxy && response.status === 401 && attempt < MAX_RETRIES) { const bodyPreview = response.body ? response.body.slice(0, 200) : '(empty)'; - console.log(`[gmail] gmailFetch: 401 Unauthorized url=${url} body=${bodyPreview}`); - // Invalidate cached token and re-resolve + console.log(`[gmail] gmailFetch: 401 Unauthorized body=${bodyPreview}`); cachedSelfHostedToken = null; const freshToken = await resolveAccessToken(); if (freshToken && freshToken !== accessToken) { - accessToken = freshToken; + // Can't reassign accessToken in proxy branch, but this is the direct branch console.log('[gmail] gmailFetch: refreshed token, retrying'); continue; } - // Token didn't change — no point retrying - } else if (response.status === 401) { - const bodyPreview = response.body ? response.body.slice(0, 200) : '(empty)'; - console.log(`[gmail] gmailFetch: 401 Unauthorized (final) url=${url} body=${bodyPreview}`); } else if (response.status >= 400) { const bodyPreview = response.body ? response.body.slice(0, 200) : '(empty)'; - console.log( - `[gmail] gmailFetch: error url=${url} status=${response.status} body=${bodyPreview}` - ); + console.log(`[gmail] gmailFetch: error status=${response.status} body=${bodyPreview}`); } // Update rate limit info from headers @@ -226,7 +240,7 @@ export async function gmailFetch( } } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); - console.error(`[gmail] gmailFetch error url=${url}: ${errorMsg}`); + console.error(`[gmail] gmailFetch error: ${errorMsg}`); const s = getGmailSkillState(); s.lastApiError = errorMsg; return { success: false, error: { code: 500, message: errorMsg } }; diff --git a/src/core/gmail/sync.ts b/src/core/gmail/sync.ts index e08560f..309490e 100644 --- a/src/core/gmail/sync.ts +++ b/src/core/gmail/sync.ts @@ -2,7 +2,7 @@ // Fetches messages via Gmail API and upserts into local SQLite database. // Skips emails already in the local DB to avoid redundant API calls. import { syncIntegrationMetadata } from '../../shared/integration-metadata'; -import { gmailFetch } from './api'; +import { gmailFetch, isGmailConnected } from './api'; import { loadGmailProfile } from './api/helpers'; import { emailExists, getEmailCount, getEmails, upsertEmail } from './db/helpers'; import { getGmailSkillState, publishSkillState } from './state'; @@ -133,9 +133,15 @@ async function runSyncPages( pageToken = result.nextPageToken; - for (const msgRef of result.messages) { - if (await syncMessage(msgRef.id)) newEmails++; - else skipped++; + // Sync messages in parallel (5 concurrent) to avoid sequential proxy round-trips + const CONCURRENCY = 5; + for (let i = 0; i < result.messages.length; i += CONCURRENCY) { + const batch = result.messages.slice(i, i + CONCURRENCY); + const results = await Promise.all(batch.map(msgRef => syncMessage(msgRef.id))); + for (const wasNew of results) { + if (wasNew) newEmails++; + else skipped++; + } } log?.(`Page ${page}: ${newEmails} new, ${skipped} skipped`, Math.min(10 + page * 10, 90)); @@ -156,8 +162,8 @@ async function runSyncPages( export async function performInitialSync(onProgress?: SyncProgressCallback): Promise { const s = getGmailSkillState(); - if (!oauth.getCredential()) { - console.log('[gmail-sync] No OAuth credential, skipping initial sync'); + if (!isGmailConnected()) { + console.log('[gmail-sync] No credential, skipping initial sync'); return; } @@ -221,7 +227,7 @@ export async function performInitialSync(onProgress?: SyncProgressCallback): Pro export async function onSync(): Promise { const s = getGmailSkillState(); - if (!oauth.getCredential() || s.syncStatus.syncInProgress) return; + if (!isGmailConnected() || s.syncStatus.syncInProgress) return; try { loadGmailProfile(); diff --git a/src/core/gmail/tools/get-emails.ts b/src/core/gmail/tools/get-emails.ts index 488140f..cd8ea46 100644 --- a/src/core/gmail/tools/get-emails.ts +++ b/src/core/gmail/tools/get-emails.ts @@ -148,16 +148,26 @@ export const getEmailsTool: ToolDefinition = { }); } + // Fetch message metadata in parallel (max 5 concurrent) to avoid + // sequential proxy round-trips that cause timeouts. + const CONCURRENCY = 5; const emails: Record[] = []; + const refs = messageList.messages; - for (const msgRef of messageList.messages) { - const msgEndpoint = `/users/me/messages/${msgRef.id}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date`; - const msgResponse: GmailApiResponse = await gmailFetch(msgEndpoint); - - if (msgResponse.success && msgResponse.data) { - const message = msgResponse.data as any; - emails.push(messageToEmailRow(message)); - upsertEmail(message); + for (let i = 0; i < refs.length; i += CONCURRENCY) { + const batch = refs.slice(i, i + CONCURRENCY); + const results = await Promise.all( + batch.map(msgRef => { + const msgEndpoint = `/users/me/messages/${msgRef.id}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Date`; + return gmailFetch(msgEndpoint); + }) + ); + for (const msgResponse of results) { + if (msgResponse.success && msgResponse.data) { + const message = msgResponse.data as any; + emails.push(messageToEmailRow(message)); + upsertEmail(message); + } } } diff --git a/src/core/gmail/tools/search-emails.ts b/src/core/gmail/tools/search-emails.ts index 9046cc7..d851706 100644 --- a/src/core/gmail/tools/search-emails.ts +++ b/src/core/gmail/tools/search-emails.ts @@ -80,29 +80,32 @@ export const searchEmailsTool: ToolDefinition = { }); } - // Get detailed information for found emails + // Fetch message metadata in parallel (max 5 concurrent) to avoid + // sequential proxy round-trips that cause timeouts. + const CONCURRENCY = 5; const emails = []; - const batchSize = 10; // Process in batches to avoid rate limits - - for (let i = 0; i < searchResults.messages.length; i += batchSize) { - const batch = searchResults.messages.slice(i, i + batchSize); - - for (const msgRef of batch) { - const msgResponse = await gmailFetch( - `/users/me/messages/${msgRef.id}?format=metadata` - ); - + const refs = searchResults.messages; + + for (let i = 0; i < refs.length; i += CONCURRENCY) { + const batch = refs.slice(i, i + CONCURRENCY); + const results = await Promise.all( + batch.map(msgRef => + gmailFetch( + `/users/me/messages/${msgRef.id}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Date` + ) + ) + ); + + for (const msgResponse of results) { if (msgResponse.success && msgResponse.data) { const message = msgResponse.data as GmailMessage; const headers = message.payload?.headers || []; - // Extract key headers const headerMap: Record = {}; headers.forEach((header: any) => { headerMap[header.name.toLowerCase()] = header.value; }); - // Parse sender info const from = headerMap.from || ''; const fromMatch = from.match(/(.+?)\s*<([^>]+)>/) || [null, from, from]; const senderName = fromMatch[1]?.trim().replace(/^["']|["']$/g, '') || null; @@ -129,16 +132,9 @@ export const searchEmailsTool: ToolDefinition = { relevance_score: calculateRelevanceScore(message, query), }); - // Cache in local database upsertEmail(message); } } - - // Small delay between batches to respect rate limits - if (i + batchSize < searchResults.messages.length) { - // In a real implementation, we might want to add a small delay here - // but since we're in a synchronous environment, we'll continue - } } // Filter out sensitive emails unless user opted in to show them diff --git a/src/core/notion/__tests__/test-notion-live.ts b/src/core/notion/__tests__/test-notion-live.ts new file mode 100644 index 0000000..02b76f0 --- /dev/null +++ b/src/core/notion/__tests__/test-notion-live.ts @@ -0,0 +1,449 @@ +#!/usr/bin/env npx tsx + +/** + * Notion skill live integration script. + * + * Interactive sequential script that walks through the full skill lifecycle: + * start → authenticate → verify connection → exercise tools → verify sync + * + * Env vars (required): + * JWT_TOKEN — session JWT from the OpenHuman backend + * BACKEND_URL — backend API base (default: https://api.tinyhumans.ai) + * + * Reads .env from the repo root automatically (JWT_TOKEN, BACKEND_URL, etc.). + * + * Usage: + * npx tsx src/core/notion/__tests__/test-notion-live.ts + */ +import * as readline from 'readline'; +import { exec } from 'child_process'; +import 'dotenv/config'; + +import { + authComplete, + callToolRaw, + getSkillStatus, + oauthComplete, + setSetupComplete, + startSkill, + stopSkill, +} from '../../../../dev/test-harness'; + +// --------------------------------------------------------------------------- +// Formatting +// --------------------------------------------------------------------------- + +const C = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + cyan: '\x1b[36m', + blue: '\x1b[34m', +}; + +function header(text: string) { + console.log(`\n${C.cyan}${'─'.repeat(60)}${C.reset}`); + console.log(`${C.cyan} ${text}${C.reset}`); + console.log(`${C.cyan}${'─'.repeat(60)}${C.reset}`); +} + +function step(label: string) { + process.stdout.write(`${C.blue} ▸ ${label}${C.reset} `); +} + +function ok(detail?: string) { + console.log(`${C.green}✓${C.reset}${detail ? ` ${C.dim}${detail}${C.reset}` : ''}`); +} + +function fail(detail: string) { + console.log(`${C.red}✗ ${detail}${C.reset}`); +} + +function info(label: string, value: unknown) { + console.log(`${C.dim} ${label}: ${C.reset}${value}`); +} + +// --------------------------------------------------------------------------- +// Prompt +// --------------------------------------------------------------------------- + +const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + +function prompt(question: string, defaultValue?: string): Promise { + const suffix = defaultValue ? ` ${C.dim}[${defaultValue}]${C.reset}` : ''; + return new Promise(resolve => { + rl.question(`${C.yellow} ? ${question}${suffix}: ${C.reset}`, answer => { + resolve(answer.trim() || defaultValue || ''); + }); + }); +} + +function promptSecret(question: string): Promise { + return new Promise(resolve => { + process.stdout.write(`${C.yellow} ? ${question}: ${C.reset}`); + const stdin = process.stdin; + const wasRaw = stdin.isRaw; + if (stdin.isTTY) stdin.setRawMode(true); + let input = ''; + const onData = (ch: Buffer) => { + const c = ch.toString(); + if (c === '\n' || c === '\r') { + if (stdin.isTTY) stdin.setRawMode(wasRaw ?? false); + stdin.removeListener('data', onData); + console.log(); + resolve(input.trim()); + } else if (c === '\x7f' || c === '\b') { + input = input.slice(0, -1); + } else if (c === '\x03') { + process.exit(1); + } else { + input += c; + } + }; + stdin.on('data', onData); + }); +} + +/** Open a URL in the default browser. */ +function openUrl(url: string) { + const cmd = + process.platform === 'darwin' + ? `open "${url}"` + : process.platform === 'win32' + ? `start "${url}"` + : `xdg-open "${url}"`; + exec(cmd, err => { + if (err) console.warn(`${C.dim} (could not open browser: ${err.message})${C.reset}`); + }); +} + +// --------------------------------------------------------------------------- +// Tool caller +// --------------------------------------------------------------------------- + +const SKILL_ID = 'notion'; + +async function callToolSafe( + toolName: string, + args: Record = {} +): Promise<{ data?: any; error?: string }> { + try { + const result = await callToolRaw(SKILL_ID, toolName, args, 60_000); + if (result.is_error) { + return { error: result.content?.[0]?.text || 'unknown error' }; + } + const text = result.content?.[0]?.text; + if (!text) return { data: null }; + try { + return { data: JSON.parse(text) }; + } catch { + return { data: text }; + } + } catch (e: any) { + return { error: e.message }; + } +} + +// --------------------------------------------------------------------------- +// Config from env +// --------------------------------------------------------------------------- + +const JWT_TOKEN = process.env.JWT_TOKEN || ''; +const BACKEND_URL = (process.env.BACKEND_URL || 'https://api.tinyhumans.ai').replace(/\/+$/, ''); + +// Pre-filled credentials from env — if all are set, skip interactive prompts. +// AUTH_MODE: "oauth" (default) or "self_hosted" +// For oauth: NOTION_INTEGRATION_ID + NOTION_CLIENT_KEY_SHARE +// For self_hosted: NOTION_API_KEY +const ENV_AUTH_MODE = process.env.AUTH_MODE || ''; +const ENV_API_KEY = process.env.NOTION_API_KEY || ''; +const ENV_INTEGRATION_ID = process.env.NOTION_INTEGRATION_ID || ''; +const ENV_CLIENT_KEY = process.env.NOTION_CLIENT_KEY_SHARE || ''; + +if (!JWT_TOKEN) { + console.error(`\n${C.red} JWT_TOKEN env var is required.${C.reset}`); + console.error( + `${C.dim} Usage: JWT_TOKEN= npx tsx src/core/notion/__tests__/test-notion-live.ts${C.reset}\n` + ); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + console.log(`\n${C.bold} Notion Skill — Live Integration Script${C.reset}`); + info('Backend', BACKEND_URL); + info('JWT', `<${JWT_TOKEN.length} chars>`); + + // ── Resolve credentials (env or interactive) ───────────────────────────── + + let mode: 'self_hosted' | 'encrypted_oauth'; + let apiKey = ''; + let integrationId = ''; + let clientKeyShare = ''; + + const hasOAuthEnv = !!(ENV_INTEGRATION_ID && ENV_CLIENT_KEY); + const hasSelfHostedEnv = !!ENV_API_KEY; + + if (hasOAuthEnv || (ENV_AUTH_MODE === 'oauth' && hasOAuthEnv)) { + // All OAuth credentials provided via env — skip prompts entirely + mode = 'encrypted_oauth'; + integrationId = ENV_INTEGRATION_ID; + clientKeyShare = ENV_CLIENT_KEY; + + header('1. Credentials (from env)'); + info('Mode', 'encrypted_oauth'); + info('Integration ID', integrationId); + info('Client key', `<${clientKeyShare.length} chars>`); + } else if (hasSelfHostedEnv || ENV_AUTH_MODE === 'self_hosted') { + // Self-hosted token provided via env + mode = 'self_hosted'; + apiKey = ENV_API_KEY; + + if (!apiKey) { + // AUTH_MODE=self_hosted but no token — prompt for it + header('1. Credentials'); + apiKey = await promptSecret('Notion integration token (ntn_...)'); + if (!apiKey) { + fail('Token is required.'); + process.exit(1); + } + } else { + header('1. Credentials (from env)'); + } + info('Mode', 'self_hosted'); + info('Token', `${apiKey.slice(0, 8)}...<${apiKey.length} chars>`); + } else { + // Nothing in env — interactive mode + header('1. Authentication Mode'); + + const choice = await prompt('Auth mode — (1) Encrypted OAuth via browser (2) API token', '1'); + mode = choice === '2' ? 'self_hosted' : 'encrypted_oauth'; + + if (mode === 'self_hosted') { + apiKey = await promptSecret('Notion integration token (ntn_...)'); + if (!apiKey) { + fail('Token is required.'); + process.exit(1); + } + } else { + // ── Initiate OAuth via browser ───────────────────────────────────── + + header('2. OAuth Flow'); + + step('Requesting OAuth URL from backend...'); + const connectUrl = `${BACKEND_URL}/auth/notion/connect?skillId=notion&responseType=json&encryptionMode=encrypted`; + const connectResp = await fetch(connectUrl, { + headers: { Authorization: `Bearer ${JWT_TOKEN}` }, + }); + if (!connectResp.ok) { + const text = await connectResp.text(); + fail(`Backend returned ${connectResp.status}: ${text}`); + process.exit(1); + } + const connectData = (await connectResp.json()) as { oauthUrl?: string; success?: boolean }; + const oauthUrl = connectData.oauthUrl; + if (!oauthUrl) { + fail(`No oauthUrl in response: ${JSON.stringify(connectData)}`); + process.exit(1); + } + ok(); + + console.log(`\n${C.yellow} Opening Notion OAuth page in your browser...${C.reset}`); + console.log(`${C.dim} If it doesn't open, visit this URL manually:${C.reset}`); + console.log(`${C.dim} ${oauthUrl}${C.reset}\n`); + openUrl(oauthUrl); + + console.log( + `${C.yellow} After authorizing, the backend will return a JSON response.${C.reset}` + ); + console.log(`${C.yellow} Copy the integrationId and clientKey from it.${C.reset}\n`); + + integrationId = await prompt('Integration ID (24-char hex from callback)'); + clientKeyShare = await promptSecret('Client key share (base64 from callback)'); + + if (!integrationId || !clientKeyShare) { + fail('Both integration ID and client key share are required.'); + process.exit(1); + } + } + } + + // ── Start skill ────────────────────────────────────────────────────────── + + header('3. Start Skill'); + + step('Stopping any existing instance...'); + try { + await stopSkill(SKILL_ID); + ok(); + } catch { + ok('(was not running)'); + } + + step('Starting notion skill...'); + try { + const snap = await startSkill(SKILL_ID); + ok(`status=${snap.status}, tools=${snap.tools.length}`); + } catch (e: any) { + fail(e.message); + process.exit(1); + } + + // ── Authenticate ───────────────────────────────────────────────────────── + + header('4. Authenticate'); + + if (mode === 'self_hosted') { + step('Sending auth/complete with API token...'); + try { + const result = (await authComplete(SKILL_ID, 'self_hosted', { api_token: apiKey })) as any; + if (result.status === 'complete') { + ok(result.message || ''); + } else { + fail(JSON.stringify(result.errors || result)); + process.exit(1); + } + } catch (e: any) { + fail(e.message); + process.exit(1); + } + } else { + step('Sending oauth/complete with encrypted credential...'); + try { + const result = await oauthComplete(SKILL_ID, { + credentialId: integrationId, + provider: 'notion', + grantedScopes: [], + clientKeyShare, + }); + ok(JSON.stringify(result).slice(0, 120)); + } catch (e: any) { + fail(e.message); + process.exit(1); + } + } + + step('Marking setup complete...'); + try { + await setSetupComplete(SKILL_ID, true); + ok(); + } catch (e: any) { + fail(e.message); + } + + // ── Verify connection ──────────────────────────────────────────────────── + + header('5. Verify Connection'); + + step('Waiting for state to settle...'); + await new Promise(r => setTimeout(r, 1500)); + ok(); + + step('Checking skill status...'); + try { + const snap = await getSkillStatus(SKILL_ID); + const s = snap.state as Record | undefined; + info('connection_status', s?.connection_status ?? '(none)'); + info('auth_status', s?.auth_status ?? '(none)'); + info('workspace', s?.workspaceName ?? '(none)'); + if (s?.connection_status === 'connected') ok('connected'); + else fail(`Expected connected, got ${s?.connection_status}`); + } catch (e: any) { + fail(e.message); + } + + // ── Exercise tools ─────────────────────────────────────────────────────── + + header('6. Exercise Tools'); + + step('list-users...'); + { + const { data, error } = await callToolSafe('list-users', {}); + if (error) fail(error); + else { + const users = data?.users || data?.results || []; + ok(`${users.length} users`); + for (const u of users.slice(0, 3)) info('user', u.name || u.id); + } + } + + step('search (query="test")...'); + { + const { data, error } = await callToolSafe('search', { query: 'test' }); + if (error) fail(error); + else ok(`${(data?.results || data?.pages || []).length} results`); + } + + step('list-all-pages...'); + { + const { data, error } = await callToolSafe('list-all-pages', {}); + if (error) fail(error); + else { + const pages = data?.pages || data?.results || []; + ok(`${pages.length} pages`); + for (const p of pages.slice(0, 5)) info('page', `${p.title || p.id} — ${p.url || ''}`); + if (pages.length > 5) info('', `...and ${pages.length - 5} more`); + } + } + + step('list-all-databases...'); + { + const { data, error } = await callToolSafe('list-all-databases', {}); + if (error) fail(error); + else { + const dbs = data?.databases || data?.results || []; + ok(`${dbs.length} databases`); + for (const d of dbs.slice(0, 5)) info('db', `${d.title || d.id}`); + } + } + + // ── Verify sync (auto-triggered by runtime after auth) ────────────────── + + header('7. Verify Sync'); + + step('Waiting for auto-sync to complete...'); + await new Promise(r => setTimeout(r, 5000)); + ok(); + + step('Checking post-sync state...'); + try { + const snap = await getSkillStatus(SKILL_ID); + const s = snap.state as Record | undefined; + info('totalPages', s?.totalPages ?? 'N/A'); + info('totalDatabases', s?.totalDatabases ?? 'N/A'); + info('pagesWithContent', s?.pagesWithContent ?? 'N/A'); + info('lastSyncTime', s?.lastSyncTime ?? 'N/A'); + info('syncInProgress', s?.syncInProgress ?? 'N/A'); + ok(); + } catch (e: any) { + fail(e.message); + } + + // ── Cleanup ────────────────────────────────────────────────────────────── + + header('8. Cleanup'); + + step('Stopping skill...'); + try { + await stopSkill(SKILL_ID); + ok(); + } catch (e: any) { + fail(e.message); + } + + console.log(`\n${C.green}${C.bold} Done.${C.reset}\n`); + rl.close(); + process.exit(0); +} + +main().catch(e => { + console.error(`\n${C.red}Fatal: ${e.message}${C.reset}`); + rl.close(); + process.exit(1); +}); diff --git a/src/core/server-ping/setup.ts b/src/core/server-ping/setup.ts index 75c7bd7..96adfb3 100644 --- a/src/core/server-ping/setup.ts +++ b/src/core/server-ping/setup.ts @@ -72,6 +72,33 @@ async function onSetupSubmit(args: { }; } + // Verify the server is reachable before proceeding + try { + const response = await net.fetch(url, { method: 'GET', timeout: 10000 }); + if (response.status >= 500) { + return { + status: 'error', + errors: [ + { + field: 'serverUrl', + message: `Server returned error ${response.status}. Verify the URL is correct.`, + }, + ], + }; + } + // Any 1xx–4xx means the server is reachable (even 404 = server is there) + } catch (e) { + return { + status: 'error', + errors: [ + { + field: 'serverUrl', + message: `Could not reach server: ${String(e)}. Check the URL and try again.`, + }, + ], + }; + } + s.config.serverUrl = url; s.config.pingIntervalSec = parseInt(values.pingIntervalSec as string) || 10; diff --git a/yarn.lock b/yarn.lock index 948981c..de4bc0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -118,6 +118,13 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + "@esbuild/aix-ppc64@0.27.3": version "0.27.3" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2" @@ -364,16 +371,24 @@ "@jridgewell/sourcemap-codec" "^1.5.0" "@jridgewell/trace-mapping" "^0.3.24" -"@jridgewell/resolve-uri@^3.1.0": +"@jridgewell/resolve-uri@^3.0.3", "@jridgewell/resolve-uri@^3.1.0": version "3.1.2" resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== -"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": version "1.5.5" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": version "0.3.31" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" @@ -769,6 +784,26 @@ javascript-natural-sort "0.7.1" lodash "^4.17.21" +"@tsconfig/node10@^1.0.7": + version "1.0.12" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.12.tgz#be57ceac1e4692b41be9de6be8c32a106636dba4" + integrity sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" + integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== + "@types/aws-lambda@^8.10.83": version "8.10.160" resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.160.tgz#7a3afdd919d730e4e2be7239d0af5f36a8b0ce1e" @@ -909,6 +944,18 @@ acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== +acorn-walk@^8.1.1: + version "8.3.5" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.5.tgz#8a6b8ca8fc5b34685af15dabb44118663c296496" + integrity sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw== + dependencies: + acorn "^8.11.0" + +acorn@^8.11.0, acorn@^8.4.1: + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== + acorn@^8.15.0: version "8.15.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" @@ -964,6 +1011,11 @@ are-we-there-yet@^3.0.0: delegates "^1.0.0" readable-stream "^3.6.0" +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + argparse@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" @@ -1284,6 +1336,11 @@ console-control-strings@^1.1.0: resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" @@ -1389,6 +1446,11 @@ detect-libc@^2.0.0: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== +diff@^4.0.1: + version "4.0.4" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.4.tgz#7a6dbfda325f25f07517e9b518f897c08332e07d" + integrity sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ== + doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -2545,6 +2607,11 @@ lru-cache@^11.0.0: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.5.tgz#6811ae01652ae5d749948cdd80bcc22218c6744f" integrity sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw== +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -3499,6 +3566,25 @@ ts-api-utils@^2.4.0: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.4.0.tgz#2690579f96d2790253bdcf1ca35d569ad78f9ad8" integrity sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA== +ts-node@^10.9.2: + version "10.9.2" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" + integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + tsconfig-paths@^3.15.0: version "3.15.0" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" @@ -3630,6 +3716,11 @@ util-deprecate@^1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + validate-npm-package-name@^6.0.0: version "6.0.2" resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-6.0.2.tgz#4e8d2c4d939975a73dd1b7a65e8f08d44c85df96" @@ -3771,6 +3862,11 @@ yargs@^17.7.2: y18n "^5.0.5" yargs-parser "^21.1.1" +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"