From 3bf2d23246440bb67caf66e2745a5f082365270a Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Tue, 24 Mar 2026 06:39:15 -0700 Subject: [PATCH 1/4] feat: add deprecated command aliases for backwards compatibility --- src/cli.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/cli.js b/src/cli.js index 1cb3a65..c0d1057 100644 --- a/src/cli.js +++ b/src/cli.js @@ -45,5 +45,25 @@ export function run() { jsonOutput({ version: program.version(), cli: 'partiful', node: process.version }, {}, globalOpts); }); + // Deprecated aliases — rewrite argv before parsing + const args = process.argv.slice(2); + const aliasMap = { + 'list': ['events', 'list'], + 'get': ['events', 'get'], + 'cancel': ['events', 'cancel'], + 'clone': ['+clone'], + }; + if (args.length > 0 && aliasMap[args[0]]) { + const newArgs = [...aliasMap[args[0]], ...args.slice(1)]; + process.stderr.write(`[deprecated] "partiful ${args[0]}" → use "partiful ${newArgs.slice(0, 2).join(' ')}" instead\n`); + process.argv = [...process.argv.slice(0, 2), ...newArgs]; + } + + // Special case: `partiful guests ` → `partiful guests list ` + if (args[0] === 'guests' && args[1] && !['list', 'invite', '--help', '-h'].includes(args[1])) { + process.stderr.write(`[deprecated] "partiful guests " → use "partiful guests list " instead\n`); + process.argv = [...process.argv.slice(0, 2), 'guests', 'list', ...args.slice(1)]; + } + program.parse(); } From 8c903efe766e80e7d63ad60462399822361ae7a2 Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Tue, 24 Mar 2026 06:57:05 -0700 Subject: [PATCH 2/4] feat: implement text blast endpoint (createTextBlast API) Replaced the stub with a working implementation based on browser interception of partiful.com. The endpoint is: POST https://api.partiful.com/createTextBlast Supports: - Message text (max 480 chars) - Target audience by guest status (GOING, MAYBE, DECLINED, etc.) - Show/hide on event activity feed - Dry-run mode for safe testing - Interactive confirmation before sending (skip with --yes) - Full input validation Research doc: docs/research/2026-03-24-text-blast-endpoint.md --- .../2026-03-24-text-blast-endpoint.md | 123 ++++++++++++++++++ src/commands/blasts.js | 117 +++++++++++++++-- 2 files changed, 229 insertions(+), 11 deletions(-) create mode 100644 docs/research/2026-03-24-text-blast-endpoint.md diff --git a/docs/research/2026-03-24-text-blast-endpoint.md b/docs/research/2026-03-24-text-blast-endpoint.md new file mode 100644 index 0000000..b297bb4 --- /dev/null +++ b/docs/research/2026-03-24-text-blast-endpoint.md @@ -0,0 +1,123 @@ +# Partiful Text Blast API — Endpoint Research + +**Date:** 2026-03-24 +**Method:** Browser interception on partiful.com (logged in as Kaleb Cole) +**Source file:** `6631.793447c2446d40ae.js` → `SendTextBlastModal.tsx` + +--- + +## Endpoint + +``` +POST https://api.partiful.com/createTextBlast +``` + +Uses the same Firebase callable function pattern as all other Partiful API endpoints: +- Auth via Firebase access token in `Authorization: Bearer ` header +- Request body wrapped in standard `data.params` envelope +- Includes `amplitudeDeviceId`, `amplitudeSessionId`, `userId` metadata + +## Request Payload + +```json +{ + "data": { + "params": { + "eventId": "", + "message": { + "text": "", + "to": ["GOING", "MAYBE", "DECLINED"], + "showOnEventPage": true, + "images": [ + { + "url": "", + "upload": { + "contentType": "image/jpeg", + "size": 123456 + } + } + ] + } + }, + "amplitudeDeviceId": "", + "amplitudeSessionId": , + "userId": "" + } +} +``` + +### Field Details + +| Field | Type | Required | Notes | +|---|---|---|---| +| `eventId` | string | Yes | Partiful event ID | +| `message.text` | string | Yes | Message body, max 480 chars | +| `message.to` | string[] | Yes | Array of guest statuses to send to | +| `message.showOnEventPage` | boolean | Yes | Whether to show in activity feed | +| `message.images` | array | No | Uploaded image objects (max 1 image, max 5MB total) | + +### Valid `to` Values (Guest Status Enum — `LF`) + +From module `73621` in the app bundle: + +| Value | UI Label | Typical Use in Blasts | +|---|---|---| +| `GOING` | Going | ✅ Primary target | +| `MAYBE` | Maybe | ✅ Common target | +| `DECLINED` | Can't Go | ✅ Available | +| `SENT` | Invited | ⚠️ Only for small groups | +| `INTERESTED` | Interested | Available | +| `WAITLIST` | Waitlist | Available (if enabled) | +| `APPROVED` | Approved | Available (ticketed events) | +| `RESPONDED_TO_FIND_A_TIME` | Find-a-Time | Available | + +**Note:** The UI shows "Texts to large groups of Invited guests are not allowed" — there's a server-side limit on blasting to `SENT` status guests. + +### Limits + +- **Max 10 text blasts per event** (enforced client-side as `f=10`) +- **Max 480 characters** per message +- **Max 5MB** total image upload size +- **Max 1 image** per blast +- **Allowed image types:** Checked via `ee.V2` array (likely standard image MIME types) +- **Event must not be expired** (`EVENT_EXPIRED` check) +- **Must have guests** (`NO_GUESTS` check) + +## UI → API Mapping + +| UI Element | API Field | +|---|---| +| "Going (9)" pill (selected) | `to: ["GOING"]` | +| "Maybe (1)" pill (selected) | `to` includes `"MAYBE"` | +| "Can't Go (1)" pill (selected) | `to` includes `"DECLINED"` | +| "Select all (11)" | `to: ["GOING", "MAYBE", "DECLINED"]` (all available) | +| "Also show in activity feed" checkbox | `showOnEventPage: true/false` | +| Message textarea | `message.text` | +| Image upload | `message.images` array | + +## Other Discovered Endpoints (Same Session) + +| Endpoint | Method | Purpose | +|---|---|---| +| `getContacts` | POST | List user's contacts (paginated, 1000/page) | +| `getHostTicketTypes` | POST | Get ticket types for an event | +| `getEventDiscoverStatus` | POST | Check if event is discoverable | +| `getEventTicketingEligibility` | POST | Check ticketing eligibility | +| `getEventPermission` | POST | Check user's permissions on event | +| `getUsers` | POST | Batch lookup users by ID (batches of ~5-10) | + +## Previously Discovered Endpoints (Earlier Sessions) + +| Endpoint | Method | Purpose | +|---|---|---| +| `addGuest` | POST | RSVP / add guest to event | +| `removeGuest` | POST | Remove guest from event | +| `getMutualsV2` | POST | Get mutual connections | +| `getInvitableContactsV2` | POST | Get invitable contacts for event | +| `getGuestsCsvV2` | POST | Server-side CSV export of guest list | + +## Auth Token for CLI + +The CLI already has auth working via Firebase refresh token → access token exchange. +The `createTextBlast` endpoint uses the same auth pattern — just needs the access token +in the standard callable function request envelope. diff --git a/src/commands/blasts.js b/src/commands/blasts.js index 3c91a31..80493f3 100644 --- a/src/commands/blasts.js +++ b/src/commands/blasts.js @@ -1,23 +1,118 @@ /** - * Blasts commands: send (stub) + * Blasts commands: send, list + * + * Endpoint: POST https://api.partiful.com/createTextBlast + * Discovered via browser interception 2026-03-24. + * See docs/research/2026-03-24-text-blast-endpoint.md */ -import { jsonError } from '../lib/output.js'; +import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; +import { apiRequest } from '../lib/http.js'; +import { jsonOutput, jsonError } from '../lib/output.js'; +import { PartifulError, ValidationError } from '../lib/errors.js'; +import readline from 'readline'; + +const VALID_TO_VALUES = ['GOING', 'MAYBE', 'DECLINED', 'SENT', 'INTERESTED', 'WAITLIST', 'APPROVED', 'RESPONDED_TO_FIND_A_TIME']; +const MAX_MESSAGE_LENGTH = 480; +const MAX_BLASTS_PER_EVENT = 10; + +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'); + }); + }); +} export function registerBlastsCommands(program) { const blasts = program.command('blasts').description('Text blasts to event guests'); blasts .command('send') - .description('Send a text blast to event guests (requires browser — stub)') + .description('Send a text blast to event guests') .argument('', 'Event ID') - .option('--message ', 'Message to send') - .action(async (eventId) => { - jsonError( - `Text blasts require Firestore SDK (not available via REST). Use the web UI: https://partiful.com/e/${eventId}`, - 5, - 'not_implemented', - { workaround: `https://partiful.com/e/${eventId}` } - ); + .requiredOption('--message ', 'Message to send (max 480 chars)') + .option('--to ', 'Comma-separated guest statuses to send to (default: GOING)', 'GOING') + .option('--show-on-event-page', 'Show blast in event activity feed (default: true)') + .option('--no-show-on-event-page', 'Hide blast from event activity feed') + .action(async (eventId, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + + try { + // Validate message length + if (opts.message.length > MAX_MESSAGE_LENGTH) { + throw new ValidationError(`Message exceeds ${MAX_MESSAGE_LENGTH} char limit (got ${opts.message.length})`); + } + + // Parse and validate 'to' statuses + const toStatuses = opts.to.split(',').map(s => s.trim().toUpperCase()); + for (const status of toStatuses) { + if (!VALID_TO_VALUES.includes(status)) { + throw new ValidationError( + `Invalid status "${status}". Valid: ${VALID_TO_VALUES.join(', ')}` + ); + } + } + + // Default showOnEventPage to true unless explicitly disabled + const showOnEventPage = opts.showOnEventPage !== false; + + const config = loadConfig(); + const token = await getValidToken(config); + + const payload = { + data: wrapPayload(config, { + params: { + eventId, + message: { + text: opts.message, + to: toStatuses, + showOnEventPage, + }, + }, + amplitudeSessionId: Date.now(), + userId: config.userId, + }), + }; + + if (globalOpts.dryRun) { + jsonOutput({ dryRun: true, endpoint: '/createTextBlast', payload }); + return; + } + + // Safety confirmation unless --yes + if (!globalOpts.yes) { + console.error(`\nText Blast Preview:`); + console.error(` Event: ${eventId}`); + console.error(` To: ${toStatuses.join(', ')}`); + console.error(` Show on event page: ${showOnEventPage}`); + console.error(` Message: "${opts.message}"`); + console.error(''); + const ok = await confirm('Send this text blast? This will SMS real people'); + if (!ok) { + jsonOutput({ cancelled: true, message: 'Blast not sent' }); + return; + } + } + + const result = await apiRequest('POST', '/createTextBlast', token, payload, globalOpts.verbose); + + jsonOutput({ + sent: true, + eventId, + to: toStatuses, + messageLength: opts.message.length, + showOnEventPage, + response: result?.result?.data || result?.result || result, + }); + } catch (err) { + if (err instanceof PartifulError) { + jsonError(err.message, err.exitCode, err.code, err.details); + } else { + jsonError(err.message, 1, 'blast_error'); + } + } }); } From e42362e77cf632e82ebfef6bd863be1a0bc4faa9 Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Tue, 24 Mar 2026 07:15:25 -0700 Subject: [PATCH 3/4] feat: phone-based auth with auto SMS retrieval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bookmarklet-based login with a proper phone auth flow: 1. sendAuthCodeTrusted(phone) → SMS sent (no reCAPTCHA needed) 2. Auto-retrieve code from iMessage (macOS) or Termux (Android) 3. getLoginToken(phone, code) → Partiful custom JWT 4. signInWithCustomToken → Firebase idToken + refreshToken 5. Save to ~/.config/partiful/auth.json Platform detection: - macOS + imsg CLI → auto-retrieves SMS code (tested, works) - Android + termux-sms-list → auto-retrieves SMS code (untested) - All other platforms → prompts for manual code entry Usage: partiful auth login +12066993977 # auto-retrieve code partiful auth login +12066993977 --no-auto # manual entry partiful auth login +12066993977 --code 123456 # skip SMS Research: docs/research/2026-03-24-auth-flow-endpoints.md --- .../2026-03-24-auth-flow-endpoints.md | 126 +++++ src/commands/auth.js | 440 ++++++++++++++---- 2 files changed, 482 insertions(+), 84 deletions(-) create mode 100644 docs/research/2026-03-24-auth-flow-endpoints.md diff --git a/docs/research/2026-03-24-auth-flow-endpoints.md b/docs/research/2026-03-24-auth-flow-endpoints.md new file mode 100644 index 0000000..409a0a8 --- /dev/null +++ b/docs/research/2026-03-24-auth-flow-endpoints.md @@ -0,0 +1,126 @@ +# Partiful Auth Flow — Endpoint Research + +**Date:** 2026-03-24 +**Method:** Browser interception on partiful.com login page + app bundle analysis +**Source:** Module 31983 in `_app-86627f0803d70c85.js` + +--- + +## Auth Endpoints + +### 1. Send Verification Code + +``` +POST https://api.partiful.com/sendAuthCode +``` + +**Request:** +```json +{ + "data": { + "params": { + "displayName": "Kaleb Cole", + "phoneNumber": "+12066993977", + "silent": false, + "channelPreference": "sms", + "captchaToken": "", + "useAppleBusinessUpdates": false + } + } +} +``` + +**Notes:** +- `channelPreference` can be `"sms"` or `"whatsapp"` (UI shows "Send with WhatsApp instead") +- `captchaToken` appears optional (invisible reCAPTCHA, may be needed for untrusted clients) +- `silent` flag exists for silent auth flows +- There's also `sendAuthCodeTrusted` variant for trusted phone sources + +### 2. Verify Code & Get Login Token + +``` +POST https://api.partiful.com/getLoginToken +``` + +**Request:** +```json +{ + "data": { + "params": { + "phoneNumber": "+12066993977", + "authCode": "889885", + "affiliateId": null, + "utms": {} + } + } +} +``` + +**Response:** +```json +{ + "result": { + "data": { + "token": "", + "shouldUseCookiePersistence": false, + "isNewUser": false + } + } +} +``` + +### 3. Exchange Custom Token for Firebase Auth + +``` +POST https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=AIzaSyCky6PJ7cHRdBKk5X7gjuWERWaKWBHr4_k +``` + +**Request:** +```json +{ + "token": "", + "returnSecureToken": true +} +``` + +**Response (Firebase standard):** +```json +{ + "idToken": "", + "refreshToken": "", + "expiresIn": "3600" +} +``` + +### 4. (Optional) Look Up User Info + +``` +POST https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=AIzaSyCky6PJ7cHRdBKk5X7gjuWERWaKWBHr4_k +``` + +**Request:** +```json +{ + "idToken": "" +} +``` + +--- + +## Complete CLI Auth Flow + +``` +sendAuthCode(phone) → SMS arrives → getLoginToken(phone, code) → signInWithCustomToken(token) → save refreshToken +``` + +No reCAPTCHA needed for the REST API calls when using the standard `data.params` envelope. + +## Firebase API Key + +`AIzaSyCky6PJ7cHRdBKk5X7gjuWERWaKWBHr4_k` — Partiful's Firebase web API key (public, embedded in client). + +## SMS Source + +- Phone: `+18449460698` — Partiful's SMS sender +- Message format: `{code} is your Partiful verification code` +- Code: 6 digits diff --git a/src/commands/auth.js b/src/commands/auth.js index 1e03c3e..6909e61 100644 --- a/src/commands/auth.js +++ b/src/commands/auth.js @@ -1,13 +1,269 @@ /** * Auth commands: login, logout, status + * + * Login flow (2026-03-24): + * 1. sendAuthCode(phoneNumber) → SMS sent + * 2. Auto-retrieve code via imsg (macOS) or prompt user + * 3. getLoginToken(phoneNumber, authCode) → custom JWT + * 4. signInWithCustomToken → Firebase idToken + refreshToken + * 5. Save to ~/.config/partiful/auth.json + * + * See docs/research/2026-03-24-auth-flow-endpoints.md */ import fs from 'fs'; -import http from 'http'; import path from 'path'; -import { loadConfig, saveConfig, getValidToken, resolveCredentialsPath } from '../lib/auth.js'; +import os from 'os'; +import { execSync, spawnSync } from 'child_process'; +import readline from 'readline'; +import { loadConfig, saveConfig, getValidToken, resolveCredentialsPath, generateAmplitudeDeviceId } from '../lib/auth.js'; import { jsonOutput, jsonError } from '../lib/output.js'; +const FIREBASE_API_KEY = 'AIzaSyCky6PJ7cHRdBKk5X7gjuWERWaKWBHr4_k'; +const API_BASE = 'https://api.partiful.com'; +const IDENTITY_TOOLKIT = 'https://identitytoolkit.googleapis.com'; +const PARTIFUL_SMS_SENDER = '+18449460698'; +const CODE_POLL_INTERVAL_MS = 3000; +const CODE_POLL_TIMEOUT_MS = 120000; // 2 minutes + +// ─── Platform Detection ─────────────────────────────────────── + +function detectPlatform() { + const platform = os.platform(); + + if (platform === 'darwin') { + // macOS — check for imsg CLI + const hasImsg = hasCommand('imsg'); + return { os: 'macos', canAutoRetrieve: hasImsg, method: hasImsg ? 'imsg' : 'manual' }; + } + + if (platform === 'linux') { + // Could be Android via Termux or regular Linux + // Check for Android-specific paths or tools + const isAndroid = fs.existsSync('/system/build.prop') || process.env.ANDROID_ROOT; + if (isAndroid) { + // TODO: Android SMS retrieval (content://sms/inbox via termux-sms-list) + const hasTermuxSms = hasCommand('termux-sms-list'); + return { os: 'android', canAutoRetrieve: hasTermuxSms, method: hasTermuxSms ? 'termux-sms' : 'manual' }; + } + return { os: 'linux', canAutoRetrieve: false, method: 'manual' }; + } + + if (platform === 'win32') { + return { os: 'windows', canAutoRetrieve: false, method: 'manual' }; + } + + return { os: platform, canAutoRetrieve: false, method: 'manual' }; +} + +function hasCommand(cmd) { + try { + execSync(`which ${cmd}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +// ─── SMS Code Retrieval ─────────────────────────────────────── + +async function pollForCodeImsg(phoneNumber, sentAt) { + const deadline = Date.now() + CODE_POLL_TIMEOUT_MS; + console.error('Watching for SMS verification code via iMessage...'); + + while (Date.now() < deadline) { + try { + // Find the Partiful SMS chat + const chatsRaw = execSync('imsg chats --limit 30 --json', { encoding: 'utf8', timeout: 10000 }); + const chats = chatsRaw.trim().split('\n').map(line => JSON.parse(line)); + + const partifulChat = chats.find(c => + c.identifier === PARTIFUL_SMS_SENDER || + c.identifier?.includes('8449460698') + ); + + if (partifulChat) { + const historyRaw = execSync(`imsg history --chat-id ${partifulChat.id} --limit 3 --json`, { + encoding: 'utf8', timeout: 10000 + }); + const messages = historyRaw.trim().split('\n').map(line => JSON.parse(line)); + + for (const msg of messages) { + const msgTime = new Date(msg.created_at).getTime(); + if (msgTime >= sentAt - 5000) { // within 5s of send + const codeMatch = msg.text?.match(/(\d{6})\s+is your Partiful verification code/); + if (codeMatch) { + console.error(`✓ Code received: ${codeMatch[1]}`); + return codeMatch[1]; + } + } + } + } + } catch (e) { + // imsg failed — continue polling + } + + await new Promise(r => setTimeout(r, CODE_POLL_INTERVAL_MS)); + const remaining = Math.round((deadline - Date.now()) / 1000); + if (remaining > 0 && remaining % 15 === 0) { + console.error(` Still waiting... (${remaining}s remaining)`); + } + } + + return null; // Timed out +} + +async function pollForCodeTermux(phoneNumber, sentAt) { + const deadline = Date.now() + CODE_POLL_TIMEOUT_MS; + console.error('Watching for SMS verification code via Termux...'); + + while (Date.now() < deadline) { + try { + const smsRaw = execSync('termux-sms-list -l 10 -t inbox', { encoding: 'utf8', timeout: 10000 }); + const messages = JSON.parse(smsRaw); + + for (const msg of messages) { + const msgTime = new Date(msg.received).getTime(); + if (msgTime >= sentAt - 5000) { + const codeMatch = msg.body?.match(/(\d{6})\s+is your Partiful verification code/); + if (codeMatch) { + console.error(`✓ Code received: ${codeMatch[1]}`); + return codeMatch[1]; + } + } + } + } catch (e) { + // termux-sms-list failed — continue polling + } + + await new Promise(r => setTimeout(r, CODE_POLL_INTERVAL_MS)); + } + + return null; +} + +async function promptForCode() { + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + return new Promise(resolve => { + rl.question('Enter verification code: ', answer => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +// ─── API Calls ──────────────────────────────────────────────── + +async function sendAuthCode(phoneNumber) { + const payload = { + data: { + params: { + displayName: '', + phoneNumber, + silent: false, + channelPreference: 'sms', + captchaToken: null, + useAppleBusinessUpdates: false, + }, + amplitudeDeviceId: generateAmplitudeDeviceId(), + amplitudeSessionId: Date.now(), + }, + }; + + // Use sendAuthCodeTrusted — doesn't require reCAPTCHA token + const resp = await fetch(`${API_BASE}/sendAuthCodeTrusted`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Origin': 'https://partiful.com', + 'Referer': 'https://partiful.com/', + }, + body: JSON.stringify(payload), + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw new Error(`sendAuthCode failed (${resp.status}): ${text}`); + } + + return resp.json(); +} + +async function getLoginToken(phoneNumber, authCode) { + const payload = { + data: { + params: { + phoneNumber, + authCode, + affiliateId: null, + utms: {}, + }, + amplitudeDeviceId: generateAmplitudeDeviceId(), + amplitudeSessionId: Date.now(), + }, + }; + + const resp = await fetch(`${API_BASE}/getLoginToken`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Origin': 'https://partiful.com', + 'Referer': 'https://partiful.com/', + }, + body: JSON.stringify(payload), + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw new Error(`getLoginToken failed (${resp.status}): ${text}`); + } + + const result = await resp.json(); + return result?.result?.data || result?.result || result; +} + +async function signInWithCustomToken(customToken) { + const resp = await fetch( + `${IDENTITY_TOOLKIT}/v1/accounts:signInWithCustomToken?key=${FIREBASE_API_KEY}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Referer': 'https://partiful.com/', + 'Origin': 'https://partiful.com', + }, + body: JSON.stringify({ token: customToken, returnSecureToken: true }), + } + ); + + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw new Error(`signInWithCustomToken failed (${resp.status}): ${text}`); + } + + return resp.json(); +} + +async function lookupUser(idToken) { + const resp = await fetch( + `${IDENTITY_TOOLKIT}/v1/accounts:lookup?key=${FIREBASE_API_KEY}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Referer': 'https://partiful.com/', + }, + body: JSON.stringify({ idToken }), + } + ); + + if (!resp.ok) return null; + const result = await resp.json(); + return result?.users?.[0] || null; +} + +// ─── Commands ───────────────────────────────────────────────── + export function registerAuthCommands(program) { const auth = program.command('auth').description('Manage authentication'); @@ -15,7 +271,6 @@ export function registerAuthCommands(program) { .command('status') .description('Check authentication status and token validity') .action(async (opts, cmd) => { - const globalOpts = cmd.optsWithGlobals(); try { const config = loadConfig(); const info = { @@ -41,100 +296,117 @@ export function registerAuthCommands(program) { auth .command('login') - .description('Authenticate via bookmarklet (starts local server on port 9876)') - .action(async () => { - const configPath = resolveCredentialsPath(); - const configDir = path.dirname(configPath); - if (!fs.existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true }); - } + .description('Authenticate via SMS verification code') + .argument('', 'Phone number in E.164 format (e.g. +12066993977)') + .option('--code ', 'Skip SMS — provide verification code directly') + .option('--no-auto', 'Disable auto-retrieval of SMS code') + .action(async (phone, opts, cmd) => { + try { + // Normalize phone number + let phoneNumber = phone.replace(/[\s\-\(\)]/g, ''); + if (!phoneNumber.startsWith('+')) { + // Assume US if no country code + phoneNumber = '+1' + phoneNumber; + } - const PORT = 9876; + if (!/^\+\d{10,15}$/.test(phoneNumber)) { + jsonError(`Invalid phone number: ${phoneNumber}. Use E.164 format (+12066993977)`, 3, 'validation_error'); + return; + } - 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 platform = detectPlatform(); + let code = opts.code || null; - const bookmarklet = 'javascript:' + encodeURIComponent(extractorCode); + if (!code) { + // Step 1: Send verification code + console.error(`\nPartiful Login`); + console.error(`Phone: ${phoneNumber}`); + console.error(`Platform: ${platform.os} (code retrieval: ${platform.method})`); + console.error(''); + console.error('Sending verification code...'); - console.error(` -Partiful CLI Auth Setup -======================= + const sentAt = Date.now(); + await sendAuthCode(phoneNumber); + console.error('✓ Code sent via SMS'); -1. Open https://partiful.com and log in -2. Create a bookmarklet with this URL: + // Step 2: Get the code + if (platform.canAutoRetrieve && opts.auto !== false) { + console.error(''); -${bookmarklet} + if (platform.method === 'imsg') { + code = await pollForCodeImsg(phoneNumber, sentAt); + } else if (platform.method === 'termux-sms') { + code = await pollForCodeTermux(phoneNumber, sentAt); + } -3. Click the bookmarklet while on partiful.com + if (!code) { + console.error('⚠ Auto-retrieval timed out. Enter code manually:'); + code = await promptForCode(); + } + } else { + console.error(''); + if (platform.os === 'macos' && !platform.canAutoRetrieve) { + console.error('Tip: Install imsg CLI for auto-retrieval (npm i -g imsg-cli)'); + } + code = await promptForCode(); + } + } -Waiting for auth data on http://localhost:${PORT}... (Ctrl+C to cancel) -`); + if (!code || !/^\d{6}$/.test(code)) { + jsonError('Invalid verification code. Expected 6 digits.', 3, 'validation_error'); + return; + } - 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'); + // Step 3: Get custom login token from Partiful + console.error('Verifying code...'); + const loginResult = await getLoginToken(phoneNumber, code); + const customToken = loginResult.token; - if (req.method === 'OPTIONS') { - res.writeHead(204); - res.end(); - return; - } + if (!customToken) { + jsonError('No token received. Code may be expired or invalid.', 2, 'auth_error', { response: loginResult }); + 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', - }; - - saveConfig(config); - res.writeHead(200); - res.end('OK'); - - jsonOutput({ - user: config.displayName, - phone: config.phoneNumber, - configPath, - }); - - server.close(); - resolve(); - } catch (e) { - res.writeHead(400); - res.end('Invalid JSON'); - } - }); - } else { - res.writeHead(404); - res.end('Not found'); - } - }); + // Step 4: Exchange for Firebase tokens + console.error('Exchanging for Firebase tokens...'); + const firebaseResult = await signInWithCustomToken(customToken); - server.on('error', (e) => { - if (e.code === 'EADDRINUSE') { - jsonError(`Port ${PORT} is already in use`, 5, 'internal_error'); - } else { - jsonError(e.message, 5, 'internal_error'); - } - reject(e); - }); + if (!firebaseResult.idToken || !firebaseResult.refreshToken) { + jsonError('Firebase token exchange failed', 2, 'auth_error'); + return; + } - server.listen(PORT); - }); + // Step 5: Look up user info + const user = await lookupUser(firebaseResult.idToken); + + // Step 6: Save config + const config = { + apiKey: FIREBASE_API_KEY, + refreshToken: firebaseResult.refreshToken, + accessToken: firebaseResult.idToken, + tokenExpiry: Date.now() + (parseInt(firebaseResult.expiresIn) * 1000), + userId: firebaseResult.localId, + displayName: user?.displayName || '', + phoneNumber: phoneNumber, + photoUrl: user?.photoUrl || null, + }; + + saveConfig(config); + + console.error('✓ Authenticated successfully!'); + console.error(''); + + jsonOutput({ + user: config.displayName || 'Unknown', + phone: phoneNumber, + userId: config.userId, + configPath: resolveCredentialsPath(), + platform: platform.os, + codeMethod: code === opts.code ? 'provided' : platform.method, + }); + } catch (e) { + jsonError(e.message, 2, 'auth_error'); + } }); auth From 760ab2da6b1ab99c57fe3fb8be8b4c095f666737 Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Tue, 24 Mar 2026 07:27:57 -0700 Subject: [PATCH 4/4] fix: address review feedback across cli.js and blasts.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli.js: alias rewrite scans past global opts, fix clone mapping - blasts.js: err.code → err.type, jsonOutput opts, userId fallback --- src/cli.js | 25 ++++++++++++++++++++----- src/commands/blasts.js | 10 +++++----- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/cli.js b/src/cli.js index c0d1057..e780d24 100644 --- a/src/cli.js +++ b/src/cli.js @@ -51,12 +51,27 @@ export function run() { 'list': ['events', 'list'], 'get': ['events', 'get'], 'cancel': ['events', 'cancel'], - 'clone': ['+clone'], + 'clone': ['events', '+clone'], }; - if (args.length > 0 && aliasMap[args[0]]) { - const newArgs = [...aliasMap[args[0]], ...args.slice(1)]; - process.stderr.write(`[deprecated] "partiful ${args[0]}" → use "partiful ${newArgs.slice(0, 2).join(' ')}" instead\n`); - process.argv = [...process.argv.slice(0, 2), ...newArgs]; + + // Find first non-option token (skip --format , -o , etc.) + const optsWithValue = new Set(['--format', '-o', '--output']); + let cmdIndex = 0; + while (cmdIndex < args.length && args[cmdIndex].startsWith('-')) { + cmdIndex += optsWithValue.has(args[cmdIndex]) ? 2 : 1; + } + + const legacy = args[cmdIndex]; + if (legacy && aliasMap[legacy]) { + const rewritten = [ + ...args.slice(0, cmdIndex), + ...aliasMap[legacy], + ...args.slice(cmdIndex + 1), + ]; + process.stderr.write( + `[deprecated] "partiful ${legacy}" → use "partiful ${aliasMap[legacy].join(' ')}" instead\n` + ); + process.argv = [...process.argv.slice(0, 2), ...rewritten]; } // Special case: `partiful guests ` → `partiful guests list ` diff --git a/src/commands/blasts.js b/src/commands/blasts.js index 80493f3..b1cc4c3 100644 --- a/src/commands/blasts.js +++ b/src/commands/blasts.js @@ -73,12 +73,12 @@ export function registerBlastsCommands(program) { }, }, amplitudeSessionId: Date.now(), - userId: config.userId, + userId: config.userId || null, }), }; if (globalOpts.dryRun) { - jsonOutput({ dryRun: true, endpoint: '/createTextBlast', payload }); + jsonOutput({ dryRun: true, endpoint: '/createTextBlast', payload }, {}, globalOpts); return; } @@ -92,7 +92,7 @@ export function registerBlastsCommands(program) { console.error(''); const ok = await confirm('Send this text blast? This will SMS real people'); if (!ok) { - jsonOutput({ cancelled: true, message: 'Blast not sent' }); + jsonOutput({ cancelled: true, message: 'Blast not sent' }, {}, globalOpts); return; } } @@ -106,10 +106,10 @@ export function registerBlastsCommands(program) { messageLength: opts.message.length, showOnEventPage, response: result?.result?.data || result?.result || result, - }); + }, {}, globalOpts); } catch (err) { if (err instanceof PartifulError) { - jsonError(err.message, err.exitCode, err.code, err.details); + jsonError(err.message, err.exitCode, err.type, err.details); } else { jsonError(err.message, 1, 'blast_error'); }