netlify-plugin-expo-qr
Version:
Netlify Build Plugin to automate Expo app updates and generate QR code pages for Expo Go
307 lines (272 loc) • 11.5 kB
JavaScript
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const QRCode = require('qrcode');
module.exports = {
onBuild: async ({ inputs, utils }) => {
console.log('🔍 Debug: inputs received:', JSON.stringify(inputs, null, 2));
const { mode = 'eas' } = inputs;
console.log(`🚀 Starting netlify-plugin-expo-qr in ${mode} mode...`);
// Check required environment variables
const externalUrlInput = (inputs && inputs.external_url) || process.env.EXTERNAL_TUNNEL_URL;
if (!process.env.EXPO_TOKEN && !externalUrlInput) {
utils.build.failBuild('❌ EXPO_TOKEN environment variable is required (unless EXTERNAL_TUNNEL_URL or inputs.external_url is provided)');
return;
}
try {
let expoUrl;
let branchName = 'unknown';
if (mode === 'eas') {
const externalUrl = externalUrlInput;
if (externalUrl) {
console.log('🔗 Using external tunnel URL (provided via inputs or env). Skipping expo start.');
expoUrl = externalUrl;
branchName = process.env.EAS_UPDATE_BRANCH || 'external';
} else {
console.log('📱 Starting Expo with tunnel...');
// Set EXPO_TOKEN for EAS authentication
console.log('🔐 Setting EXPO_TOKEN for EAS authentication...');
process.env.EXPO_TOKEN = process.env.EXPO_TOKEN;
// Get branch from environment variable or default to 'preview'
const branch = process.env.EAS_UPDATE_BRANCH || 'preview';
branchName = branch;
// Start Expo with tunnel to get the QR code
console.log('🌐 Starting Expo with tunnel...');
// Resolve Ngrok token from either env name
const resolvedNgrokToken = process.env.NGROK_AUTHTOKEN || process.env.NGROK_AUTH_TOKEN || process.env.NGROK_TOKEN || process.env.NGROK_API_KEY || '';
// Check if we have Ngrok token
if (!resolvedNgrokToken) {
console.log('⚠️ Warning: NGROK_AUTHTOKEN not set. Tunnel may fail.');
console.log('💡 Get your free token from: https://ngrok.com/');
}
}
// Ensure ngrok config exists when token is provided (helps avoid Unauthorized)
let ngrokConfigPath = undefined;
try {
if (resolvedNgrokToken) {
const homeDir = process.env.HOME || '/opt/buildhome';
// v3 path
const configDirV3 = path.join(homeDir, '.config', 'ngrok');
const configPathV3 = path.join(configDirV3, 'ngrok.yml');
if (!fs.existsSync(configDirV3)) fs.mkdirSync(configDirV3, { recursive: true });
const ngrokConfigContentV3 = `version: 3\nauthtoken: ${resolvedNgrokToken}\n`;
fs.writeFileSync(configPathV3, ngrokConfigContentV3, { encoding: 'utf8' });
console.log(`📝 Wrote ngrok v3 config to ${configPathV3}`);
// v2 path (some wrappers still read this)
const configDirV2 = path.join(homeDir, '.ngrok2');
const configPathV2 = path.join(configDirV2, 'ngrok.yml');
if (!fs.existsSync(configDirV2)) fs.mkdirSync(configDirV2, { recursive: true });
const ngrokConfigContentV2 = `authtoken: ${resolvedNgrokToken}\n`;
fs.writeFileSync(configPathV2, ngrokConfigContentV2, { encoding: 'utf8' });
console.log(`📝 Wrote ngrok v2 config to ${configPathV2}`);
ngrokConfigPath = configPathV3;
// Also ask ngrok CLI to add the token to its config (best-effort)
try {
const addAuthCmd = `npx -y ngrok config add-authtoken ${resolvedNgrokToken}`;
execSync(addAuthCmd, { stdio: 'pipe', env: { ...process.env, HOME: homeDir } });
console.log('✅ ngrok config add-authtoken executed');
} catch (authErr) {
console.log('⚠️ ngrok add-authtoken failed (continuing):', authErr.message);
}
}
} catch (cfgErr) {
console.log('⚠️ Could not write ngrok config file:', cfgErr.message);
}
try {
const expoCommand = 'npx expo start --tunnel';
console.log(`🔧 Executing: ${expoCommand}`);
const expoOutput = execSync(expoCommand, {
encoding: 'utf8',
stdio: 'pipe',
env: {
...process.env,
EXPO_TOKEN: process.env.EXPO_TOKEN,
CI: '1',
NGROK_AUTHTOKEN: resolvedNgrokToken,
NGROK_AUTH_TOKEN: resolvedNgrokToken,
NGROK_TOKEN: resolvedNgrokToken,
NGROK_API_KEY: resolvedNgrokToken,
// Point ngrok to the config file we just wrote (if any)
...(ngrokConfigPath ? { NGROK_CONFIG: ngrokConfigPath } : {})
},
timeout: 120000 // Increased timeout to 120 seconds
});
// Extract tunnel URL from output
const tunnelMatch = expoOutput.match(/exp:\/\/u-[^\s]+/);
if (tunnelMatch) {
expoUrl = tunnelMatch[0];
console.log(`✅ Expo tunnel started: ${expoUrl}`);
} else {
throw new Error('Could not extract tunnel URL from Expo output');
}
} catch (tunnelError) {
console.log('❌ Expo tunnel failed:', tunnelError.message);
throw new Error('Failed to start Expo tunnel. Please check NGROK_AUTHTOKEN environment variable.');
}
} else if (mode === 'publish') {
console.log('📱 Running Expo publish (legacy mode)...');
// Note: expo publish is deprecated, using eas update instead
console.log('⚠️ expo publish is deprecated, falling back to eas update');
const easCommand = `eas update --branch preview --message "Netlify ${process.env.COMMIT_REF || 'build'}" --non-interactive --json`;
console.log(`🔧 Executing: ${easCommand}`);
const easOutput = execSync(easCommand, {
encoding: 'utf8',
stdio: 'pipe',
env: { ...process.env, EXPO_TOKEN: process.env.EXPO_TOKEN }
});
const easResult = JSON.parse(easOutput);
console.log('✅ EAS update completed successfully');
// Extract URL from EAS update result
if (easResult.url) {
expoUrl = easResult.url;
} else if (easResult.links && easResult.links.url) {
expoUrl = easResult.links.url;
} else {
throw new Error('Could not extract Expo URL from EAS update result');
}
} else {
throw new Error(`Invalid mode: ${mode}. Must be 'eas' or 'publish'`);
}
console.log(`🔗 Extracted Expo URL: ${expoUrl}`);
// Ensure dist directory exists
const distDir = path.join(process.cwd(), 'dist');
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
// Generate QR code as Data URI
console.log('📱 Generating QR code...');
const qrCodeDataUri = await QRCode.toDataURL(expoUrl, {
errorCorrectionLevel: 'M',
margin: 2,
width: 300
});
// Create HTML page
const htmlContent = `
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Expo App QR Code</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.container {
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
text-align: center;
max-width: 500px;
width: 100%;
}
h1 {
color: #333;
margin-bottom: 20px;
font-size: 24px;
}
.qr-code {
margin: 30px 0;
}
.qr-code img {
max-width: 300px;
width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.url {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin: 20px 0;
word-break: break-all;
font-family: 'Courier New', monospace;
font-size: 14px;
color: #495057;
}
.branch {
background: #e3f2fd;
color: #1976d2;
padding: 8px 16px;
border-radius: 20px;
display: inline-block;
font-size: 14px;
font-weight: 500;
margin: 20px 0;
}
.instructions {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
text-align: left;
}
.instructions h3 {
margin-top: 0;
color: #856404;
}
.instructions ol {
margin: 10px 0;
padding-left: 20px;
}
.instructions li {
margin: 8px 0;
color: #856404;
}
</style>
</head>
<body>
<div class="container">
<h1>📱 Expo App QR Code</h1>
<div class="branch">
Branch: ${branchName}
</div>
<div class="qr-code">
<img src="${qrCodeDataUri}" alt="QR Code for Expo App" />
</div>
<div class="url">
${expoUrl}
</div>
<div class="instructions">
<h3>📋 How to open in Expo Go:</h3>
<ol>
<li>Install <strong>Expo Go</strong> from your device's app store</li>
<li>Open Expo Go on your device</li>
<li>Scan this QR code with your device's camera or Expo Go's built-in scanner</li>
<li>Your app will load automatically in Expo Go</li>
</ol>
</div>
<p style="color: #6c757d; font-size: 14px; margin-top: 30px;">
Generated by netlify-plugin-expo-qr
</p>
</div>
</body>
</html>`;
// Write HTML file
const htmlPath = path.join(distDir, 'expo-qr.html');
fs.writeFileSync(htmlPath, htmlContent);
console.log(`✅ QR code page written to: ${htmlPath}`);
// Write URL to text file
const urlPath = path.join(distDir, '__expo-latest.txt');
fs.writeFileSync(urlPath, expoUrl);
console.log(`✅ Latest Expo URL written to: ${urlPath}`);
console.log('🎉 netlify-plugin-expo-qr completed successfully!');
} catch (error) {
console.error('❌ Error in netlify-plugin-expo-qr:', error.message);
if (error.message.includes('Could not extract Expo URL')) {
utils.build.failBuild('Failed to extract Expo URL from update result');
} else {
utils.build.failBuild(`Plugin execution failed: ${error.message}`);
}
}
}
};