diff --git a/partiful b/partiful deleted file mode 100644 index cc786ff..0000000 --- a/partiful +++ /dev/null @@ -1,1600 +0,0 @@ -#!/usr/bin/env node -/** - * Partiful CLI - Create and manage Partiful events from the command line - * - * Usage: - * partiful create --title "Party Name" --date "2026-04-01 7pm" --location "My Place" - * partiful list [--past] [--json] - * partiful get - * partiful cancel - * partiful auth-status - */ - -const fs = require('fs'); -const path = require('path'); -const https = require('https'); -const http = require('http'); -const crypto = require('crypto'); -const readline = require('readline'); - -const CONFIG_PATH = path.join(process.env.HOME, '.config/partiful/auth.json'); -const API_BASE = 'api.partiful.com'; -const FIRESTORE_BASE = 'firestore.googleapis.com'; -const FIRESTORE_PROJECT = 'getpartiful'; -const GOOGLE_TOKEN_URL = 'securetoken.googleapis.com'; - -// ───────────────────────────────────────────────────────────────────────────── -// Auth helpers -// ───────────────────────────────────────────────────────────────────────────── - -function loadConfig() { - if (!fs.existsSync(CONFIG_PATH)) { - console.error('Error: No auth config found at', CONFIG_PATH); - console.error('Run browser auth flow first to extract credentials.'); - process.exit(1); - } - return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); -} - -function saveConfig(config) { - fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)); -} - -async function refreshAccessToken(config) { - return new Promise((resolve, reject) => { - const postData = `grant_type=refresh_token&refresh_token=${config.refreshToken}`; - - const options = { - hostname: GOOGLE_TOKEN_URL, - port: 443, - path: `/v1/token?key=${config.apiKey}`, - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': Buffer.byteLength(postData), - 'Referer': 'https://partiful.com/' - } - }; - - const req = https.request(options, (res) => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - try { - const result = JSON.parse(data); - if (result.error) { - reject(new Error(result.error.message)); - } else { - resolve(result); - } - } catch (e) { - reject(e); - } - }); - }); - - req.on('error', reject); - req.write(postData); - req.end(); - }); -} - -async function getValidToken(config) { - // Check if we have a cached token that's still valid - if (config.accessToken && config.tokenExpiry) { - const now = Date.now(); - if (now < config.tokenExpiry - 60000) { // 1 min buffer - return config.accessToken; - } - } - - // Refresh the token - console.error('Refreshing access token...'); - const result = await refreshAccessToken(config); - - config.accessToken = result.id_token; - config.tokenExpiry = Date.now() + (parseInt(result.expires_in) * 1000); - - // Update refresh token if rotated - if (result.refresh_token) { - config.refreshToken = result.refresh_token; - } - - saveConfig(config); - return config.accessToken; -} - -// ───────────────────────────────────────────────────────────────────────────── -// API helpers -// ───────────────────────────────────────────────────────────────────────────── - -function generateAmplitudeDeviceId() { - return crypto.randomBytes(12).toString('base64').replace(/[+/=]/g, ''); -} - -async function apiRequest(method, endpoint, token, body = null) { - return new Promise((resolve, reject) => { - const bodyStr = body ? JSON.stringify(body) : null; - - const options = { - hostname: API_BASE, - port: 443, - path: endpoint, - method: method, - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Accept': 'application/json, text/plain, */*', - 'Origin': 'https://partiful.com', - 'Referer': 'https://partiful.com/' - } - }; - - if (bodyStr) { - options.headers['Content-Length'] = Buffer.byteLength(bodyStr); - } - - const req = https.request(options, (res) => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - try { - const result = data ? JSON.parse(data) : {}; - result._statusCode = res.statusCode; - resolve(result); - } catch (e) { - resolve({ _raw: data, _statusCode: res.statusCode }); - } - }); - }); - - req.on('error', reject); - if (bodyStr) req.write(bodyStr); - req.end(); - }); -} - -async function firestoreRequest(method, eventId, body, token, updateFields = []) { - return new Promise((resolve, reject) => { - let path = `/v1/projects/${FIRESTORE_PROJECT}/databases/(default)/documents/events/${eventId}`; - if (method === 'PATCH' && updateFields.length > 0) { - path += '?' + updateFields.map(f => `updateMask.fieldPaths=${f}`).join('&'); - } - - const bodyStr = body ? JSON.stringify(body) : null; - const options = { - hostname: FIRESTORE_BASE, - port: 443, - path: path, - method: method, - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Referer': 'https://partiful.com/' - } - }; - if (bodyStr) options.headers['Content-Length'] = Buffer.byteLength(bodyStr); - - const req = https.request(options, (res) => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - try { - const result = data ? JSON.parse(data) : {}; - result._statusCode = res.statusCode; - resolve(result); - } catch (e) { - resolve({ _raw: data, _statusCode: res.statusCode }); - } - }); - }); - - req.on('error', reject); - if (bodyStr) req.write(bodyStr); - req.end(); - }); -} - -async function firestoreListDocuments(collectionPath, token, pageSize = 100, pageToken = null) { - return new Promise((resolve, reject) => { - let path = `/v1/projects/${FIRESTORE_PROJECT}/databases/(default)/documents/${collectionPath}?pageSize=${pageSize}`; - if (pageToken) path += `&pageToken=${encodeURIComponent(pageToken)}`; - - const options = { - hostname: FIRESTORE_BASE, - port: 443, - path: path, - method: 'GET', - headers: { - 'Authorization': `Bearer ${token}`, - 'Referer': 'https://partiful.com/' - } - }; - - const req = https.request(options, (res) => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - try { - const result = data ? JSON.parse(data) : {}; - result._statusCode = res.statusCode; - resolve(result); - } catch (e) { - resolve({ _raw: data, _statusCode: res.statusCode }); - } - }); - }); - - req.on('error', reject); - req.end(); - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Date helpers -// ───────────────────────────────────────────────────────────────────────────── - -function parseDateTime(dateStr, timezone = 'America/Los_Angeles') { - // Handle relative dates first - const lower = dateStr.trim().toLowerCase(); - const now = new Date(); - - if (lower === 'tomorrow') { - const d = new Date(now); - d.setDate(d.getDate() + 1); - d.setHours(19, 0, 0, 0); // default 7pm - return d; - } - - const nextDayMatch = lower.match(/^next\s+(sunday|monday|tuesday|wednesday|thursday|friday|saturday)(?:\s+(.+))?$/i); - if (nextDayMatch) { - const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; - const targetDay = dayNames.indexOf(nextDayMatch[1].toLowerCase()); - const d = new Date(now); - let daysAhead = targetDay - d.getDay(); - if (daysAhead <= 0) daysAhead += 7; - d.setDate(d.getDate() + daysAhead); - - // Parse optional time portion like "7pm" or "3:30 PM" - if (nextDayMatch[2]) { - const timeParsed = parseTimeString(nextDayMatch[2].trim()); - if (timeParsed) { - d.setHours(timeParsed.hours, timeParsed.minutes, 0, 0); - } else { - d.setHours(19, 0, 0, 0); - } - } else { - d.setHours(19, 0, 0, 0); - } - return d; - } - - // Parse human-friendly dates like "2026-04-01 7pm", "April 1, 2026 7:00 PM", "Mar 15 8am" - const cleanStr = dateStr.replace(/(\d{1,2})(am|pm)/i, '$1:00 $2'); - let date = new Date(cleanStr); - - // If year is missing, Date may default to 2001 or similar — detect and fix - if (isNaN(date.getTime()) || needsYearFix(dateStr, date)) { - // Try prepending/appending current year - const withYear = tryAddYear(dateStr, now); - if (withYear) { - const cleanWithYear = withYear.replace(/(\d{1,2})(am|pm)/i, '$1:00 $2'); - date = new Date(cleanWithYear); - } - } - - if (isNaN(date.getTime())) { - throw new Error(`Could not parse date: ${dateStr}`); - } - - // If date ended up in the past and no explicit year was given, bump to next year - if (!hasExplicitYear(dateStr) && date < now) { - date.setFullYear(date.getFullYear() + 1); - } - - return date; -} - -function parseTimeString(str) { - // Parse "7pm", "7:30pm", "7:30 PM", "19:00" - const match = str.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i); - if (!match) return null; - let hours = parseInt(match[1]); - const minutes = parseInt(match[2] || '0'); - const ampm = match[3]?.toLowerCase(); - if (ampm === 'pm' && hours < 12) hours += 12; - if (ampm === 'am' && hours === 12) hours = 0; - return { hours, minutes }; -} - -function hasExplicitYear(dateStr) { - // Check if the string contains a 4-digit year - return /\b20\d{2}\b/.test(dateStr); -} - -function needsYearFix(dateStr, date) { - // If no explicit year and parsed year is not current/next year, needs fix - if (hasExplicitYear(dateStr)) return false; - const currentYear = new Date().getFullYear(); - return date.getFullYear() < currentYear || date.getFullYear() > currentYear + 1; -} - -function tryAddYear(dateStr, now) { - const year = now.getFullYear(); - // Try "Mar 15 8am" -> "Mar 15 2026 8am" - // Insert year before time portion - const timeMatch = dateStr.match(/^(.+?)(\d{1,2}(?::\d{2})?\s*(?:am|pm).*)$/i); - if (timeMatch) { - return `${timeMatch[1].trim()} ${year} ${timeMatch[2].trim()}`; - } - // Just append year - return `${dateStr} ${year}`; -} - -function stripMarkdown(text) { - if (!text) return text; - return text - .replace(/\*\*(.*?)\*\*/g, '$1') // bold - .replace(/\*(.*?)\*/g, '$1') // italic - .replace(/\[(.*?)\]\(.*?\)/g, '$1') // links - .replace(/#{1,6}\s+/g, '') // headings - .replace(/`(.*?)`/g, '$1') // inline code - .replace(/~~(.*?)~~/g, '$1') // strikethrough - .replace(/>\s+/g, ''); // blockquotes -} - -function formatDate(isoStr) { - const d = new Date(isoStr); - return d.toLocaleDateString('en-US', { - weekday: 'short', month: 'short', day: 'numeric', - hour: 'numeric', minute: '2-digit' - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Auth login/logout -// ───────────────────────────────────────────────────────────────────────────── - -async function authLogin() { - const configDir = path.dirname(CONFIG_PATH); - if (!fs.existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true }); - } - - const PORT = 9876; - - // Bookmarklet that extracts Firebase auth from IndexedDB - const extractorCode = `(async function(){try{const dbReq=indexedDB.open('firebaseLocalStorageDb');dbReq.onsuccess=function(e){const db=e.target.result;const tx=db.transaction('firebaseLocalStorage','readonly');const store=tx.objectStore('firebaseLocalStorage');const getReq=store.getAll();getReq.onsuccess=function(){const items=getReq.result;const authItem=items.find(i=>i.fbase_key&&i.fbase_key.includes('firebase:authUser'));if(!authItem||!authItem.value){alert('No auth found. Make sure you are logged into Partiful.');return;}const v=authItem.value;const data={apiKey:v.apiKey,refreshToken:v.stsTokenManager?.refreshToken,userId:v.uid,displayName:v.displayName,phoneNumber:v.phoneNumber};if(!data.refreshToken){alert('No refresh token found. Try logging out and back in.');return;}fetch('http://localhost:${PORT}/auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}).then(r=>r.ok?alert('✓ Auth saved! You can close this tab.'):alert('Failed to save auth')).catch(()=>alert('Could not connect to CLI. Is it running?'));};};dbReq.onerror=()=>alert('Could not open IndexedDB');}catch(e){alert('Error: '+e.message);}})();`; - - const bookmarklet = 'javascript:' + encodeURIComponent(extractorCode); - - console.log(` -╔══════════════════════════════════════════════════════════════════╗ -║ Partiful CLI Auth Setup ║ -╚══════════════════════════════════════════════════════════════════╝ - -Step 1: Open https://partiful.com and log in (if not already) - -Step 2: Create a bookmarklet with this code: - - Create a new bookmark in your browser - - Set the URL to the code below - - Name it "Partiful Auth" - -Step 3: While on partiful.com, click the bookmarklet - -───────────────────────────────────────────────────────────────────── -BOOKMARKLET CODE (copy everything below): -───────────────────────────────────────────────────────────────────── -${bookmarklet} -───────────────────────────────────────────────────────────────────── - -Waiting for auth data on http://localhost:${PORT}... -(Press Ctrl+C to cancel) -`); - - return new Promise((resolve, reject) => { - const server = http.createServer((req, res) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - - if (req.method === 'OPTIONS') { - res.writeHead(204); - res.end(); - return; - } - - if (req.method === 'POST' && req.url === '/auth') { - let body = ''; - req.on('data', chunk => body += chunk); - req.on('end', () => { - try { - const data = JSON.parse(body); - if (!data.refreshToken || !data.userId) { - res.writeHead(400); - res.end('Missing required fields'); - return; - } - - const config = { - apiKey: data.apiKey || 'AIzaSyCky6PJ7cHRdBKk5X7gjuWERWaKWBHr4_k', - refreshToken: data.refreshToken, - userId: data.userId, - displayName: data.displayName || 'Unknown', - phoneNumber: data.phoneNumber || 'Unknown' - }; - - fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)); - - res.writeHead(200); - res.end('OK'); - - console.log(` -✓ Auth saved successfully! - User: ${config.displayName} - Phone: ${config.phoneNumber} - Config: ${CONFIG_PATH} - -You can now use all partiful commands. -`); - - server.close(); - resolve(); - } catch (e) { - res.writeHead(400); - res.end('Invalid JSON'); - } - }); - } else { - res.writeHead(404); - res.end('Not found'); - } - }); - - server.on('error', (e) => { - if (e.code === 'EADDRINUSE') { - console.error(`Error: Port ${PORT} is already in use.`); - console.error('Kill the process using it or try again later.'); - } else { - console.error('Server error:', e.message); - } - reject(e); - }); - - server.listen(PORT); - }); -} - -async function authLogout() { - if (fs.existsSync(CONFIG_PATH)) { - fs.unlinkSync(CONFIG_PATH); - console.log('✓ Logged out. Auth config removed.'); - } else { - console.log('Already logged out (no config found).'); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// User prompt -// ───────────────────────────────────────────────────────────────────────────── - -async function confirm(question) { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr - }); - - return new Promise(resolve => { - rl.question(question + ' [y/N]: ', answer => { - rl.close(); - resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); - }); - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Commands -// ───────────────────────────────────────────────────────────────────────────── - -async function authStatus() { - const config = loadConfig(); - console.log('Auth Status:'); - console.log(' User:', config.displayName); - console.log(' Phone:', config.phoneNumber); - console.log(' User ID:', config.userId); - - try { - await getValidToken(config); - console.log(' Token: ✓ Valid'); - } catch (e) { - console.log(' Token: ✗ Invalid -', e.message); - } -} - -async function listEvents(options) { - const config = loadConfig(); - const token = await getValidToken(config); - - const endpoint = options.past - ? '/getMyPastEventsForHomePage' - : '/getMyUpcomingEventsForHomePage'; - - const payload = { - data: { - params: {}, - amplitudeDeviceId: config.amplitudeDeviceId || generateAmplitudeDeviceId(), - amplitudeSessionId: Date.now(), - userId: config.userId - } - }; - - const result = await apiRequest('POST', endpoint, token, payload); - - if (result._statusCode !== 200) { - console.error('✗ Failed to list events'); - console.error(JSON.stringify(result, null, 2)); - process.exit(1); - } - - let events = options.past - ? result.result?.data?.pastEvents - : result.result?.data?.upcomingEvents; - - // Filter out cancelled events unless --include-cancelled - if (!options.includeCancelled && events) { - events = events.filter(e => e.status !== 'CANCELED'); - } - - if (options.json) { - console.log(JSON.stringify(events, null, 2)); - return; - } - - if (!events || events.length === 0) { - console.log(options.past ? 'No past events.' : 'No upcoming events.'); - return; - } - - console.log(options.past ? 'Past Events:' : 'Upcoming Events:'); - console.log('─'.repeat(60)); - - for (const event of events) { - const isHosted = event.ownerIds?.includes(config.userId); - const role = isHosted ? '(hosting)' : '(invited)'; - const going = event.guestStatusCounts?.GOING || 0; - const maybe = event.guestStatusCounts?.MAYBE || 0; - const cancelled = event.status === 'CANCELED' ? ' ⚠️ CANCELLED' : ''; - - console.log(`\n${event.title} ${role}${cancelled}`); - console.log(` ID: ${event.id}`); - console.log(` When: ${formatDate(event.startDate)}`); - if (event.location) console.log(` Where: ${event.location}`); - console.log(` RSVPs: ${going} going, ${maybe} maybe`); - console.log(` URL: https://partiful.com/e/${event.id}`); - } -} - -async function createEvent(options) { - const config = loadConfig(); - const token = await getValidToken(config); - const timezone = options.timezone || 'America/Los_Angeles'; - - // Parse dates - const startDate = parseDateTime(options.date, timezone); - const endDate = options.endDate ? parseDateTime(options.endDate, timezone) : null; - - // Build event payload - const eventPayload = { - data: { - params: { - event: { - title: options.title, - startDate: startDate.toISOString(), - endDate: endDate ? endDate.toISOString() : null, - timezone: timezone, - location: options.location || null, - address: options.address || null, - description: stripMarkdown(options.description) || null, - guestStatusCounts: { - READY_TO_SEND: 0, SENDING: 0, SENT: 0, SEND_ERROR: 0, - DELIVERY_ERROR: 0, INTERESTED: 0, MAYBE: 0, GOING: 0, - DECLINED: 0, WAITLIST: 0, PENDING_APPROVAL: 0, APPROVED: 0, - WITHDRAWN: 0, RESPONDED_TO_FIND_A_TIME: 0, - WAITLISTED_FOR_APPROVAL: 0, REJECTED: 0 - }, - displaySettings: { - theme: options.theme || 'oxblood', - effect: options.effect || 'sunbeams', - titleFont: 'display' - }, - showHostList: true, - showGuestCount: true, - showGuestList: true, - showActivityTimestamps: true, - displayInviteButton: true, - visibility: options.private ? 'private' : 'public', - allowGuestPhotoUpload: true, - enableGuestReminders: true, - rsvpsEnabled: true, - allowGuestsToInviteMutuals: true, - rsvpButtonGlyphType: 'emojis', - status: 'UNSAVED' - }, - cohostIds: [] - }, - amplitudeDeviceId: config.amplitudeDeviceId || generateAmplitudeDeviceId(), - amplitudeSessionId: Date.now(), - userId: config.userId - } - }; - - // Add capacity if specified - if (options.capacity) { - eventPayload.data.params.event.guestLimit = parseInt(options.capacity); - eventPayload.data.params.event.enableWaitlist = options.waitlist !== false; - } - - // Remove null values - Object.keys(eventPayload.data.params.event).forEach(k => { - if (eventPayload.data.params.event[k] === null) { - delete eventPayload.data.params.event[k]; - } - }); - - console.error('Creating event:', options.title); - const result = await apiRequest('POST', '/createEvent', token, eventPayload); - - if (result._statusCode === 200 || result.result?.data) { - const eventId = result.result?.data || result.result?.eventId; - console.log('✓ Event created!'); - console.log(` URL: https://partiful.com/e/${eventId}`); - if (options.json) { - console.log(JSON.stringify(result, null, 2)); - } - } else { - console.error('✗ Failed to create event'); - console.error(JSON.stringify(result, null, 2)); - process.exit(1); - } -} - -async function cancelEvent(eventId, options) { - const config = loadConfig(); - const token = await getValidToken(config); - - // First, get event details for confirmation - if (!options.force) { - const getPayload = { - data: { - params: { eventId }, - amplitudeDeviceId: config.amplitudeDeviceId || generateAmplitudeDeviceId(), - amplitudeSessionId: Date.now(), - userId: config.userId - } - }; - - const eventResult = await apiRequest('POST', '/getEvent', token, getPayload); - - if (eventResult._statusCode === 200 && eventResult.result?.data?.event) { - const event = eventResult.result.data.event; - console.error('\n⚠️ About to cancel event:'); - console.error(` Title: ${event.title}`); - console.error(` Date: ${formatDate(event.startDate)}`); - if (event.guestStatusCounts) { - const going = event.guestStatusCounts.GOING || 0; - const maybe = event.guestStatusCounts.MAYBE || 0; - console.error(` Guests: ${going} going, ${maybe} maybe`); - } - console.error(''); - - const confirmed = await confirm('Are you sure? This cannot be undone.'); - if (!confirmed) { - console.log('Cancelled.'); - return; - } - } - } - - const payload = { - data: { - params: { eventId }, - amplitudeDeviceId: config.amplitudeDeviceId || generateAmplitudeDeviceId(), - amplitudeSessionId: Date.now(), - userId: config.userId - } - }; - - console.error('Cancelling event:', eventId); - const result = await apiRequest('POST', '/cancelEvent', token, payload); - - if (result._statusCode === 200) { - console.log('✓ Event cancelled'); - } else { - console.error('✗ Failed to cancel event'); - console.error(JSON.stringify(result, null, 2)); - process.exit(1); - } -} - -async function getEvent(eventId, options) { - const config = loadConfig(); - const token = await getValidToken(config); - - const payload = { - data: { - params: { eventId }, - amplitudeDeviceId: config.amplitudeDeviceId || generateAmplitudeDeviceId(), - amplitudeSessionId: Date.now(), - userId: config.userId - } - }; - - const result = await apiRequest('POST', '/getEvent', token, payload); - - if (options.json || !result.result?.data?.event) { - console.log(JSON.stringify(result, null, 2)); - return; - } - - const event = result.result.data.event; - - // Show cancelled warning prominently - if (event.status === 'CANCELED') { - console.log('\n⚠️ THIS EVENT IS CANCELLED'); - console.log('─'.repeat(40)); - } - - console.log(`\n${event.title}`); - console.log('─'.repeat(40)); - console.log(`ID: ${eventId}`); - if (event.status === 'CANCELED') console.log(`Status: CANCELLED`); - console.log(`When: ${formatDate(event.startDate)}`); - if (event.endDate) console.log(`Until: ${formatDate(event.endDate)}`); - if (event.location) console.log(`Where: ${event.location}`); - if (event.description) console.log(`\n${event.description}`); - if (event.guestStatusCounts) { - const counts = event.guestStatusCounts; - console.log(`\nRSVPs: ${counts.GOING || 0} going, ${counts.MAYBE || 0} maybe, ${counts.DECLINED || 0} declined`); - } - console.log(`\nURL: https://partiful.com/e/${eventId}`); -} - -async function cloneEvent(sourceEventId, options) { - const config = loadConfig(); - const token = await getValidToken(config); - - // Fetch source event - console.error(`Fetching source event ${sourceEventId}...`); - const payload = { - data: { - params: { eventId: sourceEventId }, - amplitudeDeviceId: config.amplitudeDeviceId || generateAmplitudeDeviceId(), - amplitudeSessionId: Date.now(), - userId: config.userId - } - }; - const result = await apiRequest('POST', '/getEvent', token, payload); - - if (result._statusCode !== 200 || !result.result?.data?.event) { - console.error('✗ Could not fetch source event'); - if (result._statusCode) console.error(` Status: ${result._statusCode}`); - process.exit(1); - } - - const source = result.result.data.event; - - // Build create options from source, with overrides - const createOptions = { - title: options.title || source.title, - date: options.date, // required — must be provided - endDate: options.endDate || null, - location: options.location || source.location || null, - address: options.address || source.address || null, - description: options.description || source.description || null, - capacity: options.capacity || (source.guestLimit ? String(source.guestLimit) : null), - waitlist: source.enableWaitlist !== false, - private: options.private || source.visibility === 'private', - timezone: options.timezone || source.timezone || 'America/Los_Angeles', - theme: options.theme || source.displaySettings?.theme || null, - effect: options.effect || source.displaySettings?.effect || null, - json: options.json - }; - - // If source has endDate and new date was provided, try to preserve duration - if (source.endDate && source.startDate && options.date && !options.endDate) { - try { - const srcStart = new Date(source.startDate); - const srcEnd = new Date(source.endDate); - const durationMs = srcEnd - srcStart; - if (durationMs > 0) { - const newStart = parseDateTime(options.date, createOptions.timezone); - const newEnd = new Date(newStart.getTime() + durationMs); - createOptions.endDate = newEnd.toISOString(); - } - } catch (e) { - // Ignore duration preservation errors - } - } - - // Show what's being cloned - console.error(`\nCloning: ${source.title}`); - console.error(`─`.repeat(40)); - const copied = []; - if (createOptions.title) copied.push(`Title: ${createOptions.title}`); - if (createOptions.location) copied.push(`Location: ${createOptions.location}`); - if (createOptions.description) copied.push(`Description: ${createOptions.description.substring(0, 60)}${createOptions.description.length > 60 ? '...' : ''}`); - if (createOptions.capacity) copied.push(`Capacity: ${createOptions.capacity}`); - if (createOptions.theme) copied.push(`Theme: ${createOptions.theme}`); - copied.forEach(f => console.error(` ✓ ${f}`)); - console.error(''); - - // Create the new event - await createEvent(createOptions); - - // Handle reinvite if requested - if (options.reinvite) { - console.error('\nFetching guest list for reinvites...'); - const guestData = await getGuests(sourceEventId, {}); - const statusFilter = options.reinvite === true ? null : options.reinvite.toUpperCase(); - - const guestsToInvite = guestData.guests.filter(g => { - if (!statusFilter) return true; - return g.status === statusFilter; - }); - - if (guestsToInvite.length === 0) { - console.error(' No guests matched the filter for reinvite.'); - } else { - console.error(` Found ${guestsToInvite.length} guest(s) to reinvite`); - console.error(' ⚠️ Reinvite requires guest phone numbers or user IDs'); - console.error(' Guest names found but IDs not available from Firestore guest docs.'); - console.error(' Use "partiful invite --phone ..." to invite manually.'); - } - } -} - -async function updateEvent(eventId, options) { - const config = loadConfig(); - const token = await getValidToken(config); - - // Build Firestore fields object - const fields = {}; - const updateFields = []; - - if (options.title) { - fields.title = { stringValue: options.title }; - updateFields.push('title'); - } - - if (options.location) { - fields.location = { stringValue: options.location }; - updateFields.push('location'); - } - - if (options.description) { - fields.description = { stringValue: stripMarkdown(options.description) }; - updateFields.push('description'); - } - - if (options.date) { - const startDate = parseDateTime(options.date); - fields.startDate = { timestampValue: startDate.toISOString() }; - updateFields.push('startDate'); - } - - if (options.endDate) { - const endDate = parseDateTime(options.endDate); - fields.endDate = { timestampValue: endDate.toISOString() }; - updateFields.push('endDate'); - } - - if (options.capacity) { - fields.guestLimit = { integerValue: parseInt(options.capacity).toString() }; - updateFields.push('guestLimit'); - } - - if (updateFields.length === 0) { - console.error('Error: No fields to update. Use --title, --location, --description, --date, --end-date, or --capacity'); - process.exit(1); - } - - console.error(`Updating event ${eventId}...`); - console.error(` Fields: ${updateFields.join(', ')}`); - - const result = await firestoreRequest('PATCH', eventId, { fields }, token, updateFields); - - if (result._statusCode === 200) { - console.log('✓ Event updated!'); - console.log(` URL: https://partiful.com/e/${eventId}`); - if (options.json) { - console.log(JSON.stringify(result, null, 2)); - } - } else { - console.error('✗ Failed to update event'); - console.error(JSON.stringify(result, null, 2)); - process.exit(1); - } -} - -async function getGuests(eventId, options) { - const config = loadConfig(); - const token = await getValidToken(config); - - // Get event title and counts from API - const payload = { - data: { - params: { eventId }, - amplitudeDeviceId: config.amplitudeDeviceId || generateAmplitudeDeviceId(), - amplitudeSessionId: Date.now(), - userId: config.userId - } - }; - const eventResult = await apiRequest('POST', '/getEvent', token, payload); - let event = eventResult.result?.data?.event; - let counts = {}; - - if (event) { - counts = event.guestStatusCounts || {}; - } else { - // API down — try to get event title from Firestore directly - console.error('⚠ Partiful API unavailable, falling back to Firestore'); - const fsEvent = await firestoreRequest('GET', eventId, null, token); - if (fsEvent._statusCode === 200 && fsEvent.fields) { - event = { title: fsEvent.fields.title?.stringValue || 'Unknown Event' }; - } else { - event = { title: 'Unknown Event' }; - } - } - - // Fetch individual guests from Firestore - const guests = []; - let pageToken = null; - do { - const result = await firestoreListDocuments(`events/${eventId}/guests`, token, 100, pageToken); - if (result._statusCode !== 200) { - console.error('⚠ Could not fetch guest details (status ' + result._statusCode + ')'); - break; - } - if (result.documents) { - for (const doc of result.documents) { - const f = doc.fields || {}; - guests.push({ - name: f.name?.stringValue || 'Unknown', - status: f.status?.stringValue || 'UNKNOWN', - createdAt: f.createdAt?.timestampValue || null, - inviteDate: f.inviteDate?.timestampValue || null, - count: parseInt(f.count?.integerValue || '1'), - channel: f.inviteMetadata?.mapValue?.fields?.channel?.stringValue || null - }); - } - } - pageToken = result.nextPageToken || null; - } while (pageToken); - - // If API was down, compute counts from guest list - if (Object.keys(counts).length === 0 && guests.length > 0) { - for (const g of guests) { - const s = g.status; - if (s === 'GOING') counts.GOING = (counts.GOING || 0) + 1; - else if (s === 'MAYBE') counts.MAYBE = (counts.MAYBE || 0) + 1; - else if (s === 'SENT') counts.SENT = (counts.SENT || 0) + 1; - else if (s === 'DECLINED') counts.DECLINED = (counts.DECLINED || 0) + 1; - else if (s === 'WAITLIST') counts.WAITLIST = (counts.WAITLIST || 0) + 1; - } - } - - return { - eventId, - eventTitle: event.title, - timestamp: new Date().toISOString(), - counts: { - going: counts.GOING || 0, - maybe: counts.MAYBE || 0, - invited: counts.SENT || 0, - declined: counts.DECLINED || 0, - waitlist: counts.WAITLIST || 0 - }, - total: (counts.GOING || 0) + (counts.MAYBE || 0) + (counts.SENT || 0) + (counts.DECLINED || 0), - guests - }; -} - -async function showGuests(eventId, options) { - const summary = await getGuests(eventId, options); - - if (options.json) { - console.log(JSON.stringify(summary, null, 2)); - return summary; - } - - if (options.csv) { - console.log('name,status,invite_date,plus_ones'); - for (const g of summary.guests) { - const date = g.inviteDate ? new Date(g.inviteDate).toISOString().split('T')[0] : ''; - let name = g.name; - if (name.includes('"') || name.includes(',') || name.includes('\n')) { - name = `"${name.replace(/"/g, '""')}"`; - } - console.log(`${name},${g.status},${date},${g.count}`); - } - return summary; - } - - // Human-readable output - console.log(`\n${summary.eventTitle}`); - console.log('\u2500'.repeat(40)); - console.log(`\nGuest Summary:`); - console.log(` 👍 Going: ${summary.counts.going}`); - console.log(` 🤔 Maybe: ${summary.counts.maybe}`); - console.log(` 💌 Invited: ${summary.counts.invited}`); - console.log(` 😢 Declined: ${summary.counts.declined}`); - if (summary.counts.waitlist > 0) { - console.log(` ⏳ Waitlist: ${summary.counts.waitlist}`); - } - console.log(` ─────────────`); - console.log(` Total: ${summary.total}`); - - if (summary.guests.length > 0) { - const byStatus = {}; - for (const g of summary.guests) { - const s = g.status; - if (!byStatus[s]) byStatus[s] = []; - byStatus[s].push(g); - } - - const statusOrder = ['GOING', 'MAYBE', 'SENT', 'DECLINED', 'WAITLIST']; - const statusEmoji = { GOING: '👍', MAYBE: '🤔', SENT: '💌', DECLINED: '😢', WAITLIST: '⏳' }; - - for (const status of statusOrder) { - if (byStatus[status] && byStatus[status].length > 0) { - console.log(`\n${statusEmoji[status] || ''} ${status} (${byStatus[status].length}):`); - for (const g of byStatus[status]) { - const extra = g.count > 1 ? ` (+${g.count - 1})` : ''; - console.log(` ${g.name}${extra}`); - } - } - } - } - - return summary; -} - -async function watchGuests(eventId, intervalMs = 60000) { - console.log(`Watching RSVPs for event ${eventId}...`); - console.log(`Polling every ${intervalMs / 1000}s. Press Ctrl+C to stop.\n`); - - let lastCounts = null; - - const check = async () => { - try { - const summary = await getGuests(eventId, {}); - const counts = summary.counts; - const time = new Date().toLocaleTimeString(); - - if (lastCounts) { - // Check for changes - const changes = []; - if (counts.going !== lastCounts.going) { - const diff = counts.going - lastCounts.going; - changes.push(`👍 Going: ${lastCounts.going} → ${counts.going} (${diff > 0 ? '+' : ''}${diff})`); - } - if (counts.maybe !== lastCounts.maybe) { - const diff = counts.maybe - lastCounts.maybe; - changes.push(`🤔 Maybe: ${lastCounts.maybe} → ${counts.maybe} (${diff > 0 ? '+' : ''}${diff})`); - } - if (counts.invited !== lastCounts.invited) { - const diff = counts.invited - lastCounts.invited; - changes.push(`💌 Invited: ${lastCounts.invited} → ${counts.invited} (${diff > 0 ? '+' : ''}${diff})`); - } - if (counts.declined !== lastCounts.declined) { - const diff = counts.declined - lastCounts.declined; - changes.push(`😢 Declined: ${lastCounts.declined} → ${counts.declined} (${diff > 0 ? '+' : ''}${diff})`); - } - - if (changes.length > 0) { - console.log(`\n[${time}] 🔔 RSVP CHANGES:`); - changes.forEach(c => console.log(` ${c}`)); - } else { - process.stdout.write(`[${time}] No changes\r`); - } - } else { - console.log(`[${time}] Initial state: ${counts.going} going, ${counts.maybe} maybe, ${counts.invited} invited`); - } - - lastCounts = { ...counts }; - } catch (err) { - console.error(`[${new Date().toLocaleTimeString()}] Error: ${err.message}`); - } - }; - - await check(); - setInterval(check, intervalMs); -} - -async function searchContacts(query, options) { - const config = loadConfig(); - const token = await getValidToken(config); - - const payload = { - data: { - params: {}, - amplitudeDeviceId: config.amplitudeDeviceId || generateAmplitudeDeviceId(), - amplitudeSessionId: Date.now(), - userId: config.userId - } - }; - - const result = await apiRequest('POST', '/getContacts', token, payload); - - if (result._statusCode !== 200) { - console.error('✗ Failed to fetch contacts'); - process.exit(1); - } - - let contacts = result.result?.data || []; - - // Filter by query if provided - if (query) { - const q = query.toLowerCase(); - contacts = contacts.filter(c => (c.name || '').toLowerCase().includes(q)); - } - - if (options.json) { - console.log(JSON.stringify(contacts, null, 2)); - return; - } - - if (contacts.length === 0) { - console.log(query ? `No contacts matching "${query}"` : 'No contacts found'); - return; - } - - console.log(`\nContacts${query ? ` matching "${query}"` : ''} (${contacts.length}):\n`); - contacts.slice(0, options.limit || 20).forEach(c => { - console.log(` ${c.name}`); - console.log(` ID: ${c.id}`); - if (c.sharedEventCount) { - console.log(` ${c.sharedEventCount} shared events`); - } - console.log(); - }); - - if (contacts.length > (options.limit || 20)) { - console.log(` ... and ${contacts.length - (options.limit || 20)} more`); - } -} - -async function sendBlast(eventId, options) { - // Blasts are stored in Firestore subcollections, not via REST API - // This would require Firestore SDK integration - - console.log(`\n⚠️ Text blasts require browser automation.`); - console.log(`\nPartiful stores blasts in Firestore subcollections which`); - console.log(`require authenticated Firestore SDK access.`); - console.log(`\nFor now, use the web interface:`); - console.log(` 1. Open: https://partiful.com/e/${eventId}`); - console.log(` 2. Click "Blast" button`); - console.log(` 3. Select recipients and compose message`); - - if (options.message) { - console.log(`\nYour message draft:`); - console.log(` "${options.message}"`); - } - - process.exit(0); -} - -async function inviteToEvent(eventId, options) { - const config = loadConfig(); - const token = await getValidToken(config); - - // Build invite payload - const payload = { - data: { - params: { - eventId, - userIdsToInvite: options.userIds || [], - phoneContactsToInvite: [], - invitationMessage: options.message || '', - otherMutualsCount: 0 - }, - amplitudeDeviceId: config.amplitudeDeviceId || generateAmplitudeDeviceId(), - amplitudeSessionId: Date.now(), - userId: config.userId - } - }; - - // Handle phone numbers - convert to phoneContactsToInvite format - if (options.phones && options.phones.length > 0) { - payload.data.params.phoneContactsToInvite = options.phones.map(phone => ({ - phoneNumber: phone.replace(/[^+\d]/g, ''), // Clean phone number - firstName: '', - lastName: '' - })); - } - - if (payload.data.params.userIdsToInvite.length === 0 && - payload.data.params.phoneContactsToInvite.length === 0) { - console.error('Error: Provide --user-id or --phone to invite'); - console.error('Usage: partiful invite --phone "+12065551234"'); - process.exit(1); - } - - console.error(`Sending invites to event ${eventId}...`); - const result = await apiRequest('POST', '/addInvitedGuestsAsHost', token, payload); - - if (result._statusCode === 200) { - const invited = (options.userIds?.length || 0) + (options.phones?.length || 0); - console.log(`✓ Invited ${invited} guest(s)!`); - console.log(` View event: https://partiful.com/e/${eventId}`); - if (options.json) { - console.log(JSON.stringify(result, null, 2)); - } - } else { - console.error('✗ Failed to send invites'); - console.error(JSON.stringify(result, null, 2)); - process.exit(1); - } -} - -async function getShareLink(eventId) { - // The share link is just the event URL with a tracking param - // We can generate a simple one or get from the event - const config = loadConfig(); - const token = await getValidToken(config); - - // Get event to verify it exists - const payload = { - data: { - params: { eventId }, - amplitudeDeviceId: config.amplitudeDeviceId || generateAmplitudeDeviceId(), - amplitudeSessionId: Date.now(), - userId: config.userId - } - }; - - const result = await apiRequest('POST', '/getEvent', token, payload); - - if (result._statusCode !== 200 || !result.result?.data?.event) { - console.error('✗ Event not found'); - process.exit(1); - } - - const event = result.result.data.event; - console.log(`\n${event.title}`); - console.log('─'.repeat(40)); - console.log(`Share link: https://partiful.com/e/${eventId}`); - console.log(`\nAnyone with this link can view the event and RSVP.`); -} - -// ───────────────────────────────────────────────────────────────────────────── -// CLI parsing -// ───────────────────────────────────────────────────────────────────────────── - -function parseArgs(args) { - const result = { _: [] }; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg.startsWith('--')) { - const key = arg.slice(2); - const next = args[i + 1]; - if (next && !next.startsWith('--')) { - result[key] = next; - i++; - } else { - result[key] = true; - } - } else if (arg.startsWith('-') && arg.length === 2) { - result[arg.slice(1)] = true; - } else { - result._.push(arg); - } - } - return result; -} - -function printHelp() { - console.log(` -Partiful CLI - Create and manage Partiful events - -Commands: - partiful auth login Set up authentication (first time) - partiful auth logout Remove stored credentials - partiful auth status Check authentication status - partiful list [--past] [--json] List your events - partiful get [--json] Get event details - partiful guests [opts] Get guest list / RSVP counts - partiful create [options] Create a new event - partiful update [opts] Update an existing event - partiful invite [opts] Send invites to an event - partiful blast [opts] Send message blast (opens web UI) - partiful share Get shareable link - partiful contacts [query] Search contacts by name - partiful cancel [-f] Cancel an event - partiful clone [opts] Clone an event with a new date - -List Options: - --past Show past events instead of upcoming - --include-cancelled Include cancelled events (hidden by default) - --json Output raw JSON - -Guests Options: - --json Output as JSON - --csv Output as CSV - --watch Watch for RSVP changes (polls every 60s) - -Create Options: - --title "Event Name" Event title (required) - --date "2026-04-01 7pm" Start date/time (required) - --end-date "2026-04-01 10pm" End date/time (optional) - --location "Venue Name" Location name - --address "123 Main St" Street address - --description "Details" Event description - --capacity 10 Guest limit - --waitlist Enable waitlist (default: true) - --private Make event private - --timezone "America/Los_Angeles" Timezone (default: PT) - --theme "oxblood" Color theme - --json Output full JSON response - -Update Options: - --title "New Title" Update event title - --date "2026-04-01 8pm" Update start date/time - --end-date "2026-04-01 11pm" Update end date/time - --location "New Venue" Update location - --description "New details" Update description - --capacity 15 Update guest limit - --json Output full JSON response - -Invite Options: - --phone "+12065551234" Invite by phone number (repeatable) - --user-id "abc123" Invite by Partiful user ID (repeatable) - --message "Custom note" Optional invitation message - --json Output full JSON response - -Cancel Options: - -f, --force Skip confirmation prompt - -Clone Options: - --date "Apr 22 7pm" New date for cloned event (required) - --end-date "Apr 22 10pm" New end date (auto-calculated if omitted) - --title "New Title" Override title - --location "New Venue" Override location - --description "New desc" Override description - --reinvite List guests to reinvite (all statuses) - --reinvite going List only guests with GOING status - --json Output full JSON response - -Examples: - partiful auth login - partiful list - partiful create --title "Game Night" --date "Apr 15 7pm" --capacity 10 - partiful update FDwyIXK42phoWEZgFin5 --title "Game Night v2" - partiful invite FDwyIXK42phoWEZgFin5 --phone "+12065551234" - partiful share FDwyIXK42phoWEZgFin5 - partiful cancel FDwyIXK42phoWEZgFin5 -f - partiful clone FDwyIXK42phoWEZgFin5 --date "Apr 22 7pm" -`); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Main -// ───────────────────────────────────────────────────────────────────────────── - -async function main() { - const args = parseArgs(process.argv.slice(2)); - const command = args._[0]; - const subcommand = args._[1]; - - switch (command) { - case 'auth': - switch (subcommand) { - case 'login': - await authLogin(); - break; - case 'logout': - await authLogout(); - break; - case 'status': - default: - await authStatus(); - break; - } - break; - - case 'auth-status': - case 'status': - await authStatus(); - break; - - case 'list': - case 'ls': - await listEvents({ - past: args.past, - json: args.json, - includeCancelled: args['include-cancelled'] - }); - break; - - case 'create': - if (!args.title || !args.date) { - console.error('Error: --title and --date are required'); - console.error('Usage: partiful create --title "Party" --date "2026-04-01 7pm"'); - process.exit(1); - } - await createEvent({ - title: args.title, - date: args.date, - endDate: args['end-date'], - location: args.location, - address: args.address, - description: args.description, - capacity: args.capacity, - waitlist: args.waitlist, - private: args.private, - timezone: args.timezone, - theme: args.theme, - json: args.json - }); - break; - - case 'cancel': - case 'delete': - const cancelId = args._[1]; - if (!cancelId) { - console.error('Error: Event ID required'); - console.error('Usage: partiful cancel '); - process.exit(1); - } - await cancelEvent(cancelId, { force: args.force || args.f }); - break; - - case 'update': - case 'edit': - const updateId = args._[1]; - if (!updateId) { - console.error('Error: Event ID required'); - console.error('Usage: partiful update --title "New Title"'); - process.exit(1); - } - await updateEvent(updateId, { - title: args.title, - date: args.date, - endDate: args['end-date'], - location: args.location, - description: args.description, - capacity: args.capacity, - json: args.json - }); - break; - - case 'get': - case 'show': - const eventId = args._[1]; - if (!eventId) { - console.error('Error: Event ID required'); - console.error('Usage: partiful get '); - process.exit(1); - } - await getEvent(eventId, { json: args.json }); - break; - - case 'guests': - case 'attendees': - case 'rsvps': - const guestsEventId = args._[1]; - if (!guestsEventId) { - console.error('Error: Event ID required'); - console.error('Usage: partiful guests [--json|--csv|--watch]'); - process.exit(1); - } - if (args.watch) { - await watchGuests(guestsEventId, 60000); - } else { - await showGuests(guestsEventId, { json: args.json, csv: args.csv }); - } - break; - - case 'invite': - const inviteEventId = args._[1]; - if (!inviteEventId) { - console.error('Error: Event ID required'); - console.error('Usage: partiful invite --phone "+12065551234"'); - process.exit(1); - } - // Collect all --phone and --user-id args (support multiple) - const phones = []; - const userIds = []; - for (let i = 0; i < process.argv.length; i++) { - if (process.argv[i] === '--phone' && process.argv[i + 1]) { - phones.push(process.argv[i + 1]); - } - if (process.argv[i] === '--user-id' && process.argv[i + 1]) { - userIds.push(process.argv[i + 1]); - } - } - await inviteToEvent(inviteEventId, { - phones: phones.length > 0 ? phones : null, - userIds: userIds.length > 0 ? userIds : null, - message: args.message, - json: args.json - }); - break; - - case 'share': - case 'link': - const shareEventId = args._[1]; - if (!shareEventId) { - console.error('Error: Event ID required'); - console.error('Usage: partiful share '); - process.exit(1); - } - await getShareLink(shareEventId); - break; - - case 'blast': - case 'message': - const blastEventId = args._[1]; - if (!blastEventId) { - console.error('Error: Event ID required'); - console.error('Usage: partiful blast --message "Your message"'); - process.exit(1); - } - await sendBlast(blastEventId, { message: args.message }); - break; - - case 'contacts': - case 'contact': - case 'search': - const contactQuery = args._[1]; - await searchContacts(contactQuery, { json: args.json, limit: args.limit || 20 }); - break; - - case 'clone': - case 'duplicate': - case 'copy': - const cloneSourceId = args._[1]; - if (!cloneSourceId) { - console.error('Error: Source event ID required'); - console.error('Usage: partiful clone --date "Apr 22 7pm"'); - process.exit(1); - } - if (!args.date) { - console.error('Error: --date is required for clone'); - console.error('Usage: partiful clone --date "Apr 22 7pm" [--title "New Title"]'); - process.exit(1); - } - await cloneEvent(cloneSourceId, { - date: args.date, - endDate: args['end-date'], - title: args.title, - location: args.location, - address: args.address, - description: args.description, - capacity: args.capacity, - private: args.private, - timezone: args.timezone, - theme: args.theme, - reinvite: args.reinvite, - json: args.json - }); - break; - - case 'help': - case '--help': - case '-h': - case undefined: - printHelp(); - break; - - default: - console.error(`Unknown command: ${command}`); - printHelp(); - process.exit(1); - } -} - -main().catch(err => { - console.error('Error:', err.message); - process.exit(1); -}); diff --git a/src/cli.js b/src/cli.js index 3fa55c6..1cb3a65 100644 --- a/src/cli.js +++ b/src/cli.js @@ -8,6 +8,8 @@ import { registerCloneHelper } from './helpers/clone.js'; import { registerWatchHelper } from './helpers/watch.js'; import { registerExportHelper } from './helpers/export.js'; import { registerShareHelper } from './helpers/share.js'; +import { registerSchemaCommand } from './commands/schema.js'; +import { jsonOutput } from './lib/output.js'; export function run() { const program = new Command(); @@ -33,6 +35,15 @@ export function run() { registerWatchHelper(program); registerExportHelper(program); registerShareHelper(program); + registerSchemaCommand(program); + + program + .command('version') + .description('Show CLI version and info') + .action((opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + jsonOutput({ version: program.version(), cli: 'partiful', node: process.version }, {}, globalOpts); + }); program.parse(); } diff --git a/src/commands/schema.js b/src/commands/schema.js new file mode 100644 index 0000000..36a309e --- /dev/null +++ b/src/commands/schema.js @@ -0,0 +1,100 @@ +import { jsonOutput, jsonError, EXIT } from '../lib/output.js'; + +const SCHEMAS = { + 'events.list': { + command: 'events list', + parameters: { + '--past': { type: 'boolean', required: false, description: 'Show past events' }, + '--include-cancelled': { type: 'boolean', required: false, description: 'Include cancelled events' }, + }, + }, + 'events.get': { + command: 'events get ', + parameters: { + eventId: { type: 'string', required: true, description: 'Event ID', positional: true }, + }, + }, + 'events.create': { + command: 'events create', + parameters: { + '--title': { type: 'string', required: true, description: 'Event title' }, + '--date': { type: 'string', required: true, description: 'Start date/time (natural language)' }, + '--end-date': { type: 'string', required: false, description: 'End date/time' }, + '--location': { type: 'string', required: false, description: 'Venue name' }, + '--address': { type: 'string', required: false, description: 'Street address' }, + '--description': { type: 'string', required: false, description: 'Event description' }, + '--capacity': { type: 'integer', required: false, description: 'Guest limit' }, + '--private': { type: 'boolean', required: false, default: false, description: 'Make event private' }, + '--timezone': { type: 'string', required: false, default: 'America/Los_Angeles', description: 'Timezone' }, + '--theme': { type: 'string', required: false, default: 'oxblood', description: 'Color theme' }, + }, + }, + 'events.update': { + command: 'events update ', + parameters: { + eventId: { type: 'string', required: true, positional: true }, + '--title': { type: 'string', required: false }, + '--date': { type: 'string', required: false }, + '--end-date': { type: 'string', required: false }, + '--location': { type: 'string', required: false }, + '--description': { type: 'string', required: false }, + '--capacity': { type: 'integer', required: false }, + }, + }, + 'events.cancel': { + command: 'events cancel ', + parameters: { + eventId: { type: 'string', required: true, positional: true }, + }, + }, + 'guests.list': { + command: 'guests list ', + parameters: { + eventId: { type: 'string', required: true, positional: true }, + '--status': { type: 'string', required: false, description: 'Filter by status' }, + }, + }, + 'guests.invite': { + command: 'guests invite ', + parameters: { + eventId: { type: 'string', required: true, positional: true }, + '--phone': { type: 'string[]', required: false, description: 'Phone numbers' }, + '--user-id': { type: 'string[]', required: false, description: 'Partiful user IDs' }, + '--message': { type: 'string', required: false, description: 'Custom invitation message' }, + }, + }, + 'contacts.list': { + command: 'contacts list [query]', + parameters: { + query: { type: 'string', required: false, positional: true, description: 'Search query' }, + '--limit': { type: 'integer', required: false, default: 20 }, + }, + }, + 'blasts.send': { + command: 'blasts send ', + parameters: { + eventId: { type: 'string', required: true, positional: true, description: 'Event ID' }, + '--message': { type: 'string', required: false, description: 'Message to send' }, + }, + }, +}; + +export function registerSchemaCommand(program) { + program + .command('schema [path]') + .description('Introspect command parameters (e.g., events.create)') + .action((path, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + if (!path) { + jsonOutput({ commands: Object.keys(SCHEMAS) }, { count: Object.keys(SCHEMAS).length }, globalOpts); + return; + } + if (!Object.hasOwn(SCHEMAS, path)) { + const available = Object.keys(SCHEMAS).join(', '); + jsonError(`Unknown schema path: ${path}. Available: ${available}`, 4, 'not_found'); + return; + } + const schema = SCHEMAS[path]; + jsonOutput(schema, {}, globalOpts); + }); +} diff --git a/tests/events-integration.test.js b/tests/events-integration.test.js new file mode 100644 index 0000000..4bf0cba --- /dev/null +++ b/tests/events-integration.test.js @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { run, runRaw } from './helpers.js'; + +describe('events integration', () => { + describe('--dry-run returns payload without API calls', () => { + it('events list --dry-run', () => { + const out = run(['events', 'list', '--dry-run']); + expect(out.status).toBe('success'); + expect(out.data.dryRun).toBe(true); + expect(out.data.endpoint).toContain('UpcomingEvents'); + expect(out.data.payload).toBeDefined(); + }); + + it('events list --past --dry-run', () => { + const out = run(['events', 'list', '--past', '--dry-run']); + expect(out.data.endpoint).toContain('PastEvents'); + }); + + it('events get --dry-run', () => { + const out = run(['events', 'get', 'test-event-123', '--dry-run']); + expect(out.status).toBe('success'); + expect(out.data.dryRun).toBe(true); + expect(out.data.endpoint).toBe('/getEvent'); + }); + + it('events create --dry-run', () => { + const out = run([ + 'events', 'create', + '--title', 'Test Party', + '--date', '2026-06-01 7pm', + '--dry-run', + ]); + expect(out.status).toBe('success'); + expect(out.data.dryRun).toBe(true); + expect(out.data.endpoint).toBe('/createEvent'); + expect(out.data.payload.data).toBeDefined(); + }); + + it('events cancel --dry-run --yes', () => { + const out = run(['events', 'cancel', 'test-id', '--dry-run', '--yes']); + expect(out.status).toBe('success'); + expect(out.data.dryRun).toBe(true); + expect(out.data.endpoint).toBe('/cancelEvent'); + }); + }); + + describe('JSON envelope shape', () => { + it('dry-run output has status, data, metadata', () => { + const out = run(['events', 'list', '--dry-run']); + expect(out).toHaveProperty('status', 'success'); + expect(out).toHaveProperty('data'); + expect(out).toHaveProperty('metadata'); + }); + }); + + describe('schema command', () => { + it('schema with no args lists all commands', () => { + const out = run(['schema']); + expect(out.status).toBe('success'); + expect(out.data.commands).toBeInstanceOf(Array); + expect(out.data.commands).toContain('events.list'); + expect(out.data.commands).toContain('guests.invite'); + expect(out.metadata.count).toBeGreaterThan(0); + }); + + it('schema events.create returns parameters', () => { + const out = run(['schema', 'events.create']); + expect(out.status).toBe('success'); + expect(out.data.command).toBe('events create'); + expect(out.data.parameters['--title'].required).toBe(true); + expect(out.data.parameters['--date'].required).toBe(true); + }); + + it('schema unknown path returns error', () => { + const { stdout, exitCode } = runRaw(['schema', 'nonexistent']); + expect(exitCode).not.toBe(0); + const out = JSON.parse(stdout.trim()); + expect(out.status).toBe('error'); + expect(out.error.type).toBe('not_found'); + }); + }); +}); + +describe('version command', () => { + it('returns version info', () => { + const { stdout, exitCode } = runRaw(['version']); + expect(exitCode).toBe(0); + const out = JSON.parse(stdout.trim()); + expect(out.status).toBe('success'); + expect(out.data.cli).toBe('partiful'); + expect(out.data.version).toBeTruthy(); + expect(out.data.node).toMatch(/^v/); + }); +}); diff --git a/tests/guests-integration.test.js b/tests/guests-integration.test.js new file mode 100644 index 0000000..35cb792 --- /dev/null +++ b/tests/guests-integration.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { run, runRaw } from './helpers.js'; + +describe('guests integration', () => { + describe('--dry-run', () => { + it('guests list --dry-run returns collection path', () => { + const out = run(['guests', 'list', 'evt-123', '--dry-run']); + expect(out.status).toBe('success'); + expect(out.data.dryRun).toBe(true); + expect(out.data.eventId).toBe('evt-123'); + expect(out.data.collection).toContain('evt-123'); + }); + + it('guests invite --dry-run with phone', () => { + const out = run([ + 'guests', 'invite', 'evt-123', + '--phone', '+12065551234', + '--dry-run', + ]); + expect(out.status).toBe('success'); + expect(out.data.dryRun).toBe(true); + expect(out.data.endpoint).toBe('/addInvitedGuestsAsHost'); + }); + + it('guests invite --dry-run with user-id', () => { + const out = run([ + 'guests', 'invite', 'evt-123', + '--user-id', 'user-abc', + '--dry-run', + ]); + expect(out.status).toBe('success'); + expect(out.data.dryRun).toBe(true); + }); + }); + + describe('JSON envelope shape', () => { + it('dry-run output has status, data, metadata', () => { + const out = run(['guests', 'list', 'evt-123', '--dry-run']); + expect(out).toHaveProperty('status', 'success'); + expect(out).toHaveProperty('data'); + expect(out).toHaveProperty('metadata'); + }); + }); + + describe('invite validation', () => { + it('invite without --phone or --user-id returns validation error', () => { + const { stdout, exitCode } = runRaw(['guests', 'invite', 'evt-123']); + expect(exitCode).not.toBe(0); + const out = JSON.parse(stdout.trim()); + expect(out.status).toBe('error'); + expect(out.error.type).toBe('validation_error'); + expect(out.error.message).toMatch(/phone|user-id/i); + }); + }); +}); diff --git a/tests/helpers.js b/tests/helpers.js new file mode 100644 index 0000000..1656ee0 --- /dev/null +++ b/tests/helpers.js @@ -0,0 +1,26 @@ +import { execFileSync } from 'child_process'; +import { resolve } from 'path'; + +const CLI = resolve('bin/partiful'); + +export function run(args, opts = {}) { + const stdout = execFileSync('node', [CLI, ...args], { + encoding: 'utf-8', + env: { ...process.env, PARTIFUL_TOKEN: 'fake-token', ...opts.env }, + timeout: 10000, + }); + return JSON.parse(stdout.trim()); +} + +export function runRaw(args, opts = {}) { + try { + const stdout = execFileSync('node', [CLI, ...args], { + encoding: 'utf-8', + env: { ...process.env, PARTIFUL_TOKEN: 'fake-token', ...opts.env }, + timeout: 10000, + }); + return { stdout, exitCode: 0 }; + } catch (e) { + return { stdout: e.stdout || '', stderr: e.stderr || '', exitCode: e.status }; + } +}