Skip to main content
Glama
connect.ts13.9 kB
/** * WP Navigator Connect Command * * Connects to a WordPress site using a Magic Link URL. * Magic Links enable zero-CLI setup: users copy a link from WordPress admin * and paste it into `wpnav connect` to authenticate automatically. * * @module cli/commands/connect * @since v2.7.0 */ import * as fs from 'fs'; import * as path from 'path'; import { processMagicLink, formatErrorMessage, formatSuccessMessage, type MagicLinkExchangeResponse, } from '../auth/magic-link.js'; import { inputPrompt, confirmPrompt } from '../tui/prompts.js'; import { success, error as errorMessage, warning, info, newline, box, keyValue, createSpinner, colorize, symbols, } from '../tui/components.js'; // ============================================================================= // Types // ============================================================================= export interface ConnectOptions { /** Skip confirmation prompts */ yes?: boolean; /** Output JSON instead of TUI */ json?: boolean; /** Allow HTTP for local development */ local?: boolean; /** Skip auto-init after connecting */ skipInit?: boolean; } export interface ConnectResult { success: boolean; site_url?: string; site_name?: string; username?: string; plugin_version?: string; plugin_edition?: string; files_created?: string[]; error?: { code: string; message: string; }; } // ============================================================================= // Constants // ============================================================================= /** Files created during auto-init */ const AUTO_INIT_FILES = ['wpnavigator.jsonc', 'snapshots/.gitkeep', '.gitignore']; /** Template for wpnavigator.jsonc */ const WPNAVIGATOR_TEMPLATE = `{ // WP Navigator Project Manifest // Generated by wpnav connect on {{timestamp}} "schema_version": 1, // Site information (auto-populated) "site": { "url": "{{siteUrl}}", "name": "{{siteName}}" }, // Brand settings (customize as needed) "brand": { "name": "{{siteName}}", "colors": { "primary": "#2563eb" } }, // Pages to track (add your pages here) "pages": [] } `; // ============================================================================= // Utility Functions // ============================================================================= /** * Generate .wpnav.env file content */ function generateWpnavEnvContent(siteUrl: string, username: string, password: string): string { const timestamp = new Date().toISOString(); return `# WP Navigator Connection Settings # Generated by wpnav connect on ${timestamp} # # WARNING: This file contains sensitive credentials. # Add .wpnav.env to your .gitignore file! # WordPress Site URL (without trailing slash) WP_BASE_URL=${siteUrl} # REST API endpoint (usually <site>/wp-json) WP_REST_API=${siteUrl}/wp-json # WP Navigator API base WPNAV_BASE=${siteUrl}/wp-json/wpnav/v1 # Introspect endpoint for plugin discovery WPNAV_INTROSPECT=${siteUrl}/wp-json/wpnav/v1/introspect # Application Password credentials # Generated automatically via Magic Link WP_APP_USER=${username} WP_APP_PASS=${password} `; } /** * Write file atomically (temp file + rename) */ function writeFileAtomic(filePath: string, content: string, mode: number = 0o644): void { const tempPath = `${filePath}.${process.pid}.tmp`; try { // Write to temp file first fs.writeFileSync(tempPath, content, { encoding: 'utf8', mode }); // Atomic rename fs.renameSync(tempPath, filePath); } catch (err) { // Clean up temp file if it exists try { if (fs.existsSync(tempPath)) { fs.unlinkSync(tempPath); } } catch { // Ignore cleanup errors } throw err; } } /** * Check if project needs initialization (no wpnavigator.jsonc) */ function needsInit(cwd: string): boolean { const manifestPath = path.join(cwd, 'wpnavigator.jsonc'); return !fs.existsSync(manifestPath); } /** * Check if .wpnav.env already exists */ function envFileExists(cwd: string): boolean { const envPath = path.join(cwd, '.wpnav.env'); return fs.existsSync(envPath); } /** * Get path to .wpnav.env */ function getEnvPath(cwd: string): string { return path.join(cwd, '.wpnav.env'); } /** * Ensure directory exists */ function ensureDir(dirPath: string): void { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } } /** * Update .gitignore to include .wpnav.env */ function updateGitignore(cwd: string): boolean { const gitignorePath = path.join(cwd, '.gitignore'); const entry = '.wpnav.env'; try { if (fs.existsSync(gitignorePath)) { const content = fs.readFileSync(gitignorePath, 'utf8'); // Check if already included if (content.includes(entry)) { return false; } // Append to existing file const newContent = content.endsWith('\n') ? `${content}${entry}\n` : `${content}\n${entry}\n`; fs.writeFileSync(gitignorePath, newContent); } else { // Create new .gitignore fs.writeFileSync(gitignorePath, `# WP Navigator credentials (keep secret)\n${entry}\n`); } return true; } catch { return false; } } /** * Run minimal auto-init (scaffold project structure) */ function runAutoInit(cwd: string, siteUrl: string, siteName: string): string[] { const created: string[] = []; // Create snapshots directory const snapshotsDir = path.join(cwd, 'snapshots'); if (!fs.existsSync(snapshotsDir)) { ensureDir(snapshotsDir); // Add .gitkeep so directory is tracked fs.writeFileSync(path.join(snapshotsDir, '.gitkeep'), ''); created.push('snapshots/'); } // Create wpnavigator.jsonc const manifestPath = path.join(cwd, 'wpnavigator.jsonc'); if (!fs.existsSync(manifestPath)) { const timestamp = new Date().toISOString(); const content = WPNAVIGATOR_TEMPLATE.replace('{{timestamp}}', timestamp) .replace(/{{siteUrl}}/g, siteUrl) .replace(/{{siteName}}/g, siteName || 'My WordPress Site'); fs.writeFileSync(manifestPath, content); created.push('wpnavigator.jsonc'); } // Update .gitignore if (updateGitignore(cwd)) { created.push('.gitignore (updated)'); } return created; } /** * Validate magic link URL format (basic check before full parse) */ function looksLikeMagicLink(url: string): boolean { const trimmed = url.trim().toLowerCase(); return trimmed.startsWith('wpnav://connect'); } // ============================================================================= // Display Functions // ============================================================================= /** * Display success result in TUI */ function displaySuccess(credentials: MagicLinkExchangeResponse, filesCreated: string[]): void { newline(); box('Connected Successfully'); newline(); keyValue('Site', credentials.site_name || credentials.site_url); keyValue('URL', credentials.site_url); keyValue('Username', credentials.username); if (credentials.plugin_version) { const edition = credentials.plugin_edition ? ` (${credentials.plugin_edition})` : ''; keyValue('Plugin', `v${credentials.plugin_version}${edition}`); } if (filesCreated.length > 0) { newline(); info('Files created:'); for (const file of filesCreated) { console.error(` ${colorize(symbols.success, 'green')} ${file}`); } } newline(); success('Credentials saved to .wpnav.env'); newline(); info('Next steps:'); console.error(' 1. Run `npx wpnav status` to verify connection'); console.error(' 2. Run `npx wpnav snapshot site` to capture site state'); console.error(' 3. See `npx wpnav --help` for all commands'); } /** * Display error in TUI */ function displayError(message: string): void { newline(); box('Connection Failed'); newline(); console.error(message); newline(); } /** * Output JSON result to stdout */ function outputJSON(data: unknown): void { console.log(JSON.stringify(data, null, 2)); } // ============================================================================= // Main Handler // ============================================================================= /** * Handle the 'wpnav connect' command * * @param args - Command arguments (magic link URL) * @param options - Command options * @returns Exit code: 0 for success, 1 for errors * * @example * ```bash * npx wpnav connect "wpnav://connect?site=example.com&token=abc123&expires=1705312800" * npx wpnav connect # Interactive mode * npx wpnav connect --local <url> # Allow HTTP for localhost * npx wpnav connect --skip-init <url> # Don't auto-scaffold project * ``` */ export async function handleConnect(args: string[], options: ConnectOptions = {}): Promise<number> { const cwd = process.cwd(); const skipConfirm = options.yes === true; const isJson = options.json === true; const allowHttp = options.local === true; const skipInit = options.skipInit === true; // Get magic link URL from args or prompt let magicLinkUrl = args[0]; // Interactive mode: prompt for URL if not provided if (!magicLinkUrl) { if (isJson) { outputJSON({ success: false, command: 'connect', error: { code: 'MISSING_URL', message: 'Magic Link URL is required. Pass it as an argument or use interactive mode.', }, }); return 1; } newline(); info('No Magic Link URL provided. Entering interactive mode.'); newline(); info('To generate a Magic Link:'); console.error(' 1. Open WordPress admin'); console.error(' 2. Go to: WP Navigator > Settings'); console.error(' 3. Click "Connect AI Assistant"'); console.error(' 4. Copy the Magic Link'); newline(); try { magicLinkUrl = await inputPrompt({ message: 'Paste your Magic Link URL', validate: (value) => { if (!value.trim()) return 'URL is required'; if (!looksLikeMagicLink(value)) { return 'URL should start with "wpnav://connect"'; } return null; }, }); } catch (err) { // User cancelled (Ctrl+C) info('Cancelled.'); return 0; } } // Remove surrounding quotes if present (common copy/paste issue) magicLinkUrl = magicLinkUrl.replace(/^["']|["']$/g, '').trim(); // Check if .wpnav.env already exists if (envFileExists(cwd)) { if (!skipConfirm && !isJson) { newline(); warning('A .wpnav.env file already exists in this directory.'); newline(); const overwrite = await confirmPrompt({ message: 'Overwrite existing credentials?', defaultValue: false, }); if (!overwrite) { info('Cancelled. Existing credentials not modified.'); return 0; } } else if (isJson && !skipConfirm) { outputJSON({ success: false, command: 'connect', error: { code: 'ENV_EXISTS', message: 'A .wpnav.env file already exists. Use --yes to overwrite.', }, }); return 1; } } // Process the magic link let spinner: ReturnType<typeof createSpinner> | null = null; if (!isJson) { newline(); spinner = createSpinner({ text: 'Exchanging Magic Link token...' }); } const result = await processMagicLink(magicLinkUrl, { allowInsecureHttp: allowHttp, timeoutMs: 15000, }); // Handle failure if (!result.success) { if (spinner) { spinner.fail('Connection failed'); } if (isJson) { outputJSON({ success: false, command: 'connect', error: { code: result.error.code, message: result.error.message, }, }); } else { displayError(formatErrorMessage(result.error)); } return 1; } // Success - got credentials const credentials = result.credentials; if (spinner) { spinner.succeed('Token exchanged successfully'); } // Store credentials try { const envContent = generateWpnavEnvContent( credentials.site_url, credentials.username, credentials.app_password ); const envPath = getEnvPath(cwd); writeFileAtomic(envPath, envContent, 0o600); // Update .gitignore to protect credentials updateGitignore(cwd); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); if (isJson) { outputJSON({ success: false, command: 'connect', error: { code: 'WRITE_ERROR', message: `Failed to save credentials: ${errMsg}`, }, }); } else { newline(); errorMessage(`Failed to save credentials: ${errMsg}`); } return 1; } // Auto-init if needed let filesCreated: string[] = []; if (!skipInit && needsInit(cwd)) { if (!isJson) { const initSpinner = createSpinner({ text: 'Setting up project...' }); filesCreated = runAutoInit(cwd, credentials.site_url, credentials.site_name || ''); initSpinner.succeed('Project initialized'); } else { filesCreated = runAutoInit(cwd, credentials.site_url, credentials.site_name || ''); } } // Output result if (isJson) { const jsonResult: ConnectResult = { success: true, site_url: credentials.site_url, site_name: credentials.site_name, username: credentials.username, plugin_version: credentials.plugin_version, plugin_edition: credentials.plugin_edition, files_created: filesCreated.length > 0 ? filesCreated : undefined, }; outputJSON({ success: true, command: 'connect', data: jsonResult, }); } else { displaySuccess(credentials, filesCreated); } return 0; } export default handleConnect;

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/littlebearapps/wp-navigator-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server