/**
* 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;