refactor: move setup scripts out of src/ to reduce build token count
Setup scripts are standalone CLI tools run via tsx with no runtime imports from the main app. Moving them out of src/ excludes them from the tsc build output and reduces the compiled bundle size. - git mv src/setup/ setup/ - Fix imports to use ../src/logger.js and ../src/config.js - Update package.json, vitest.config.ts, SKILL.md references - Fix platform tests to be cross-platform (macOS + Linux) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
310
setup/whatsapp-auth.ts
Normal file
310
setup/whatsapp-auth.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Step: whatsapp-auth — Full WhatsApp auth flow with polling.
|
||||
* Replaces 04-auth-whatsapp.sh
|
||||
*/
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { logger } from '../src/logger.js';
|
||||
import { openBrowser, isHeadless } from './platform.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
const QR_AUTH_TEMPLATE = `<!DOCTYPE html>
|
||||
<html><head><title>NanoClaw - WhatsApp Auth</title>
|
||||
<meta http-equiv="refresh" content="3">
|
||||
<style>
|
||||
body { font-family: -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: white; border-radius: 16px; padding: 40px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
|
||||
h2 { margin: 0 0 8px; }
|
||||
.timer { font-size: 18px; color: #666; margin: 12px 0; }
|
||||
.timer.urgent { color: #e74c3c; font-weight: bold; }
|
||||
.instructions { color: #666; font-size: 14px; margin-top: 16px; }
|
||||
svg { width: 280px; height: 280px; }
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<h2>Scan with WhatsApp</h2>
|
||||
<div class="timer" id="timer">Expires in <span id="countdown">60</span>s</div>
|
||||
<div id="qr">{{QR_SVG}}</div>
|
||||
<div class="instructions">Settings \\u2192 Linked Devices \\u2192 Link a Device</div>
|
||||
</div>
|
||||
<script>
|
||||
var startKey = 'nanoclaw_qr_start';
|
||||
var start = localStorage.getItem(startKey);
|
||||
if (!start) { start = Date.now().toString(); localStorage.setItem(startKey, start); }
|
||||
var elapsed = Math.floor((Date.now() - parseInt(start)) / 1000);
|
||||
var remaining = Math.max(0, 60 - elapsed);
|
||||
var countdown = document.getElementById('countdown');
|
||||
var timer = document.getElementById('timer');
|
||||
countdown.textContent = remaining;
|
||||
if (remaining <= 10) timer.classList.add('urgent');
|
||||
if (remaining <= 0) {
|
||||
timer.textContent = 'QR code expired \\u2014 a new one will appear shortly';
|
||||
timer.classList.add('urgent');
|
||||
localStorage.removeItem(startKey);
|
||||
}
|
||||
</script></body></html>`;
|
||||
|
||||
const SUCCESS_HTML = `<!DOCTYPE html>
|
||||
<html><head><title>NanoClaw - Connected!</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: white; border-radius: 16px; padding: 40px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
|
||||
h2 { color: #27ae60; margin: 0 0 8px; }
|
||||
p { color: #666; }
|
||||
.check { font-size: 64px; margin-bottom: 16px; }
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<div class="check">✓</div>
|
||||
<h2>Connected to WhatsApp</h2>
|
||||
<p>You can close this tab.</p>
|
||||
</div>
|
||||
<script>localStorage.removeItem('nanoclaw_qr_start');</script>
|
||||
</body></html>`;
|
||||
|
||||
function parseArgs(args: string[]): { method: string; phone: string } {
|
||||
let method = '';
|
||||
let phone = '';
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--method' && args[i + 1]) { method = args[i + 1]; i++; }
|
||||
if (args[i] === '--phone' && args[i + 1]) { phone = args[i + 1]; i++; }
|
||||
}
|
||||
return { method, phone };
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function readFileSafe(filePath: string): string {
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getPhoneNumber(projectRoot: string): string {
|
||||
try {
|
||||
const creds = JSON.parse(
|
||||
fs.readFileSync(path.join(projectRoot, 'store', 'auth', 'creds.json'), 'utf-8'),
|
||||
);
|
||||
if (creds.me?.id) {
|
||||
return creds.me.id.split(':')[0].split('@')[0];
|
||||
}
|
||||
} catch {
|
||||
// Not available yet
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function emitAuthStatus(
|
||||
method: string,
|
||||
authStatus: string,
|
||||
status: string,
|
||||
extra: Record<string, string> = {},
|
||||
): void {
|
||||
const fields: Record<string, string> = {
|
||||
AUTH_METHOD: method,
|
||||
AUTH_STATUS: authStatus,
|
||||
...extra,
|
||||
STATUS: status,
|
||||
LOG: 'logs/setup.log',
|
||||
};
|
||||
emitStatus('AUTH_WHATSAPP', fields);
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const { method, phone } = parseArgs(args);
|
||||
const statusFile = path.join(projectRoot, 'store', 'auth-status.txt');
|
||||
const qrFile = path.join(projectRoot, 'store', 'qr-data.txt');
|
||||
|
||||
if (!method) {
|
||||
emitAuthStatus('unknown', 'failed', 'failed', { ERROR: 'missing_method_flag' });
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
// qr-terminal is a manual flow
|
||||
if (method === 'qr-terminal') {
|
||||
emitAuthStatus('qr-terminal', 'manual', 'manual', { PROJECT_PATH: projectRoot });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'pairing-code' && !phone) {
|
||||
emitAuthStatus('pairing-code', 'failed', 'failed', { ERROR: 'missing_phone_number' });
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
if (!['qr-browser', 'pairing-code'].includes(method)) {
|
||||
emitAuthStatus(method, 'failed', 'failed', { ERROR: 'unknown_method' });
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
// Clean stale state
|
||||
logger.info({ method }, 'Starting WhatsApp auth');
|
||||
try { fs.rmSync(path.join(projectRoot, 'store', 'auth'), { recursive: true, force: true }); } catch { /* ok */ }
|
||||
try { fs.unlinkSync(qrFile); } catch { /* ok */ }
|
||||
try { fs.unlinkSync(statusFile); } catch { /* ok */ }
|
||||
|
||||
// Start auth process in background
|
||||
const authArgs = method === 'pairing-code'
|
||||
? ['src/whatsapp-auth.ts', '--pairing-code', '--phone', phone]
|
||||
: ['src/whatsapp-auth.ts'];
|
||||
|
||||
const authProc = spawn('npx', ['tsx', ...authArgs], {
|
||||
cwd: projectRoot,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
const logFile = path.join(projectRoot, 'logs', 'setup.log');
|
||||
const logStream = fs.createWriteStream(logFile, { flags: 'a' });
|
||||
authProc.stdout?.pipe(logStream);
|
||||
authProc.stderr?.pipe(logStream);
|
||||
|
||||
// Cleanup on exit
|
||||
const cleanup = () => {
|
||||
try { authProc.kill(); } catch { /* ok */ }
|
||||
};
|
||||
process.on('exit', cleanup);
|
||||
|
||||
try {
|
||||
if (method === 'qr-browser') {
|
||||
await handleQrBrowser(projectRoot, statusFile, qrFile);
|
||||
} else {
|
||||
await handlePairingCode(projectRoot, statusFile, phone);
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
process.removeListener('exit', cleanup);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQrBrowser(
|
||||
projectRoot: string,
|
||||
statusFile: string,
|
||||
qrFile: string,
|
||||
): Promise<void> {
|
||||
// Poll for QR data (15s)
|
||||
let qrReady = false;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const statusContent = readFileSafe(statusFile);
|
||||
if (statusContent === 'already_authenticated') {
|
||||
emitAuthStatus('qr-browser', 'already_authenticated', 'success');
|
||||
return;
|
||||
}
|
||||
if (fs.existsSync(qrFile)) {
|
||||
qrReady = true;
|
||||
break;
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
if (!qrReady) {
|
||||
emitAuthStatus('qr-browser', 'failed', 'failed', { ERROR: 'qr_timeout' });
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
// Generate QR SVG and HTML
|
||||
const qrData = fs.readFileSync(qrFile, 'utf-8');
|
||||
try {
|
||||
const svg = execSync(
|
||||
`node -e "const QR=require('qrcode');const data=${JSON.stringify(qrData)};QR.toString(data,{type:'svg'},(e,s)=>{if(e)process.exit(1);process.stdout.write(s)})"`,
|
||||
{ cwd: projectRoot, encoding: 'utf-8' },
|
||||
);
|
||||
const html = QR_AUTH_TEMPLATE.replace('{{QR_SVG}}', svg);
|
||||
const htmlPath = path.join(projectRoot, 'store', 'qr-auth.html');
|
||||
fs.writeFileSync(htmlPath, html);
|
||||
|
||||
// Open in browser (cross-platform)
|
||||
if (!isHeadless()) {
|
||||
const opened = openBrowser(htmlPath);
|
||||
if (!opened) {
|
||||
logger.warn('Could not open browser — display QR in terminal as fallback');
|
||||
}
|
||||
} else {
|
||||
logger.info('Headless environment — QR HTML saved but browser not opened');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to generate QR HTML');
|
||||
}
|
||||
|
||||
// Poll for completion (120s)
|
||||
await pollAuthCompletion('qr-browser', statusFile, projectRoot);
|
||||
}
|
||||
|
||||
async function handlePairingCode(
|
||||
projectRoot: string,
|
||||
statusFile: string,
|
||||
phone: string,
|
||||
): Promise<void> {
|
||||
// Poll for pairing code (15s)
|
||||
let pairingCode = '';
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const statusContent = readFileSafe(statusFile);
|
||||
if (statusContent === 'already_authenticated') {
|
||||
emitAuthStatus('pairing-code', 'already_authenticated', 'success');
|
||||
return;
|
||||
}
|
||||
if (statusContent.startsWith('pairing_code:')) {
|
||||
pairingCode = statusContent.replace('pairing_code:', '');
|
||||
break;
|
||||
}
|
||||
if (statusContent.startsWith('failed:')) {
|
||||
emitAuthStatus('pairing-code', 'failed', 'failed', {
|
||||
ERROR: statusContent.replace('failed:', ''),
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
if (!pairingCode) {
|
||||
emitAuthStatus('pairing-code', 'failed', 'failed', { ERROR: 'pairing_code_timeout' });
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
// Emit pairing code immediately so the caller can display it to the user
|
||||
emitAuthStatus('pairing-code', 'pairing_code_ready', 'waiting', { PAIRING_CODE: pairingCode });
|
||||
|
||||
// Poll for completion (120s)
|
||||
await pollAuthCompletion('pairing-code', statusFile, projectRoot, pairingCode);
|
||||
}
|
||||
|
||||
async function pollAuthCompletion(
|
||||
method: string,
|
||||
statusFile: string,
|
||||
projectRoot: string,
|
||||
pairingCode?: string,
|
||||
): Promise<void> {
|
||||
const extra: Record<string, string> = {};
|
||||
if (pairingCode) extra.PAIRING_CODE = pairingCode;
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const content = readFileSafe(statusFile);
|
||||
|
||||
if (content === 'authenticated' || content === 'already_authenticated') {
|
||||
// Write success page if qr-auth.html exists
|
||||
const htmlPath = path.join(projectRoot, 'store', 'qr-auth.html');
|
||||
if (fs.existsSync(htmlPath)) {
|
||||
fs.writeFileSync(htmlPath, SUCCESS_HTML);
|
||||
}
|
||||
const phoneNumber = getPhoneNumber(projectRoot);
|
||||
if (phoneNumber) extra.PHONE_NUMBER = phoneNumber;
|
||||
emitAuthStatus(method, content, 'success', extra);
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.startsWith('failed:')) {
|
||||
const error = content.replace('failed:', '');
|
||||
emitAuthStatus(method, 'failed', 'failed', { ERROR: error, ...extra });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
emitAuthStatus(method, 'failed', 'failed', { ERROR: 'timeout', ...extra });
|
||||
process.exit(3);
|
||||
}
|
||||
Reference in New Issue
Block a user