diff --git a/src/cli.js b/src/cli.js index 21d5bfb..b7e225c 100644 --- a/src/cli.js +++ b/src/cli.js @@ -10,6 +10,7 @@ import { registerExportHelper } from './helpers/export.js'; import { registerShareHelper } from './helpers/share.js'; import { registerSchemaCommand } from './commands/schema.js'; import { registerPosterCommands } from './commands/posters.js'; +import { registerDoctorCommands } from './commands/doctor.js'; import { jsonOutput } from './lib/output.js'; export function run() { @@ -38,6 +39,7 @@ export function run() { registerShareHelper(program); registerSchemaCommand(program); registerPosterCommands(program); + registerDoctorCommands(program); program .command('version') diff --git a/src/commands/doctor.js b/src/commands/doctor.js new file mode 100644 index 0000000..b7aded1 --- /dev/null +++ b/src/commands/doctor.js @@ -0,0 +1,178 @@ +/** + * Doctor command: diagnose CLI setup health. + */ + +import os from 'os'; +import fs from 'fs'; +import { execSync } from 'child_process'; +import { loadConfig, refreshAccessToken, resolveCredentialsPath, wrapPayload } from '../lib/auth.js'; +import { apiRequest } from '../lib/http.js'; +import { jsonOutput, jsonError } from '../lib/output.js'; +import { PartifulError } from '../lib/errors.js'; + +const CHECKS = [ + { name: 'config_file', label: 'Config file' }, + { name: 'token_refresh', label: 'Token refresh' }, + { name: 'api_connectivity', label: 'API connectivity' }, + { name: 'environment', label: 'Environment' }, + { name: 'platform', label: 'Platform' }, +]; + +async function runChecks() { + const results = []; + + // 1. Config file + const configPath = resolveCredentialsPath(); + const displayPath = configPath.replace(process.env.HOME, '~'); + let config = null; + try { + if (!fs.existsSync(configPath)) { + results.push({ name: 'config_file', passed: false, detail: `Not found: ${displayPath}` }); + } else { + const raw = fs.readFileSync(configPath, 'utf8'); + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + results.push({ name: 'config_file', passed: false, detail: 'Invalid JSON' }); + } + const required = ['apiKey', 'refreshToken', 'userId']; + const missing = required.filter(f => !parsed[f]); + if (missing.length > 0) { + results.push({ name: 'config_file', passed: false, detail: `Missing fields: ${missing.join(', ')}` }); + } else { + config = parsed; + results.push({ name: 'config_file', passed: true, detail: displayPath }); + } + } + } catch (e) { + results.push({ name: 'config_file', passed: false, detail: e.message }); + } + + // 2. Token refresh + if (!config) { + results.push({ name: 'token_refresh', passed: false, detail: 'Skipped (no valid config)' }); + } else { + try { + const tokenResult = await refreshAccessToken(config); + const expiresIn = parseInt(tokenResult.expires_in) || 0; + const minutes = Math.floor(expiresIn / 60); + config.accessToken = tokenResult.id_token; + config.tokenExpiry = Date.now() + expiresIn * 1000; + if (tokenResult.refresh_token) config.refreshToken = tokenResult.refresh_token; + results.push({ name: 'token_refresh', passed: true, detail: `Token valid for ${minutes} min` }); + } catch (e) { + results.push({ name: 'token_refresh', passed: false, detail: e.message }); + } + } + + // 3. API connectivity + if (!config || !config.accessToken) { + results.push({ name: 'api_connectivity', passed: false, detail: 'Skipped (no token)' }); + } else { + try { + const payload = { + data: wrapPayload(config, { + params: {}, + amplitudeSessionId: Date.now(), + userId: config.userId, + }), + }; + await apiRequest('POST', '/getMyUpcomingEventsForHomePage', config.accessToken, payload, false); + results.push({ name: 'api_connectivity', passed: true, detail: 'api.partiful.com reachable' }); + } catch (e) { + results.push({ name: 'api_connectivity', passed: false, detail: e.message }); + } + } + + // 4. Environment + try { + const pkgPath = new URL('../../package.json', import.meta.url); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + results.push({ + name: 'environment', + passed: true, + detail: `CLI v${pkg.version}, Node ${process.version}`, + }); + } catch (e) { + results.push({ + name: 'environment', + passed: false, + detail: `Unable to read runtime metadata: ${e.message}`, + }); + } + + // 5. Platform + const platform = os.platform(); + const arch = os.arch(); + let smsDetail; + if (platform === 'darwin') { + try { + execSync('which imsg', { stdio: 'ignore' }); + smsDetail = 'imsg available'; + } catch { + smsDetail = 'imsg not found'; + } + } else if (platform === 'linux' && process.env.TERMUX_VERSION) { + smsDetail = 'termux detected'; + } else { + smsDetail = 'no SMS auto-retrieve'; + } + results.push({ + name: 'platform', + passed: true, + detail: `${platform} ${arch}, ${smsDetail}`, + }); + + return results; +} + +function printTable(checks) { + process.stderr.write('\nPartiful CLI — Doctor\n'); + process.stderr.write('─'.repeat(50) + '\n'); + for (const check of checks) { + const icon = check.passed ? '✓' : '✗'; + const label = CHECKS.find(c => c.name === check.name)?.label || check.name; + process.stderr.write(` ${icon} ${label.padEnd(20)} ${check.detail}\n`); + } + const allPassed = checks.every(c => c.passed); + process.stderr.write('─'.repeat(50) + '\n'); + process.stderr.write(allPassed ? ' All checks passed ✓\n\n' : ' Some checks failed ✗\n\n'); +} + +export function registerDoctorCommands(program) { + program + .command('doctor') + .description('Check CLI setup health and report diagnostics') + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + + if (globalOpts.dryRun) { + jsonOutput({ + checks: CHECKS.map(c => ({ name: c.name, label: c.label })), + note: 'Dry run — no checks executed', + }); + return; + } + + try { + const checks = await runChecks(); + const allPassed = checks.every(c => c.passed); + + if (globalOpts.format !== 'json') { + printTable(checks); + } + + const envelope = { + status: 'success', + data: { checks, allPassed }, + }; + process.stdout.write(JSON.stringify(envelope) + '\n'); + + if (!allPassed) process.exit(1); + } catch (e) { + if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); + else jsonError(e.message); + } + }); +} diff --git a/src/commands/events.js b/src/commands/events.js index 0c7ee52..bbd4740 100644 --- a/src/commands/events.js +++ b/src/commands/events.js @@ -179,6 +179,8 @@ export function registerEventsCommands(program) { .option('--poster ', 'Built-in poster ID (use "posters search" to find)') .option('--poster-search ', 'Search for a poster by keyword') .option('--image ', 'Custom image file to upload') + .option('--link ', 'Link URL (repeatable)') + .option('--link-text ', 'Display text for link (paired with --link by position)') .action(async (opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); try { @@ -245,6 +247,13 @@ export function registerEventsCommands(program) { event.enableWaitlist = true; } + if (opts.link && opts.link.length > 0) { + event.links = opts.link.map((url, i) => ({ + url, + text: opts.linkText?.[i] || url, + })); + } + // Poster image handling if (opts.poster) { const catalog = await fetchCatalog(); @@ -328,6 +337,8 @@ export function registerEventsCommands(program) { .option('--poster ', 'Set poster by ID') .option('--poster-search ', 'Search and set best matching poster') .option('--image ', 'Upload and set custom image') + .option('--link ', 'Link URL (repeatable)') + .option('--link-text ', 'Display text for link (paired with --link by position)') .action(async (eventId, opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); try { @@ -344,6 +355,21 @@ export function registerEventsCommands(program) { if (opts.endDate) { fields.endDate = { timestampValue: parseDateTime(opts.endDate).toISOString() }; updateFields.push('endDate'); } if (opts.capacity) { fields.guestLimit = { integerValue: String(opts.capacity) }; updateFields.push('guestLimit'); } + if (opts.link && opts.link.length > 0) { + const links = opts.link.map((url, i) => ({ + url, + text: opts.linkText?.[i] || url, + })); + fields.links = { + arrayValue: { + values: links.map(l => ({ + mapValue: { fields: toFirestoreMap(l) } + })) + } + }; + updateFields.push('links'); + } + // Handle image options const imageOpts = [opts.poster, opts.posterSearch, opts.image].filter(Boolean).length; if (imageOpts > 1) { @@ -368,7 +394,7 @@ export function registerEventsCommands(program) { jsonError(`No posters found matching "${opts.posterSearch}".`, 4, 'not_found'); return; } - poster = results[0].poster; + poster = results[0]; } const imageObj = buildPosterImage(poster); @@ -415,7 +441,7 @@ export function registerEventsCommands(program) { } if (updateFields.length === 0) { - jsonError('No fields to update. Use --title, --location, --description, --date, --end-date, --capacity, --poster, --poster-search, or --image', 3, 'validation_error'); + jsonError('No fields to update. Use --title, --location, --description, --date, --end-date, --capacity, --link, --poster, --poster-search, or --image', 3, 'validation_error'); return; } @@ -437,6 +463,221 @@ export function registerEventsCommands(program) { } }); + events + .command('clone') + .description('Clone an existing event with a new date') + .argument('', 'Source event ID') + .requiredOption('--date ', 'New event date (required)') + .option('--end-date ', 'End date/time (overrides duration preservation)') + .option('--title ', 'Override title') + .option('--location <location>', 'Override location name') + .option('--address <address>', 'Override street address') + .option('--description <desc>', 'Override description') + .option('--capacity <n>', 'Override guest limit', parseInt) + .option('--private', 'Make event private') + .option('--timezone <tz>', 'Override timezone') + .option('--theme <theme>', 'Override color theme') + .option('--effect <effect>', 'Override visual effect') + .option('--poster <posterId>', 'Override with built-in poster ID') + .option('--poster-search <query>', 'Override with poster search') + .option('--image <path>', 'Override with custom image') + .option('--link <url...>', 'Override links (repeatable)') + .option('--link-text <text...>', 'Display text for links') + .action(async (eventId, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + try { + const config = loadConfig(); + const token = await getValidToken(config); + + // 1. Fetch source event + const getPayload = { + data: wrapPayload(config, { + params: { eventId }, + amplitudeSessionId: Date.now(), + userId: config.userId, + }), + }; + + let sourceEvent; + try { + const result = await apiRequest('POST', '/getEvent', token, getPayload, globalOpts.verbose); + sourceEvent = result.result?.data?.event; + } catch (e) { + if (!globalOpts.dryRun) throw e; + // In dry-run, tolerate network failure — preview with empty source + sourceEvent = null; + } + + if (!sourceEvent && !globalOpts.dryRun) { + jsonError('Source event not found', 4, 'not_found'); + return; + } + + // Use empty object if source not available in dry-run + const src = sourceEvent || {}; + + // 2. Parse new date and preserve duration + const tz = opts.timezone || src.timezone || 'America/Los_Angeles'; + const newStart = parseDateTime(opts.date, tz); + let newEnd = null; + + if (opts.endDate) { + newEnd = parseDateTime(opts.endDate, tz); + } else if (src.startDate && src.endDate) { + const srcStart = new Date(src.startDate); + const srcEnd = new Date(src.endDate); + const durationMs = srcEnd.getTime() - srcStart.getTime(); + if (durationMs > 0) { + newEnd = new Date(newStart.getTime() + durationMs); + } + } + + // 3. Build cloned event payload + const event = { + title: opts.title || src.title || 'Untitled Event', + startDate: newStart.toISOString(), + timezone: tz, + displaySettings: { + theme: opts.theme || src.displaySettings?.theme || 'oxblood', + effect: opts.effect || src.displaySettings?.effect || 'sunbeams', + titleFont: src.displaySettings?.titleFont || 'display', + }, + showHostList: src.showHostList ?? true, + showGuestCount: src.showGuestCount ?? true, + showGuestList: src.showGuestList ?? true, + showActivityTimestamps: src.showActivityTimestamps ?? true, + displayInviteButton: src.displayInviteButton ?? true, + visibility: opts.private ? 'private' : (src.visibility || 'public'), + allowGuestPhotoUpload: src.allowGuestPhotoUpload ?? true, + enableGuestReminders: src.enableGuestReminders ?? true, + rsvpsEnabled: src.rsvpsEnabled ?? true, + allowGuestsToInviteMutuals: src.allowGuestsToInviteMutuals ?? true, + rsvpButtonGlyphType: src.rsvpButtonGlyphType ?? 'emojis', + status: 'UNSAVED', + 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, + }, + }; + + if (newEnd) event.endDate = newEnd.toISOString(); + + // Copy location fields + const loc = opts.location !== undefined ? opts.location : src.location; + if (loc) event.location = loc; + const addr = opts.address !== undefined ? opts.address : src.address; + if (addr) event.address = addr; + + // Copy description + const desc = opts.description !== undefined ? stripMarkdown(opts.description) : src.description; + if (desc) event.description = desc; + + // Copy capacity + const cap = opts.capacity !== undefined ? opts.capacity : src.guestLimit; + if (cap) { + event.guestLimit = cap; + event.enableWaitlist = true; + } + + // Copy links + if (opts.link && opts.link.length > 0) { + event.links = opts.link.map((url, i) => ({ + url, + text: opts.linkText?.[i] || url, + })); + } else if (src.links) { + event.links = src.links; + } + + // Copy image from source (unless overridden) + const imageOptCount = [opts.poster, opts.posterSearch, opts.image].filter(Boolean).length; + if (imageOptCount > 1) { + jsonError('Use only one of --poster, --poster-search, or --image.', 3, 'validation_error'); + return; + } + + if (opts.poster) { + const catalog = await fetchCatalog(); + const poster = catalog.find(p => p.id === opts.poster); + if (!poster) { + jsonError(`Poster not found: "${opts.poster}".`, 4, 'not_found'); + return; + } + event.image = buildPosterImage(poster); + } else if (opts.posterSearch) { + const catalog = await fetchCatalog(); + const results = searchPosters(catalog, opts.posterSearch); + if (results.length === 0) { + jsonError(`No posters found matching "${opts.posterSearch}".`, 4, 'not_found'); + return; + } + event.image = buildPosterImage(results[0]); + } else if (opts.image) { + const isImageUrl = opts.image.startsWith('http://') || opts.image.startsWith('https://'); + if (!isImageUrl) { + const ext = pathExtname(opts.image).toLowerCase(); + const allowed = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif']; + if (!allowed.includes(ext)) { + jsonError(`Unsupported image type "${ext}".`, 3, 'validation_error'); + return; + } + } + if (globalOpts.dryRun) { + event.image = isImageUrl + ? { source: 'upload', url: opts.image, note: 'URL will be downloaded and uploaded on real run' } + : { source: 'upload', file: opts.image, note: 'File will be uploaded on real run' }; + } else if (isImageUrl) { + const { downloadToTemp, uploadEventImage, buildUploadImage } = await import('../lib/upload.js'); + const { tempPath, cleanup } = await downloadToTemp(opts.image); + try { + const uploadData = await uploadEventImage(tempPath, token, config, globalOpts.verbose); + event.image = buildUploadImage(uploadData, pathBasename(tempPath)); + } finally { + cleanup(); + } + } else { + const { uploadEventImage, buildUploadImage } = await import('../lib/upload.js'); + const uploadData = await uploadEventImage(opts.image, token, config, globalOpts.verbose); + event.image = buildUploadImage(uploadData, pathBasename(opts.image)); + } + } else if (src.image) { + event.image = src.image; + } + + // 4. Build API payload + const payload = { + data: wrapPayload(config, { + params: { event, cohostIds: [] }, + amplitudeSessionId: Date.now(), + userId: config.userId, + }), + }; + + if (globalOpts.dryRun) { + jsonOutput({ dryRun: true, endpoint: '/createEvent', clonedFrom: eventId, payload }); + return; + } + + // 5. Create the event + const result = await apiRequest('POST', '/createEvent', token, payload, globalOpts.verbose); + const newEventId = result.result?.data || result.result?.eventId; + + jsonOutput({ + id: newEventId, + clonedFrom: eventId, + title: event.title, + startDate: newStart.toISOString(), + url: `https://partiful.com/e/${newEventId}`, + }); + } catch (e) { + if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); + else jsonError(e.message); + } + }); + events .command('cancel') .description('Cancel an event') diff --git a/tests/doctor.test.js b/tests/doctor.test.js new file mode 100644 index 0000000..8431fea --- /dev/null +++ b/tests/doctor.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'child_process'; +import { resolve } from 'path'; + +const CLI = resolve('bin/partiful'); + +function runCli(args, env = {}) { + try { + const stdout = execFileSync('node', [CLI, ...args], { + encoding: 'utf8', + env: { + ...process.env, + PARTIFUL_CREDENTIALS_FILE: '/tmp/__nonexistent_partiful_auth__.json', + ...env, + }, + timeout: 10000, + }); + return { stdout, exitCode: 0 }; + } catch (e) { + return { stdout: e.stdout || '', stderr: e.stderr || '', exitCode: e.status }; + } +} + +describe('doctor command', () => { + it('--dry-run returns list of checks without executing', () => { + const { stdout } = runCli(['--dry-run', 'doctor']); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.status).toBe('success'); + expect(parsed.data.checks).toBeInstanceOf(Array); + expect(parsed.data.checks.length).toBeGreaterThanOrEqual(5); + expect(parsed.data.checks.map(c => c.name)).toEqual( + expect.arrayContaining(['config_file', 'token_refresh', 'api_connectivity', 'environment', 'platform']) + ); + expect(parsed.data.note).toMatch(/dry run/i); + }); + + it('missing config file produces failed config_file check', () => { + const { stdout, exitCode } = runCli(['doctor']); + expect(exitCode).not.toBe(0); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.status).toBe('success'); + expect(parsed.data.checks).toBeInstanceOf(Array); + const configCheck = parsed.data.checks.find(c => c.name === 'config_file'); + expect(configCheck).toBeDefined(); + expect(configCheck.passed).toBe(false); + expect(parsed.data.allPassed).toBe(false); + }); + + it('output shape matches expected envelope', () => { + const { stdout } = runCli(['doctor']); + const parsed = JSON.parse(stdout.trim()); + expect(parsed).toHaveProperty('status'); + expect(parsed).toHaveProperty('data'); + expect(parsed.data).toHaveProperty('checks'); + expect(parsed.data).toHaveProperty('allPassed'); + for (const check of parsed.data.checks) { + expect(check).toHaveProperty('name'); + expect(check).toHaveProperty('passed'); + expect(check).toHaveProperty('detail'); + } + }); +}); diff --git a/tests/events-integration.test.js b/tests/events-integration.test.js index d46a875..eb52667 100644 --- a/tests/events-integration.test.js +++ b/tests/events-integration.test.js @@ -210,6 +210,104 @@ describe('events integration', () => { expect(out.error.type).toBe('not_found'); }); }); + + describe('--link flags', () => { + it('events create --link --dry-run includes links in payload', () => { + const out = run([ + 'events', 'create', + '--title', 'Link Party', + '--date', '2026-06-01 7pm', + '--link', 'https://zoom.us/j/123', + '--dry-run', + ]); + expect(out.status).toBe('success'); + const event = out.data.payload.data.params.event; + expect(event.links).toEqual([{ url: 'https://zoom.us/j/123', text: 'https://zoom.us/j/123' }]); + }); + + it('events create with multiple --link flags', () => { + const out = run([ + 'events', 'create', + '--title', 'Multi Link', + '--date', '2026-06-01 7pm', + '--link', 'https://zoom.us/j/123', + '--link', 'https://docs.google.com/doc', + '--dry-run', + ]); + const event = out.data.payload.data.params.event; + expect(event.links).toHaveLength(2); + expect(event.links[0].url).toBe('https://zoom.us/j/123'); + expect(event.links[1].url).toBe('https://docs.google.com/doc'); + }); + + it('events create with --link + --link-text pairing', () => { + const out = run([ + 'events', 'create', + '--title', 'Named Links', + '--date', '2026-06-01 7pm', + '--link', 'https://zoom.us/j/123', + '--link-text', 'Zoom', + '--link', 'https://docs.google.com/doc', + '--link-text', 'Agenda', + '--dry-run', + ]); + const event = out.data.payload.data.params.event; + expect(event.links).toEqual([ + { url: 'https://zoom.us/j/123', text: 'Zoom' }, + { url: 'https://docs.google.com/doc', text: 'Agenda' }, + ]); + }); + + it('events update --link --dry-run includes links in update', () => { + const out = run([ + 'events', 'update', 'test-event-123', + '--link', 'https://example.com', + '--link-text', 'Example', + '--dry-run', + ]); + expect(out.status).toBe('success'); + expect(out.data.fields).toContain('links'); + const linksField = out.data.body.fields.links; + expect(linksField.arrayValue.values).toHaveLength(1); + expect(linksField.arrayValue.values[0].mapValue.fields.url.stringValue).toBe('https://example.com'); + expect(linksField.arrayValue.values[0].mapValue.fields.text.stringValue).toBe('Example'); + }); + }); +}); + +describe('events clone', () => { + it('events clone --dry-run produces createEvent payload with clonedFrom', () => { + const out = run([ + 'events', 'clone', 'test-event-123', + '--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.clonedFrom).toBe('test-event-123'); + expect(out.data.payload.data.params.event).toBeDefined(); + expect(out.data.payload.data.params.event.startDate).toBeDefined(); + }); + + it('events clone --dry-run with --title override applies override', () => { + const out = run([ + 'events', 'clone', 'test-event-123', + '--date', '2026-06-01 7pm', + '--title', 'Override Title', + '--dry-run', + ]); + expect(out.status).toBe('success'); + const event = out.data.payload.data.params.event; + expect(event.title).toBe('Override Title'); + }); + + it('events clone without --date shows error', () => { + const { stdout, exitCode } = runRaw([ + 'events', 'clone', 'test-event-123', + ]); + expect(exitCode).not.toBe(0); + }); }); describe('version command', () => {