diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 24c69f0b90ba..b143141f465a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.25", + "version": "1.2.1", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", diff --git a/packages/opencode/src/cli/cmd/command-groups.ts b/packages/opencode/src/cli/cmd/command-groups.ts index 08ee528208f9..9347f1267408 100644 --- a/packages/opencode/src/cli/cmd/command-groups.ts +++ b/packages/opencode/src/cli/cmd/command-groups.ts @@ -138,6 +138,7 @@ export const COMMAND_CATEGORY_MAP: Record = { profile: "entities", // Communication + obs: "communication", phone: "communication", voice: "communication", transcribe: "communication", diff --git a/packages/opencode/src/cli/cmd/platform-atlas-comms.ts b/packages/opencode/src/cli/cmd/platform-atlas-comms.ts index caf3ff30f5ec..f7aa35b88d6e 100644 --- a/packages/opencode/src/cli/cmd/platform-atlas-comms.ts +++ b/packages/opencode/src/cli/cmd/platform-atlas-comms.ts @@ -2,8 +2,6 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, dim, bold, success, highlight } from "./iris-api" -import { execSync } from "child_process" -import { homedir } from "os" // ============================================================================ // Atlas Comms CLI — Unified cross-channel lead communications log @@ -53,12 +51,12 @@ async function resolveLead(idOrQuery: string): Promise<{ id: number; lead: any } return { id: leadId, lead: data?.data ?? data } } -// ── iMessage ingestion (local SQLite) ── +// ── iMessage ingestion (via shared lib) ── function ingestImessage(lead: any): any[] { - const MESSAGES_DB = `${homedir()}/Library/Messages/chat.db` + const { searchByHandle, normalizeHandle } = require("../lib/imessage") const identifiers: string[] = [] - if (lead.phone) identifiers.push(lead.phone.replace(/\D/g, "").slice(-10)) + if (lead.phone) identifiers.push(normalizeHandle(lead.phone)) if (lead.email) identifiers.push(lead.email) if (lead.instagram) identifiers.push(lead.instagram.replace("@", "")) @@ -67,28 +65,15 @@ function ingestImessage(lead: any): any[] { const items: any[] = [] for (const ident of identifiers) { try { - const sql = `SELECT - m.rowid, m.text, m.is_from_me, m.date, - datetime(m.date/1000000000 + strftime('%s','2001-01-01'), 'unixepoch', 'localtime') as sent_dt, - c.chat_identifier - FROM message m - JOIN chat_message_join cmj ON m.rowid = cmj.message_id - JOIN chat c ON cmj.chat_id = c.rowid - WHERE c.chat_identifier LIKE '%${ident}%' - AND m.text IS NOT NULL AND m.text != '' - ORDER BY m.date DESC LIMIT 100` - const raw = execSync(`sqlite3 "${MESSAGES_DB}" "${sql}"`, { encoding: "utf-8", timeout: 10000 }).trim() - if (!raw) continue - for (const line of raw.split("\n")) { - const parts = line.split("|") - if (parts.length < 5) continue + const messages = searchByHandle(ident, 90, 100) + for (const m of messages) { items.push({ - direction: parts[2] === "1" ? "outbound" : "inbound", - from_identifier: parts[2] === "1" ? "me" : (parts[5] || ident), - body: parts[1], - sent_at: parts[4], - external_message_id: `imessage_${parts[0]}`, - metadata: { chat_identifier: parts[5] || ident }, + direction: m.from_me ? "outbound" : "inbound", + from_identifier: m.from_me ? "me" : (m.chat_identifier || ident), + body: m.text, + sent_at: m.date, + external_message_id: `imessage_${m.id}`, + metadata: { chat_identifier: m.chat_identifier || ident }, }) } } catch { /* SQLite access may fail — skip silently */ } diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index aa134ccd328c..2f972e2773bc 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -47,8 +47,12 @@ const BloqsListCommand = cmd({ try { const params = new URLSearchParams({ per_page: String(args.limit) }) const res = await irisFetch(`/api/v1/user/${userId}/bloqs?${params}`) - const ok = await handleApiError(res, "List bloqs") - if (!ok) { spinner.stop("Failed", 1); process.exitCode = 1; prompts.outro("Done"); return } + if (!res.ok) { + spinner.stop("Failed", 1) + await handleApiError(res, "List bloqs") + prompts.outro("Done") + return + } const data = (await res.json()) as { data?: any[] } const bloqs: any[] = data?.data ?? [] @@ -105,9 +109,13 @@ const BloqsGetCommand = cmd({ spinner.start("Loading…") try { - const res = await irisFetch(`/api/v1/users/${userId}/bloqs/${args.id}`) - const ok = await handleApiError(res, "Get bloq") - if (!ok) { spinner.stop("Failed", 1); process.exitCode = 1; prompts.outro("Done"); return } + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args.id}`) + if (!res.ok) { + spinner.stop("Failed", 1) + await handleApiError(res, "Get bloq") + prompts.outro("Done") + return + } const data = (await res.json()) as { data?: any } const b = data?.data ?? data @@ -122,7 +130,7 @@ const BloqsGetCommand = cmd({ console.log() // Load lists - const listsRes = await irisFetch(`/api/v1/users/${userId}/bloqs/${args.id}/lists`) + const listsRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${args.id}/lists`) if (listsRes.ok) { const listsData = (await listsRes.json()) as { data?: any[] } const lists: any[] = listsData?.data ?? [] @@ -137,7 +145,7 @@ const BloqsGetCommand = cmd({ // Load files if requested if (args.files) { - const filesRes = await irisFetch(`/api/v1/users/${userId}/bloqs/${args.id}/files`) + const filesRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${args.id}/files`) if (filesRes.ok) { const filesData = (await filesRes.json()) as { data?: any[] } const files: any[] = filesData?.data ?? [] @@ -228,15 +236,19 @@ const BloqsCreateCommand = cmd({ spinner.start("Creating bloq…") try { - const res = await irisFetch(`/api/v1/users/${userId}/bloqs`, { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs`, { method: "POST", body: JSON.stringify({ name, description }), }) - const ok = await handleApiError(res, "Create bloq") - if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + if (!res.ok) { + spinner.stop("Failed", 1) + await handleApiError(res, "Create bloq") + prompts.outro("Done") + return + } - const data = (await res.json()) as { data?: any } - const b = data?.data ?? data + const data = (await res.json()) as { data?: { bloq?: any } } + const b = data?.data?.bloq ?? data?.data ?? data spinner.stop(`${success("✓")} Bloq created: ${bold(String(b.name ?? b.id))}`) printDivider() @@ -292,14 +304,18 @@ const BloqsIngestCommand = cmd({ const formData = new FormData() formData.append("file", new Blob([blob]), filename) - const res = await fetch(`${FL_API}/api/v1/users/${userId}/bloqs/${args.id}/files`, { + const res = await fetch(`${FL_API}/api/v1/user/${userId}/bloqs/${args.id}/files`, { method: "POST", headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, body: formData, }) - const ok = await handleApiError(res, "Ingest file") - if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + if (!res.ok) { + spinner.stop("Failed", 1) + await handleApiError(res, "Ingest file") + prompts.outro("Done") + return + } const data = (await res.json()) as { data?: any; message?: string } spinner.stop(`${success("✓")} ${filename} ingested`) @@ -380,11 +396,15 @@ const BloqsAddItemCommand = cmd({ if (title) payload.title = title const res = await irisFetch( - `/api/v1/users/${userId}/bloqs/${args["bloq-id"]}/lists/${args["list-id"]}/items`, + `/api/v1/user/${userId}/bloqs/${args["bloq-id"]}/lists/${args["list-id"]}/items`, { method: "POST", body: JSON.stringify(payload) }, ) - const ok = await handleApiError(res, "Add item") - if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + if (!res.ok) { + spinner.stop("Failed", 1) + await handleApiError(res, "Add item") + prompts.outro("Done") + return + } spinner.stop(`${success("✓")} Item added`) prompts.outro(dim(`iris bloqs get ${args["bloq-id"]}`)) @@ -480,21 +500,25 @@ const BloqsComposeCommand = cmd({ spinner.start("Creating knowledge base…") try { - const res = await irisFetch(`/api/v1/users/${userId}/bloqs`, { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs`, { method: "POST", body: JSON.stringify({ name, description }), }) - const ok = await handleApiError(res, "Create bloq") - if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + if (!res.ok) { + spinner.stop("Failed", 1) + await handleApiError(res, "Create bloq") + prompts.outro("Done") + return + } - const data = (await res.json()) as { data?: any } - const bloq = data?.data ?? data + const data = (await res.json()) as { data?: { bloq?: any } } + const bloq = data?.data?.bloq ?? data?.data ?? data const bloqId = bloq.id // Step 5: Create lists let listsCreated = 0 for (const listName of suggestedLists) { - const listRes = await irisFetch(`/api/v1/users/${userId}/bloqs/${bloqId}/lists`, { + const listRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}/lists`, { method: "POST", body: JSON.stringify({ name: listName }), }) diff --git a/packages/opencode/src/cli/cmd/platform-customer.ts b/packages/opencode/src/cli/cmd/platform-customer.ts index 5d7fbc6cf8b6..e460222daf50 100644 --- a/packages/opencode/src/cli/cmd/platform-customer.ts +++ b/packages/opencode/src/cli/cmd/platform-customer.ts @@ -398,10 +398,9 @@ async function searchMail(query: string, days: number): Promise { } function queryMessagesDb(sql: string): string { - const { execSync } = require("child_process") as typeof import("child_process") - const db = `${process.env.HOME}/Library/Messages/chat.db` try { - return execSync(`sqlite3 "${db}" "${sql.replace(/"/g, '\\"')}"`, { encoding: "utf-8", timeout: 5000 }).trim() + const { query } = require("../lib/imessage") + return query(sql) } catch { return "" } } diff --git a/packages/opencode/src/cli/cmd/platform-doctor.ts b/packages/opencode/src/cli/cmd/platform-doctor.ts index 30e6ac25835f..908922ea0c74 100644 --- a/packages/opencode/src/cli/cmd/platform-doctor.ts +++ b/packages/opencode/src/cli/cmd/platform-doctor.ts @@ -128,12 +128,15 @@ export const PlatformDoctorCommand = cmd({ // ── 5. macOS Permissions ── sp.start("Checking macOS permissions…") // Full Disk Access (needed for iMessage SQLite) - try { - const db = `${homedir()}/Library/Messages/chat.db` - execSync(`sqlite3 "${db}" "SELECT 1 FROM message LIMIT 1" 2>&1`, { encoding: "utf-8", timeout: 3000 }) - allResults.push({ name: "Full Disk Access", ok: true, detail: "Messages.app readable" }) - } catch { - allResults.push({ name: "Full Disk Access", ok: false, detail: "cannot read Messages.app", hint: "System Settings → Privacy → Full Disk Access" }) + { + const { isAvailable } = await import("../lib/imessage") + const ok = isAvailable() + allResults.push({ + name: "Full Disk Access", + ok, + detail: ok ? "Messages.app readable" : "cannot read Messages.app", + hint: ok ? undefined : "System Settings → Privacy → Full Disk Access", + }) } // Contacts access (needed for address book matching) diff --git a/packages/opencode/src/cli/cmd/platform-events-production.ts b/packages/opencode/src/cli/cmd/platform-events-production.ts new file mode 100644 index 000000000000..c2685d5cb23a --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-events-production.ts @@ -0,0 +1,529 @@ +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { UI } from "../ui" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" + +// ============================================================================ +// Event Production CLI — runsheet, checklist, budget, overview +// All data in metadata.production (no new tables) +// ============================================================================ + +async function loadEvent(eventId: number): Promise { + const res = await irisFetch(`/api/v1/events/${eventId}`) + if (!res.ok) return null + return ((await res.json()) as any)?.data ?? null +} + +async function loadSubResources(eventId: number) { + const [stagesRes, ticketsRes, vendorsRes] = await Promise.all([ + irisFetch(`/api/v1/events/${eventId}/stages`).catch(() => null), + irisFetch(`/api/v1/events/${eventId}/tickets`).catch(() => null), + irisFetch(`/api/v1/events/${eventId}/vendors`).catch(() => null), + ]) + return { + stages: stagesRes?.ok ? ((await stagesRes.json()) as any)?.data ?? [] : [], + tickets: ticketsRes?.ok ? ((await ticketsRes.json()) as any)?.data ?? [] : [], + vendors: vendorsRes?.ok ? ((await vendorsRes.json()) as any)?.data ?? [] : [], + } +} + +function to12h(t: string): string { + if (!t) return "" + const [h, m] = t.split(":").map(Number) + const ampm = h >= 12 ? "PM" : "AM" + const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h + return `${h12}:${String(m).padStart(2, "0")} ${ampm}` +} + +function getProduction(event: any): any { + return event?.metadata?.production ?? {} +} + +// Load local event JSON (for metadata editing) +async function loadLocalEvent(eventId: number): Promise<{ data: any; path: string } | null> { + const { existsSync, readFileSync, readdirSync } = await import("fs") + const { join } = await import("path") + const { homedir } = await import("os") + const dir = join(homedir(), ".iris", "events") + if (!existsSync(dir)) return null + const files = readdirSync(dir).filter((f: string) => f.startsWith(`${eventId}-`) && f.endsWith(".json") && !f.includes("tickets")) + if (files.length === 0) return null + const path = join(dir, files[0]) + return { data: JSON.parse(readFileSync(path, "utf8")), path } +} + +async function saveLocalEvent(path: string, data: any) { + const { writeFileSync } = await import("fs") + writeFileSync(path, JSON.stringify(data, null, 2)) +} + +// ── Overview ── + +const OverviewCmd = cmd({ + command: "overview", + aliases: ["status", "dashboard"], + describe: "full production dashboard — readiness, runsheet, tickets, staff, budget", + builder: (y) => y, + async handler(args: any) { + const eventId = args["event-id"] ?? args._parentEventId ?? args.eventId + UI.empty() + prompts.intro(`◈ Production: Event #${eventId}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + if (!eventId) { + prompts.log.error("No event ID. Usage: iris events production overview") + prompts.outro("Done") + return + } + + const sp = prompts.spinner() + sp.start("Loading…") + + const event = await loadEvent(eventId) + if (!event) { sp.stop("Event not found", 1); prompts.outro("Done"); return } + const { stages, tickets, vendors } = await loadSubResources(eventId) + const prod = getProduction(event) + + sp.stop(bold(event.title)) + + // Header + printDivider() + printKV("Date", `${event.start_date} ${to12h(event.start_time)} – ${to12h(event.end_time)}`) + printKV("Venue", event.venue_name || dim("not set")) + + // Runsheet + const runsheet: any[] = prod.runsheet ?? [] + // Also build from stage set times if no runsheet + let timeline = runsheet + if (timeline.length === 0) { + for (const s of stages) { + for (const st of (s.set_times ?? s.event_stage_set_times ?? [])) { + timeline.push({ time: st.start_time, title: st.title, status: "pending", stage: s.title }) + } + } + // Add production timeline from metadata + for (const pt of (event.metadata?.production_timeline ?? [])) { + timeline.push({ time: pt.time, title: pt.task, status: "pending", isProd: true }) + } + timeline.sort((a: any, b: any) => (a.time || "").localeCompare(b.time || "")) + } + + if (timeline.length > 0) { + console.log() + console.log(` ${bold("Runsheet")} ${dim(`(${timeline.length} items)`)}`) + const now = new Date() + const nowTime = now.getHours().toString().padStart(2, "0") + ":" + now.getMinutes().toString().padStart(2, "0") + for (const item of timeline) { + const isPast = item.time < nowTime + const icon = item.status === "done" ? success("✓") : isPast ? dim("·") : "○" + const timeStr = dim(to12h(item.time).padEnd(9)) + const title = item.isProd ? dim(item.title) : item.title + console.log(` ${icon} ${timeStr} ${title}`) + } + } + + // Tickets + console.log() + console.log(` ${bold("Tickets")} ${dim(`(${tickets.length} tiers)`)}`) + let totalRevenue = 0 + for (const t of tickets) { + const sold = t.quantity_sold ?? 0 + const total = t.quantity_total + const price = parseFloat(t.price || "0") + totalRevenue += sold * price + const soldStr = total ? `${sold}/${total}` : `${sold}` + console.log(` ${t.title}: ${soldStr} sold ${dim(`($${price})`)}`) + } + printKV(" Ticket Revenue", `$${totalRevenue.toFixed(2)}`) + + // Vendors + console.log() + console.log(` ${bold("Vendors")} ${dim(`(${vendors.length})`)}`) + if (vendors.length === 0) { + console.log(` ${dim("none — iris events vendor-create " + eventId)}`) + } else { + for (const v of vendors) { + console.log(` ${v.title}${v.subtitle ? dim(` — ${v.subtitle}`) : ""}`) + } + } + + // Stages + console.log() + console.log(` ${bold("Stages")} ${dim(`(${stages.length})`)}`) + for (const s of stages) { + const setTimes = s.set_times ?? s.event_stage_set_times ?? [] + console.log(` ${highlight(s.title)} ${dim(`(${setTimes.length} acts)`)}`) + } + + // Checklist + const checklist: any[] = prod.checklist ?? [] + if (checklist.length > 0) { + const done = checklist.filter((c: any) => c.done).length + const pct = Math.round((done / checklist.length) * 100) + console.log() + console.log(` ${bold("Checklist")} ${dim(`${done}/${checklist.length} (${pct}%)`)}`) + for (const item of checklist) { + const icon = item.done ? success("✓") : `${UI.Style.TEXT_DANGER}✗${UI.Style.TEXT_NORMAL}` + console.log(` ${icon} ${item.item}`) + } + } + + // Budget + const budget = prod.budget + if (budget) { + const income = (budget.income ?? []).reduce((s: number, i: any) => s + (i.amount || 0), 0) + const expenses = (budget.expenses ?? []).reduce((s: number, i: any) => s + (i.amount || 0), 0) + console.log() + console.log(` ${bold("Budget")}`) + printKV(" Income", `$${income}`) + printKV(" Expenses", `$${expenses}`) + printKV(" Margin", income - expenses >= 0 ? success(`$${income - expenses}`) : `${UI.Style.TEXT_DANGER}$${income - expenses}${UI.Style.TEXT_NORMAL}`) + } + + printDivider() + prompts.outro("Done") + }, +}) + +// ── Runsheet ── + +const RunsheetCmd = cmd({ + command: "runsheet", + aliases: ["timeline", "schedule"], + describe: "show/edit the run-of-show timeline", + builder: (y) => + y + .option("add", { type: "string", describe: 'add item: "18:30 Sound Check 30min"' }) + .option("done", { type: "number", describe: "mark item # as done" }) + .option("json", { type: "boolean", default: false }), + async handler(args: any) { + const eventId = args["event-id"] ?? args._parentEventId ?? args.eventId + UI.empty() + prompts.intro(`◈ Runsheet — Event #${eventId}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const local = await loadLocalEvent(eventId) + if (!local) { + prompts.log.error(`No local event file. Run: iris events pull ${eventId}`) + prompts.outro("Done") + return + } + + const prod = local.data.metadata?.production ?? {} + let runsheet: any[] = prod.runsheet ?? [] + + // If no runsheet, build from stages + production_timeline + if (runsheet.length === 0 && !args.add) { + const stages = local.data.stages ?? local.data.event_stages ?? [] + for (const s of stages) { + for (const st of (s.set_times ?? s.event_stage_set_times ?? [])) { + runsheet.push({ time: st.start_time, end: st.end_time, title: st.title, status: "pending", stage: s.title }) + } + } + for (const pt of (local.data.metadata?.production_timeline ?? [])) { + runsheet.push({ time: pt.time, title: pt.task, status: "pending", isProd: true }) + } + runsheet.sort((a: any, b: any) => (a.time || "").localeCompare(b.time || "")) + } + + // --add + if (args.add) { + const match = String(args.add).match(/^(\d{1,2}:\d{2})\s+(.+?)(?:\s+(\d+)min)?$/) + if (!match) { + prompts.log.error('Format: "HH:MM Title [Nmin]" — e.g. "18:30 Sound Check 30min"') + prompts.outro("Done") + return + } + const [, time, title, dur] = match + const item: any = { time, title, status: "pending" } + if (dur) item.duration_min = parseInt(dur) + runsheet.push(item) + runsheet.sort((a: any, b: any) => (a.time || "").localeCompare(b.time || "")) + + if (!local.data.metadata) local.data.metadata = {} + if (!local.data.metadata.production) local.data.metadata.production = {} + local.data.metadata.production.runsheet = runsheet + await saveLocalEvent(local.path, local.data) + prompts.log.success(`Added: ${to12h(time)} ${title}`) + prompts.log.info(dim(`Push with: iris events push ${eventId}`)) + prompts.outro("Done") + return + } + + // --done + if (args.done != null) { + const idx = args.done - 1 + if (idx < 0 || idx >= runsheet.length) { + prompts.log.error(`Item #${args.done} not found (have ${runsheet.length} items)`) + prompts.outro("Done") + return + } + runsheet[idx].status = "done" + if (!local.data.metadata) local.data.metadata = {} + if (!local.data.metadata.production) local.data.metadata.production = {} + local.data.metadata.production.runsheet = runsheet + await saveLocalEvent(local.path, local.data) + prompts.log.success(`Marked done: ${runsheet[idx].title}`) + prompts.outro("Done") + return + } + + // Display + if (args.json) { + console.log(JSON.stringify(runsheet, null, 2)) + prompts.outro("Done") + return + } + + const now = new Date() + const nowTime = now.getHours().toString().padStart(2, "0") + ":" + now.getMinutes().toString().padStart(2, "0") + let nowIdx = -1 + for (let i = runsheet.length - 1; i >= 0; i--) { + if (runsheet[i].time <= nowTime) { nowIdx = i; break } + } + + printDivider() + for (let i = 0; i < runsheet.length; i++) { + const item = runsheet[i] + const isPast = i < nowIdx + const isNow = i === nowIdx + const isNext = i === nowIdx + 1 + const isDone = item.status === "done" + + let icon = "○" + if (isDone) icon = success("✓") + else if (isNow) icon = highlight("●") + else if (isPast) icon = dim("·") + + const num = dim(`${String(i + 1).padStart(2)}.`) + const time = dim(to12h(item.time || "").padEnd(9)) + const label = isNow ? bold(item.title) : isNext ? highlight(item.title) : isPast && !isDone ? dim(item.title) : item.title + const stage = item.stage && !item.isProd ? dim(` [${item.stage}]`) : "" + const nowLabel = isNow ? ` ${highlight("← NOW")}` : isNext ? ` ${dim("← NEXT")}` : "" + + console.log(` ${num} ${icon} ${time} ${label}${stage}${nowLabel}`) + } + printDivider() + prompts.log.info(dim(`Mark done: iris events production ${eventId} runsheet --done 3`)) + prompts.log.info(dim(`Add item: iris events production ${eventId} runsheet --add "18:30 Sound Check 30min"`)) + prompts.outro("Done") + }, +}) + +// ── Checklist ── + +const ChecklistCmd = cmd({ + command: "checklist", + aliases: ["todo", "tasks"], + describe: "production checklist with completion tracking", + builder: (y) => + y + .option("add", { type: "string", describe: "add checklist item" }) + .option("done", { type: "number", describe: "mark item # as done" }) + .option("undo", { type: "number", describe: "mark item # as not done" }) + .option("json", { type: "boolean", default: false }), + async handler(args: any) { + const eventId = args["event-id"] ?? args._parentEventId ?? args.eventId + UI.empty() + prompts.intro(`◈ Checklist — Event #${eventId}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const local = await loadLocalEvent(eventId) + if (!local) { + prompts.log.error(`No local event file. Run: iris events pull ${eventId}`) + prompts.outro("Done") + return + } + + if (!local.data.metadata) local.data.metadata = {} + if (!local.data.metadata.production) local.data.metadata.production = {} + let checklist: any[] = local.data.metadata.production.checklist ?? [] + + // --add + if (args.add) { + checklist.push({ item: String(args.add), done: false }) + local.data.metadata.production.checklist = checklist + await saveLocalEvent(local.path, local.data) + prompts.log.success(`Added: ${args.add}`) + prompts.outro("Done") + return + } + + // --done + if (args.done != null) { + const idx = args.done - 1 + if (idx < 0 || idx >= checklist.length) { + prompts.log.error(`Item #${args.done} not found`) + prompts.outro("Done") + return + } + checklist[idx].done = true + local.data.metadata.production.checklist = checklist + await saveLocalEvent(local.path, local.data) + prompts.log.success(`Done: ${checklist[idx].item}`) + prompts.outro("Done") + return + } + + // --undo + if (args.undo != null) { + const idx = args.undo - 1 + if (idx >= 0 && idx < checklist.length) { + checklist[idx].done = false + local.data.metadata.production.checklist = checklist + await saveLocalEvent(local.path, local.data) + prompts.log.info(`Undone: ${checklist[idx].item}`) + } + prompts.outro("Done") + return + } + + // Display + if (args.json) { + console.log(JSON.stringify(checklist, null, 2)) + prompts.outro("Done") + return + } + + if (checklist.length === 0) { + prompts.log.warn("No checklist items yet") + prompts.log.info(dim(`Add: iris events production ${eventId} checklist --add "Test OBS scenes"`)) + prompts.outro("Done") + return + } + + const done = checklist.filter((c: any) => c.done).length + const pct = Math.round((done / checklist.length) * 100) + const pctColor = pct >= 80 ? success : pct >= 50 ? (s: string) => `${UI.Style.TEXT_WARNING}${s}${UI.Style.TEXT_NORMAL}` : (s: string) => `${UI.Style.TEXT_DANGER}${s}${UI.Style.TEXT_NORMAL}` + + printDivider() + for (let i = 0; i < checklist.length; i++) { + const c = checklist[i] + const icon = c.done ? success("✓") : `${UI.Style.TEXT_DANGER}✗${UI.Style.TEXT_NORMAL}` + const num = dim(`${String(i + 1).padStart(2)}.`) + console.log(` ${num} ${icon} ${c.done ? dim(c.item) : c.item}`) + } + printDivider() + console.log(` Completion: ${pctColor(`${pct}%`)} (${done}/${checklist.length})`) + + prompts.outro("Done") + }, +}) + +// ── Budget (quick view) ── + +const BudgetCmd = cmd({ + command: "budget", + aliases: ["pnl", "money"], + describe: "income vs expenses with margin", + builder: (y) => + y + .option("add-income", { type: "string", describe: 'add income: "sponsors 500 confirmed"' }) + .option("add-expense", { type: "string", describe: 'add expense: "PA rental 200 pending"' }) + .option("json", { type: "boolean", default: false }), + async handler(args: any) { + const eventId = args["event-id"] ?? args._parentEventId ?? args.eventId + UI.empty() + prompts.intro(`◈ Budget — Event #${eventId}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const local = await loadLocalEvent(eventId) + if (!local) { + prompts.log.error(`No local event file. Run: iris events pull ${eventId}`) + prompts.outro("Done") + return + } + + if (!local.data.metadata) local.data.metadata = {} + if (!local.data.metadata.production) local.data.metadata.production = {} + if (!local.data.metadata.production.budget) local.data.metadata.production.budget = { income: [], expenses: [] } + const budget = local.data.metadata.production.budget + + // --add-income + if (args["add-income"]) { + const parts = String(args["add-income"]).split(/\s+/) + const source = parts[0] + const amount = parseFloat(parts[1] || "0") + const status = parts[2] || "pending" + budget.income.push({ source, amount, status }) + await saveLocalEvent(local.path, local.data) + prompts.log.success(`Added income: ${source} $${amount} (${status})`) + prompts.outro("Done") + return + } + + // --add-expense + if (args["add-expense"]) { + const parts = String(args["add-expense"]).split(/\s+/) + const item = parts[0] + const amount = parseFloat(parts[1] || "0") + const status = parts[2] || "pending" + budget.expenses.push({ item, amount, status }) + await saveLocalEvent(local.path, local.data) + prompts.log.success(`Added expense: ${item} $${amount} (${status})`) + prompts.outro("Done") + return + } + + // Display + const income = budget.income ?? [] + const expenses = budget.expenses ?? [] + const totalIncome = income.reduce((s: number, i: any) => s + (i.amount || 0), 0) + const totalExpenses = expenses.reduce((s: number, i: any) => s + (i.amount || 0), 0) + const margin = totalIncome - totalExpenses + + if (args.json) { + console.log(JSON.stringify({ income, expenses, totalIncome, totalExpenses, margin }, null, 2)) + prompts.outro("Done") + return + } + + printDivider() + console.log(` ${bold("Income")}`) + for (const i of income) { + console.log(` ${success("+")} $${i.amount} ${i.source} ${dim(i.status || "")}`) + } + if (income.length === 0) console.log(` ${dim("none")}`) + + console.log() + console.log(` ${bold("Expenses")}`) + for (const e of expenses) { + console.log(` ${dim("-")} $${e.amount} ${e.item} ${dim(e.status || "")}`) + } + if (expenses.length === 0) console.log(` ${dim("none")}`) + + printDivider() + printKV(" Total Income", success(`$${totalIncome}`)) + printKV(" Total Expenses", `$${totalExpenses}`) + printKV(" Margin", margin >= 0 ? success(`$${margin}`) : `${UI.Style.TEXT_DANGER}-$${Math.abs(margin)}${UI.Style.TEXT_NORMAL}`) + + prompts.outro("Done") + }, +}) + +// ============================================================================ +// Root — registered as subcommand of `iris events` +// ============================================================================ + +// Shared event ID — set by parent command, read by subcommands +let _productionEventId = 0 + +export const ProductionCommand = cmd({ + command: "production", + aliases: ["prod"], + describe: "event production management — runsheet, checklist, budget, overview", + builder: (y: any) => + y + .option("event-id", { type: "number", alias: "e", demandOption: true, describe: "event ID" }) + .command(OverviewCmd) + .command(RunsheetCmd) + .command(ChecklistCmd) + .command(BudgetCmd) + .demandCommand(1, "specify: overview, runsheet, checklist, budget"), + async handler() {}, +}) + +// Re-export for subcommand access +export function getProductionEventId(args: any): number { + return args["event-id"] ?? _productionEventId ?? 0 +} diff --git a/packages/opencode/src/cli/cmd/platform-events.ts b/packages/opencode/src/cli/cmd/platform-events.ts index e25f524dda5e..779f2c69c984 100644 --- a/packages/opencode/src/cli/cmd/platform-events.ts +++ b/packages/opencode/src/cli/cmd/platform-events.ts @@ -4,6 +4,7 @@ import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join, basename } from "path" +import { ProductionCommand } from "./platform-events-production" // ============================================================================ // Sync helpers @@ -33,7 +34,10 @@ function entityFilename(e: Record): string { function findLocalFile(dir: string, id: number): string | undefined { if (!existsSync(dir)) return undefined const prefix = `${id}-` - const files = require("fs").readdirSync(dir).filter((f: string) => f.startsWith(prefix) && f.endsWith(".json")) + // Exclude tickets files — those are managed by tickets-pull/push + const files = require("fs").readdirSync(dir).filter((f: string) => + f.startsWith(prefix) && f.endsWith(".json") && !f.includes("tickets") + ) return files.length > 0 ? join(dir, files[0]) : undefined } @@ -361,17 +365,30 @@ const PushCommand = cmd({ spinner.start(`Pushing ${basename(filepath)}…`) const entity = JSON.parse(readFileSync(filepath, "utf-8")) - const payload: Record = { - title: entity.title, description: entity.description, - start_date: entity.start_date, start_time: entity.start_time, - end_date: entity.end_date, end_time: entity.end_time, - venue_name: entity.venue_name, street: entity.street, - city: entity.city, state: entity.state, zip: entity.zip, - pricing: entity.pricing, purchase_ticket_url: entity.purchase_ticket_url, - tags: entity.tags, event_type: entity.event_type, status: entity.status, - url: entity.url, photo: entity.photo, + // Pass-through: send all fields. API validates known fields, + // unknown fields are saved to metadata so nothing is lost (#58785) + const READONLY = new Set(["id", "created_at", "updated_at", "creator", "tickets", "stages", "vendors", "staff", "bloq"]) + const payload: Record = {} + const extraMetadata: Record = {} + const KNOWN_FIELDS = new Set([ + "title", "description", "start_date", "start_time", "end_date", "end_time", + "venue_name", "street", "city", "state", "zip", "pricing", + "purchase_ticket_url", "tags", "event_type", "status", "url", "photo", + "metadata", "profile_id", "bloq_id", + ]) + for (const [k, v] of Object.entries(entity)) { + if (READONLY.has(k) || v === undefined || v === null) continue + if (KNOWN_FIELDS.has(k)) { + payload[k] = v + } else { + extraMetadata[k] = v + } + } + // Merge extra fields into metadata so they're preserved + const metaKeys = Object.keys(extraMetadata) + if (metaKeys.length > 0) { + payload.metadata = { ...(entity.metadata ?? {}), ...extraMetadata } } - for (const k of Object.keys(payload)) { if (payload[k] === undefined) delete payload[k] } const res = await irisFetch(`/api/v1/events/${args.id}`, { method: "PUT", body: JSON.stringify(payload) }) const ok = await handleApiError(res, "Push event") @@ -382,6 +399,11 @@ const PushCommand = cmd({ printDivider() printKV("ID", args.id) printKV("From", filepath) + // Warn about fields saved to metadata (not schema-validated) + if (metaKeys.length > 0) { + console.log(` ${dim("Saved to metadata:")} ${metaKeys.join(", ")}`) + console.log(` ${dim("These fields are preserved but not schema-validated.")}`) + } printDivider() prompts.outro(dim(`iris events diff ${args.id}`)) @@ -920,7 +942,8 @@ const TicketsPushCommand = cmd({ yargs .positional("event-id", { describe: "event ID", type: "number", demandOption: true }) .option("file", { alias: "f", describe: "local JSON file path", type: "string" }) - .option("dry-run", { describe: "show what would change without applying", type: "boolean", default: false }), + .option("dry-run", { describe: "show what would change without applying", type: "boolean", default: false }) + .option("force", { alias: "y", describe: "skip confirmation prompt (for automation)", type: "boolean", default: false }), async handler(args) { UI.empty() prompts.intro(`◈ Push Tickets — Event #${args["event-id"]}`) @@ -1030,9 +1053,11 @@ const TicketsPushCommand = cmd({ return } - // 5. Confirm and apply - const confirmed = await prompts.confirm({ message: "Apply these changes?" }) - if (!confirmed || prompts.isCancel(confirmed)) { prompts.outro("Cancelled"); return } + // 5. Confirm and apply (skip with --force for automation) + if (!args.force) { + const confirmed = await prompts.confirm({ message: "Apply these changes?" }) + if (!confirmed || prompts.isCancel(confirmed)) { prompts.outro("Cancelled"); return } + } const applySpinner = prompts.spinner() applySpinner.start("Applying…") @@ -1285,13 +1310,338 @@ const TicketCheckoutCommand = cmd({ }, }) +// ============================================================================ +// Preflight — live system checks before going live +// ============================================================================ + +const BRIDGE = "http://localhost:3200" + +interface PCheck { name: string; ok: boolean; detail?: string; hint?: string; category: string } + +const PreflightCommand = cmd({ + command: "preflight ", + aliases: ["pre", "go-check"], + describe: "production readiness check — verify OBS, stream, tickets, bridge before going live", + builder: (y) => + y.positional("event-id", { type: "number", demandOption: true }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Event #${args["event-id"]} — Preflight Check`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const sp = prompts.spinner() + sp.start("Loading event…") + + // Fetch event data + const eventRes = await irisFetch(`/api/v1/events/${args["event-id"]}`) + if (!eventRes.ok) { await handleApiError(eventRes, "Get event"); sp.stop("Failed", 1); prompts.outro("Done"); return } + const event = ((await eventRes.json()) as any)?.data ?? {} + sp.stop(bold(event.title || `Event #${args["event-id"]}`)) + + // Fetch sub-resources in parallel + const [stagesRes, ticketsRes, vendorsRes] = await Promise.all([ + irisFetch(`/api/v1/events/${args["event-id"]}/stages`).catch(() => null), + irisFetch(`/api/v1/events/${args["event-id"]}/tickets`).catch(() => null), + irisFetch(`/api/v1/events/${args["event-id"]}/vendors`).catch(() => null), + ]) + const stages: any[] = stagesRes?.ok ? ((await stagesRes.json()) as any)?.data ?? [] : [] + const tickets: any[] = ticketsRes?.ok ? ((await ticketsRes.json()) as any)?.data ?? [] : [] + const vendors: any[] = vendorsRes?.ok ? ((await vendorsRes.json()) as any)?.data ?? [] : [] + + sp.start("Running checks…") + + const checks: PCheck[] = [] + + // ── Production checks (OBS + Bridge) ── + let obsConnected = false + let obsScenes: string[] = [] + let obsStreamActive = false + let obsRecordActive = false + let obsInputs: any[] = [] + + try { + const health = await fetch(`${BRIDGE}/health`, { signal: AbortSignal.timeout(3000) }).then(r => r.json()) + checks.push({ name: "IRIS Bridge", ok: true, detail: "running", category: "Production" }) + + const obs = health?.messaging?.obs ?? health?.obs ?? {} + obsConnected = obs?.status === "running" + checks.push({ + name: "OBS connected", + ok: obsConnected, + detail: obsConnected ? obs.host : "not connected", + hint: obsConnected ? undefined : "iris obs connect", + category: "Production", + }) + } catch { + checks.push({ name: "IRIS Bridge", ok: false, hint: "iris hive start", category: "Production" }) + checks.push({ name: "OBS connected", ok: false, hint: "start bridge first", category: "Production" }) + } + + if (obsConnected) { + try { + const scenes = await fetch(`${BRIDGE}/api/obs/scenes`, { signal: AbortSignal.timeout(3000) }).then(r => r.json()) + obsScenes = (scenes.scenes || []).map((s: any) => s.name) + const matched = stages.filter(s => obsScenes.some(os => os.toLowerCase().includes(s.title?.toLowerCase() || "___"))) + checks.push({ + name: "Scenes match stages", + ok: matched.length > 0 || stages.length === 0, + detail: `${obsScenes.length} scenes, ${matched.length}/${stages.length} stages matched`, + hint: matched.length === 0 && stages.length > 0 ? "iris obs scenes — map stages to OBS scenes" : undefined, + category: "Production", + }) + checks.push({ name: "Current scene", ok: true, detail: scenes.current || "?", category: "Production" }) + } catch {} + + try { + const stream = await fetch(`${BRIDGE}/api/obs/stream/status`, { signal: AbortSignal.timeout(3000) }).then(r => r.json()) + obsStreamActive = stream.active + checks.push({ + name: "Streaming", + ok: true, + detail: stream.active ? `LIVE — ${stream.timecode}` : "not streaming (ready)", + category: "Production", + }) + } catch {} + + try { + const rec = await fetch(`${BRIDGE}/api/obs/record/status`, { signal: AbortSignal.timeout(3000) }).then(r => r.json()) + obsRecordActive = rec.active + checks.push({ + name: "Recording", + ok: true, + detail: rec.active ? `recording — ${rec.timecode}` : "not recording (ready)", + category: "Production", + }) + } catch {} + + try { + obsInputs = await fetch(`${BRIDGE}/api/obs/inputs`, { signal: AbortSignal.timeout(3000) }).then(r => r.json()) + const cameras = obsInputs.filter((i: any) => i.kind?.includes("capture") && !i.kind?.includes("audio") && !i.kind?.includes("screen")) + const mics = obsInputs.filter((i: any) => i.kind?.includes("audio")) + checks.push({ name: "Cameras detected", ok: cameras.length > 0, detail: `${cameras.length} camera(s)`, category: "Production" }) + checks.push({ name: "Audio inputs", ok: mics.length > 0, detail: `${mics.length} mic(s)`, category: "Production" }) + } catch {} + } + + // ── Tickets ── + checks.push({ + name: "Tickets created", + ok: tickets.length > 0, + detail: tickets.length > 0 ? `${tickets.length} tier(s): ${tickets.map((t: any) => `$${t.price}`).join(" / ")}` : "none", + hint: tickets.length === 0 ? `iris events ticket-create ${args["event-id"]}` : undefined, + category: "Tickets", + }) + + const onSale = tickets.filter((t: any) => t.status === "active" && t.is_visible) + checks.push({ + name: "Tickets on sale", + ok: onSale.length > 0, + detail: `${onSale.length}/${tickets.length} on sale`, + hint: onSale.length === 0 ? "activate tickets in admin" : undefined, + category: "Tickets", + }) + + // Check checkout URLs + for (const t of tickets.slice(0, 3)) { + if (t.checkout_url) { + try { + const r = await fetch(t.checkout_url, { method: "HEAD", redirect: "follow", signal: AbortSignal.timeout(5000) }) + checks.push({ name: `Checkout: ${t.title}`, ok: r.status < 400, detail: `${r.status}`, category: "Tickets" }) + } catch { + checks.push({ name: `Checkout: ${t.title}`, ok: false, detail: "unreachable", category: "Tickets" }) + } + } + } + + // ── Content ── + const slug = event.slug || event.title?.toLowerCase().replace(/[^a-z0-9]+/g, "-") + if (slug) { + try { + const pageRes = await fetch(`https://heyiris.io/p/${slug}`, { method: "HEAD", signal: AbortSignal.timeout(5000) }) + checks.push({ name: "Event page", ok: pageRes.status === 200, detail: pageRes.status === 200 ? `heyiris.io/p/${slug}` : `HTTP ${pageRes.status}`, hint: pageRes.status !== 200 ? `iris pages create --slug=${slug}` : undefined, category: "Content" }) + } catch { + checks.push({ name: "Event page", ok: false, hint: `iris pages create --slug=${slug}`, category: "Content" }) + } + } + + // ── Logistics ── + checks.push({ + name: "Stages defined", + ok: stages.length > 0, + detail: `${stages.length} stage(s)`, + hint: stages.length === 0 ? `iris events stage-create ${args["event-id"]}` : undefined, + category: "Logistics", + }) + checks.push({ + name: "Vendors confirmed", + ok: vendors.length > 0, + detail: `${vendors.length} vendor(s)`, + hint: vendors.length === 0 ? `iris events vendor-create ${args["event-id"]}` : undefined, + category: "Logistics", + }) + checks.push({ + name: "Venue set", + ok: !!(event.venue_name && event.city), + detail: event.venue_name ? `${event.venue_name}, ${event.city}` : "missing", + hint: !event.venue_name ? `iris events update ${args["event-id"]} --venue="..."` : undefined, + category: "Logistics", + }) + + sp.stop("Done") + + // ── Render ── + if (args.json) { + console.log(JSON.stringify(checks, null, 2)) + prompts.outro("Done") + return + } + + const categories = [...new Set(checks.map(c => c.category))] + const passing = checks.filter(c => c.ok).length + const total = checks.length + const pct = Math.round((passing / total) * 100) + + for (const cat of categories) { + const catChecks = checks.filter(c => c.category === cat) + const catPass = catChecks.filter(c => c.ok).length + console.log() + console.log(` ${bold(cat)} ${dim(`(${catPass}/${catChecks.length})`)}`) + for (const c of catChecks) { + const icon = c.ok ? success("✓") : `${UI.Style.TEXT_DANGER}✗${UI.Style.TEXT_NORMAL}` + const detail = c.detail ? dim(` ${c.detail}`) : "" + const hint = (!c.ok && c.hint) ? ` ${dim(`→ ${c.hint}`)}` : "" + console.log(` ${icon} ${c.name.padEnd(22)}${detail}${hint}`) + } + } + + printDivider() + const color = pct >= 80 ? success : pct >= 50 ? (s: string) => `${UI.Style.TEXT_WARNING}${s}${UI.Style.TEXT_NORMAL}` : (s: string) => `${UI.Style.TEXT_DANGER}${s}${UI.Style.TEXT_NORMAL}` + console.log(` Readiness: ${color(`${pct}%`)} (${passing}/${total})`) + + if (pct < 100) process.exitCode = 1 + prompts.outro("Done") + }, +}) + +// ============================================================================ +// Audit — data completeness + professional quality checks +// ============================================================================ + +const AuditCommand = cmd({ + command: "audit ", + aliases: ["qa", "check"], + describe: "data completeness audit — check all fields, stages, tickets, staff, content quality", + builder: (y) => + y.positional("event-id", { type: "number", demandOption: true }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Event #${args["event-id"]} — Audit`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const sp = prompts.spinner() + sp.start("Loading event…") + + const eventRes = await irisFetch(`/api/v1/events/${args["event-id"]}`) + if (!eventRes.ok) { await handleApiError(eventRes, "Get event"); sp.stop("Failed", 1); prompts.outro("Done"); return } + const event = ((await eventRes.json()) as any)?.data ?? {} + + const [stagesRes, ticketsRes, vendorsRes] = await Promise.all([ + irisFetch(`/api/v1/events/${args["event-id"]}/stages`).catch(() => null), + irisFetch(`/api/v1/events/${args["event-id"]}/tickets`).catch(() => null), + irisFetch(`/api/v1/events/${args["event-id"]}/vendors`).catch(() => null), + ]) + const stages: any[] = stagesRes?.ok ? ((await stagesRes.json()) as any)?.data ?? [] : [] + const tickets: any[] = ticketsRes?.ok ? ((await ticketsRes.json()) as any)?.data ?? [] : [] + const vendors: any[] = vendorsRes?.ok ? ((await vendorsRes.json()) as any)?.data ?? [] : [] + + sp.stop(bold(event.title || `Event #${args["event-id"]}`)) + + const checks: PCheck[] = [] + const eid = args["event-id"] + + // ── Event Details ── + checks.push({ name: "Title", ok: !!event.title, detail: event.title || "missing", hint: `iris events update ${eid} --title="..."`, category: "Details" }) + checks.push({ name: "Description", ok: !!(event.description && event.description.length > 20), detail: event.description ? `${event.description.length} chars` : "missing", hint: `iris events update ${eid} --description="..."`, category: "Details" }) + checks.push({ name: "Date set", ok: !!event.start_date, detail: event.start_date || "missing", hint: `iris events update ${eid} --date=YYYY-MM-DD`, category: "Details" }) + checks.push({ name: "Time set", ok: !!event.start_time, detail: event.start_time || "missing", category: "Details" }) + checks.push({ name: "Venue", ok: !!event.venue_name, detail: event.venue_name || "missing", hint: `iris events update ${eid} --venue="..."`, category: "Details" }) + checks.push({ name: "Address", ok: !!(event.city && event.state), detail: event.city ? `${event.city}, ${event.state}` : "missing", category: "Details" }) + checks.push({ name: "Photo/banner", ok: !!event.photo, detail: event.photo ? "set" : "missing", category: "Details" }) + + // ── Stages & Lineup ── + checks.push({ name: "Stages defined", ok: stages.length > 0, detail: `${stages.length} stage(s)`, hint: `iris events stage-create ${eid}`, category: "Stages & Lineup" }) + const stagesWithSetTimes = stages.filter((s: any) => (s.set_times?.length || s.event_stage_set_times?.length || 0) > 0) + checks.push({ name: "Performers scheduled", ok: stagesWithSetTimes.length > 0 || stages.length === 0, detail: `${stagesWithSetTimes.length}/${stages.length} stages have lineup`, category: "Stages & Lineup" }) + + // ── Tickets & Sales ── + checks.push({ name: "Tickets created", ok: tickets.length > 0, detail: `${tickets.length} tier(s)`, hint: "create ticket tiers", category: "Tickets & Sales" }) + const priced = tickets.filter((t: any) => t.price && parseFloat(t.price) > 0) + checks.push({ name: "All priced", ok: priced.length === tickets.length && tickets.length > 0, detail: priced.length > 0 ? priced.map((t: any) => `${t.title}: $${t.price}`).join(", ") : "none", category: "Tickets & Sales" }) + const active = tickets.filter((t: any) => t.status === "active") + checks.push({ name: "Tickets active", ok: active.length > 0, detail: `${active.length}/${tickets.length} active`, category: "Tickets & Sales" }) + const withCheckout = tickets.filter((t: any) => t.checkout_url) + checks.push({ name: "Checkout URLs", ok: withCheckout.length === tickets.length && tickets.length > 0, detail: `${withCheckout.length}/${tickets.length} have URLs`, hint: `iris events ticket-checkout ${eid}`, category: "Tickets & Sales" }) + const withQR = tickets.filter((t: any) => t.qr_url) + checks.push({ name: "QR codes", ok: withQR.length > 0, detail: `${withQR.length}/${tickets.length} have QR`, hint: "QR auto-generated from checkout_url", category: "Tickets & Sales" }) + + // ── Vendors & Partners ── + checks.push({ name: "Vendors listed", ok: vendors.length > 0, detail: `${vendors.length} vendor(s)`, hint: `iris events vendor-create ${eid}`, category: "Vendors & Partners" }) + + // ── Content & Marketing ── + const slug = event.slug || event.title?.toLowerCase().replace(/[^a-z0-9]+/g, "-") + if (slug) { + try { + const pageRes = await fetch(`https://heyiris.io/p/${slug}`, { method: "HEAD", signal: AbortSignal.timeout(5000) }) + checks.push({ name: "Landing page", ok: pageRes.status === 200, detail: pageRes.status === 200 ? `heyiris.io/p/${slug}` : "not found", hint: `iris pages create --slug=${slug}`, category: "Content" }) + } catch { + checks.push({ name: "Landing page", ok: false, hint: `iris pages create --slug=${slug}`, category: "Content" }) + } + } + checks.push({ name: "Ticket URL on event", ok: !!event.purchase_ticket_url, detail: event.purchase_ticket_url ? "set" : "missing", category: "Content" }) + + // ── Render ── + if (args.json) { + console.log(JSON.stringify(checks, null, 2)) + prompts.outro("Done") + return + } + + const categories = [...new Set(checks.map(c => c.category))] + const passing = checks.filter(c => c.ok).length + const total = checks.length + const pct = Math.round((passing / total) * 100) + + for (const cat of categories) { + const catChecks = checks.filter(c => c.category === cat) + const catPass = catChecks.filter(c => c.ok).length + console.log() + console.log(` ${bold(cat)} ${dim(`(${catPass}/${catChecks.length})`)}`) + for (const c of catChecks) { + const icon = c.ok ? success("✓") : `${UI.Style.TEXT_DANGER}✗${UI.Style.TEXT_NORMAL}` + const detail = c.detail ? dim(` ${c.detail}`) : "" + const hint = (!c.ok && c.hint) ? ` ${dim(`→ ${c.hint}`)}` : "" + console.log(` ${icon} ${c.name.padEnd(22)}${detail}${hint}`) + } + } + + printDivider() + const color = pct >= 80 ? success : pct >= 50 ? (s: string) => `${UI.Style.TEXT_WARNING}${s}${UI.Style.TEXT_NORMAL}` : (s: string) => `${UI.Style.TEXT_DANGER}${s}${UI.Style.TEXT_NORMAL}` + console.log(` Completeness: ${color(`${pct}%`)} (${passing}/${total})`) + + if (pct < 100) process.exitCode = 1 + prompts.outro("Done") + }, +}) + // ============================================================================ // Root command // ============================================================================ export const PlatformEventsCommand = cmd({ command: "events", - describe: "manage events, stages, vendors, tickets — pull, push, diff, CRUD", + describe: "manage events, stages, vendors, tickets — pull, push, diff, CRUD, preflight, audit", builder: (yargs) => yargs .command(ListCommand) @@ -1316,6 +1666,10 @@ export const PlatformEventsCommand = cmd({ .command(TicketsPushCommand) .command(TicketsDiffCommand) .command(TicketCheckoutCommand) + // Production QA + .command(PreflightCommand) + .command(AuditCommand) + .command(ProductionCommand) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-imessage.ts b/packages/opencode/src/cli/cmd/platform-imessage.ts index 1eb1d6327822..14fb90c86c99 100644 --- a/packages/opencode/src/cli/cmd/platform-imessage.ts +++ b/packages/opencode/src/cli/cmd/platform-imessage.ts @@ -3,19 +3,8 @@ import * as prompts from "@clack/prompts" import { UI } from "../ui" import { printDivider, dim, bold, success } from "./iris-api" import { execSync } from "child_process" -import { existsSync } from "fs" - -// macOS iMessage integration — reads directly from ~/Library/Messages/chat.db (SQLite) -// No bridge dependency needed — just macOS + Full Disk Access permission - -const MESSAGES_DB = `${process.env.HOME}/Library/Messages/chat.db` - -function queryMessages(sql: string): string { - return execSync(`sqlite3 "${MESSAGES_DB}" "${sql.replace(/"/g, '\\"')}"`, { - encoding: "utf-8", - timeout: 10000, - }).trim() -} +import { isAvailable, query as queryMessages, normalizeHandle, getContactCards } from "../lib/imessage" +import { resolveContactName, resolveContactNames } from "../lib/contacts" const ImessageSearchCommand = cmd({ command: "search ", @@ -24,35 +13,63 @@ const ImessageSearchCommand = cmd({ builder: (yargs) => yargs .positional("query", { type: "string", demandOption: true, describe: "phone number (last 10 digits) or chat identifier" }) - .option("days", { type: "number", default: 7, describe: "search last N days" }) + .option("days", { type: "number", default: 30, describe: "search last N days" }) + .option("since", { type: "string", describe: "search from date (YYYY-MM-DD)" }) .option("limit", { type: "number", default: 50, describe: "max messages" }) .option("json", { type: "boolean", default: false }), async handler(args) { UI.empty() prompts.intro(`◈ iMessage Search — "${args.query}"`) - if (process.platform !== "darwin") { - prompts.log.error("iMessage is only available on macOS") - prompts.outro("Done") - return - } - - if (!existsSync(MESSAGES_DB)) { - prompts.log.error(`Messages database not found at ${MESSAGES_DB}. Grant Full Disk Access in System Settings.`) + if (!isAvailable()) { + prompts.log.error("iMessage not available. Requires macOS + Full Disk Access in System Settings.") prompts.outro("Done") return } // Normalize phone number — strip everything except digits const digits = args.query.replace(/\D/g, "") - const isPhone = digits.length >= 7 + let isPhone = digits.length >= 7 + let normalized = isPhone ? normalizeHandle(args.query) : args.query + + // If not a phone number, try to resolve as lead name → phone or email (#58890) + if (!isPhone) { + try { + const { irisFetch: _fetch } = await import("./iris-api") + const leadRes = await _fetch(`/api/v1/leads?search=${encodeURIComponent(args.query)}&per_page=5`) + if (leadRes.ok) { + const leadData = (await leadRes.json()) as any + const leads = leadData?.data?.data ?? leadData?.data ?? [] + if (Array.isArray(leads)) { + // Try phone first, then email as iMessage handle + const withPhone = leads.find((l: any) => l.phone) + const withEmail = leads.find((l: any) => l.email) + if (withPhone) { + const resolvedDigits = withPhone.phone.replace(/\D/g, "") + if (resolvedDigits.length >= 7) { + normalized = normalizeHandle(withPhone.phone) + isPhone = true + prompts.log.info(`Resolved "${args.query}" → ${withPhone.name || "?"} (${withPhone.phone})`) + } + } else if (withEmail) { + // iMessage can use email as Apple ID handle + normalized = withEmail.email + prompts.log.info(`Resolved "${args.query}" → ${withEmail.name || "?"} (${withEmail.email})`) + } + } + } + } catch {} + } - // Build WHERE clause — match chat_identifier by phone digits or name + // Build WHERE clause — match chat_identifier by phone digits or email const whereClause = isPhone - ? `c.chat_identifier LIKE '%${digits.slice(-10)}%'` + ? `c.chat_identifier LIKE '%${normalized}%'` : `c.chat_identifier LIKE '%${args.query.replace(/'/g, "''")}%'` - const cutoffSeconds = args.days * 86400 + // --since takes priority over --days (#58884) + const cutoffSeconds = args.since + ? Math.max(0, Math.floor((Date.now() - new Date(String(args.since)).getTime()) / 1000)) + : (args.days as number) * 86400 const sql = ` SELECT m.rowid, @@ -89,11 +106,14 @@ const ImessageSearchCommand = cmd({ return } + // Resolve contact name from leads (#58888) + const contactName = await resolveContactName(digits || String(args.query)) ?? "Them" + // Display in chronological order (oldest first) const reversed = [...messages].reverse() printDivider() for (const msg of reversed) { - const direction = msg.from_me ? bold("You →") : bold("← Them") + const direction = msg.from_me ? bold("You →") : bold(`← ${contactName}`) const dateStr = dim(msg.date) console.log(` ${dateStr} ${direction} ${msg.text}`) } @@ -123,19 +143,23 @@ const ImessageReadCommand = cmd({ UI.empty() prompts.intro(`◈ iMessage Read — "${args.query}"`) - if (process.platform !== "darwin" || !existsSync(MESSAGES_DB)) { - prompts.log.error("iMessage database not available") + if (!isAvailable()) { + prompts.log.error("iMessage not available. Requires macOS + Full Disk Access.") prompts.outro("Done") return } const digits = args.query.replace(/\D/g, "") const isPhone = digits.length >= 7 + const normalized = isPhone ? normalizeHandle(args.query) : args.query const whereClause = isPhone - ? `c.chat_identifier LIKE '%${digits.slice(-10)}%'` + ? `c.chat_identifier LIKE '%${normalized}%'` : `c.chat_identifier LIKE '%${args.query.replace(/'/g, "''")}%'` - const cutoffSeconds = args.days * 86400 + // --since takes priority over --days (#58884) + const cutoffSeconds = args.since + ? Math.max(0, Math.floor((Date.now() - new Date(String(args.since)).getTime()) / 1000)) + : (args.days as number) * 86400 const sql = ` SELECT m.rowid, @@ -194,20 +218,24 @@ const ImessageChatsCommand = cmd({ describe: "list recent iMessage conversations", builder: (yargs) => yargs - .option("days", { type: "number", default: 7, describe: "recent conversations in last N days" }) + .option("days", { type: "number", default: 30, describe: "recent conversations in last N days" }) + .option("since", { type: "string", describe: "from date (YYYY-MM-DD)" }) .option("limit", { type: "number", default: 50, describe: "max conversations" }) .option("json", { type: "boolean", default: false }), async handler(args) { UI.empty() prompts.intro("◈ Recent iMessage Chats") - if (process.platform !== "darwin" || !existsSync(MESSAGES_DB)) { - prompts.log.error("iMessage database not available") + if (!isAvailable()) { + prompts.log.error("iMessage not available. Requires macOS + Full Disk Access.") prompts.outro("Done") return } - const cutoffSeconds = args.days * 86400 + // --since takes priority over --days (#58884) + const cutoffSeconds = args.since + ? Math.max(0, Math.floor((Date.now() - new Date(String(args.since)).getTime()) / 1000)) + : (args.days as number) * 86400 const sql = ` SELECT c.chat_identifier, @@ -241,9 +269,15 @@ const ImessageChatsCommand = cmd({ return } + // Resolve phone numbers → lead names in bulk (#58888) + const phones = chats.filter(c => /^\+?\d{10,}$/.test(c.identifier.replace(/[^+\d]/g, ""))) + const phoneMap = await resolveContactNames(phones.map(c => c.identifier)) + printDivider() for (const chat of chats) { - console.log(` ${bold(chat.identifier)} ${dim(`${chat.message_count} msgs`)} ${dim(chat.last_message)}`) + const name = phoneMap.get(chat.identifier) + const label = name ? `${bold(name)} ${dim(chat.identifier)}` : bold(chat.identifier) + console.log(` ${label} ${dim(`${chat.message_count} msgs`)} ${dim(chat.last_message)}`) } printDivider() prompts.outro(`${success("✓")} ${chats.length} conversation${chats.length === 1 ? "" : "s"}`) @@ -309,6 +343,63 @@ end tell` }, }) +const ImessageContactsCommand = cmd({ + command: "contacts", + aliases: ["vcards", "cards"], + describe: "list contact cards (vCards) shared via iMessage", + builder: (yargs) => + yargs + .option("days", { type: "number", default: 90, describe: "look back N days" }) + .option("chat", { type: "string", describe: "filter by chat/phone number" }) + .option("limit", { type: "number", default: 20 }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ iMessage Contact Cards") + + if (!isAvailable()) { + prompts.log.error("iMessage not available. Requires macOS + Full Disk Access.") + prompts.outro("Done") + return + } + + const sp = prompts.spinner() + sp.start("Scanning attachments…") + + const cards = getContactCards({ + days: args.days as number, + limit: args.limit as number, + chat: args.chat as string | undefined, + }) + + sp.stop(`${cards.length} contact card(s)`) + + if (args.json) { + console.log(JSON.stringify(cards, null, 2)) + prompts.outro("Done") + return + } + + if (cards.length === 0) { + prompts.log.info("No contact cards found in recent messages") + prompts.outro("Done") + return + } + + printDivider() + for (const card of cards) { + console.log(` ${bold(card.full_name)} ${dim(card.date)}`) + if (card.phones.length > 0) console.log(` ${dim("Phone:")} ${card.phones.join(", ")}`) + if (card.emails.length > 0) console.log(` ${dim("Email:")} ${card.emails.join(", ")}`) + if (card.company) console.log(` ${dim("Org:")} ${card.company}`) + console.log(` ${dim("From:")} ${card.sent_by}`) + console.log() + } + printDivider() + prompts.outro(dim("iris leads create --name \"...\" --phone \"...\" --email \"...\"")) + }, +}) + export const PlatformImessageCommand = cmd({ command: "imessage", aliases: ["sms", "messages"], @@ -319,6 +410,7 @@ export const PlatformImessageCommand = cmd({ .command(ImessageReadCommand) .command(ImessageChatsCommand) .command(ImessageSendCommand) + .command(ImessageContactsCommand) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-leads.ts b/packages/opencode/src/cli/cmd/platform-leads.ts index d967e76e604f..fe4a46c4ccbc 100644 --- a/packages/opencode/src/cli/cmd/platform-leads.ts +++ b/packages/opencode/src/cli/cmd/platform-leads.ts @@ -535,7 +535,8 @@ const LeadsCreateCommand = cmd({ builder: (yargs) => yargs .option("name", { describe: "lead name", type: "string" }) - .option("email", { describe: "email address", type: "string" }) + .option("email", { describe: "primary email address", type: "string" }) + .option("emails", { describe: "additional emails (comma-separated)", type: "string" }) .option("phone", { describe: "phone number", type: "string" }) .option("company", { describe: "company name", type: "string" }) .option("source", { describe: "lead source (e.g. referral, inbound, outreach)", type: "string" }) @@ -596,6 +597,13 @@ const LeadsCreateCommand = cmd({ if (args.company) payload.company = args.company if (args.source) payload.source = args.source if (args.status) payload.status = args.status + // Store additional emails in contact_info.emails array + if (args.emails) { + const extras = String(args.emails).split(",").map((e: string) => e.trim()).filter(Boolean) + if (extras.length > 0) { + payload.contact_info = { emails: extras } + } + } const res = await irisFetch("/api/v1/leads", { method: "POST", @@ -612,6 +620,7 @@ const LeadsCreateCommand = cmd({ printKV("ID", l.id) printKV("Name", l.name) printKV("Email", l.email ?? dim("none")) + if (args.emails) printKV("Alt Emails", String(args.emails)) printKV("Company", l.company ?? dim("none")) printKV("Source", l.source ?? args.source ?? dim("none")) printKV("Status", l.status) @@ -1325,17 +1334,13 @@ export async function runChannelHealthChecks(): Promise { // iMessage — verify macOS Messages.app SQLite access (async (): Promise => { try { - const { execSync } = await import("child_process") - const { homedir } = await import("os") - const db = `${homedir()}/Library/Messages/chat.db` - execSync(`sqlite3 "${db}" "SELECT count(*) FROM message LIMIT 1"`, { encoding: "utf-8", timeout: 3000 }) - return { name: "iMessage", ok: true, status: "verified" } - } catch (e: any) { - const msg = e?.message ?? "" - if (msg.includes("not authorized") || msg.includes("permission denied")) { - return { name: "iMessage", ok: false, status: "no_permission", error: "Full Disk Access required", hint: "System Settings → Privacy → Full Disk Access → enable terminal" } + const { isAvailable } = await import("../lib/imessage") + if (isAvailable()) { + return { name: "iMessage", ok: true, status: "verified" } } - return { name: "iMessage", ok: false, status: "error", error: "SQLite access failed", hint: "check macOS Messages.app" } + return { name: "iMessage", ok: false, status: "no_permission", error: "Full Disk Access required", hint: "System Settings → Privacy → Full Disk Access → enable terminal" } + } catch { + return { name: "iMessage", ok: false, status: "error", error: "check failed", hint: "check macOS Messages.app" } } })(), diff --git a/packages/opencode/src/cli/cmd/platform-obs.ts b/packages/opencode/src/cli/cmd/platform-obs.ts new file mode 100644 index 000000000000..ebd096181a9a --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-obs.ts @@ -0,0 +1,453 @@ +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { UI } from "../ui" +import { dim, bold, success, highlight } from "./iris-api" + +// ============================================================================ +// OBS Studio CLI — control OBS via WebSocket through the IRIS bridge +// +// Bridge endpoints: /api/obs/* on localhost:3200 +// OBS WebSocket: obs-websocket v5 on localhost:4455 +// ============================================================================ + +const BRIDGE = "http://localhost:3200" + +async function obsFetch(path: string, method = "GET", body?: any): Promise { + const res = await fetch(`${BRIDGE}${path}`, { + method, + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(10000), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })) + throw new Error(err.error || `HTTP ${res.status}`) + } + return res.json() +} + +function printDivider() { console.log(dim(" " + "─".repeat(60))) } + +// ── connect ── + +const ConnectCmd = cmd({ + command: "connect [url]", + describe: "connect to OBS WebSocket (default: ws://localhost:4455)", + builder: (y) => + y + .positional("url", { type: "string", default: "ws://localhost:4455" }) + .option("password", { type: "string", describe: "OBS WebSocket password" }), + async handler(args) { + UI.empty() + prompts.intro("◈ OBS Connect") + const sp = prompts.spinner() + sp.start(`Connecting to ${args.url}…`) + try { + const result = await obsFetch("/api/providers/obs", "POST", { + ws_url: args.url, + password: args.password ?? undefined, + }) + sp.stop(success(`Connected to ${result.host}`)) + } catch (e: any) { + sp.stop("Failed") + prompts.log.error(e.message) + prompts.log.info(dim("Make sure OBS is running with WebSocket Server enabled")) + prompts.log.info(dim("OBS → Tools → WebSocket Server Settings → Enable")) + } + prompts.outro("Done") + }, +}) + +// ── disconnect ── + +const DisconnectCmd = cmd({ + command: "disconnect", + describe: "disconnect from OBS", + async handler() { + UI.empty() + prompts.intro("◈ OBS Disconnect") + try { + await obsFetch("/api/providers/obs", "DELETE") + prompts.log.success("Disconnected from OBS") + } catch (e: any) { + prompts.log.error(e.message) + } + prompts.outro("Done") + }, +}) + +// ── scenes ── + +const ScenesCmd = cmd({ + command: "scenes", + aliases: ["ls"], + describe: "list available OBS scenes", + async handler() { + UI.empty() + prompts.intro("◈ OBS Scenes") + try { + const data = await obsFetch("/api/obs/scenes") + printDivider() + for (const s of data.scenes || []) { + const current = s.name === data.current ? success(" ● LIVE") : "" + console.log(` ${highlight(s.name)}${current}`) + } + printDivider() + if (data.current) { + console.log(` ${dim("Current:")} ${bold(data.current)}`) + } + } catch (e: any) { + prompts.log.error(e.message) + } + prompts.outro("Done") + }, +}) + +// ── scene ── + +const SceneCmd = cmd({ + command: "scene ", + aliases: ["switch"], + describe: "switch to a scene", + builder: (y) => y.positional("name", { type: "string", demandOption: true }), + async handler(args) { + UI.empty() + const sp = prompts.spinner() + sp.start(`Switching to "${args.name}"…`) + try { + await obsFetch("/api/obs/scene", "POST", { scene_name: args.name }) + sp.stop(success(`Scene: ${args.name}`)) + } catch (e: any) { + sp.stop("Failed") + prompts.log.error(e.message) + } + }, +}) + +// ── stream start|stop|status ── + +const StreamCmd = cmd({ + command: "stream ", + describe: "control streaming (start|stop|status)", + builder: (y) => y.positional("action", { type: "string", choices: ["start", "stop", "status"], demandOption: true }), + async handler(args) { + UI.empty() + if (args.action === "status") { + try { + const data = await obsFetch("/api/obs/stream/status") + prompts.intro("◈ Stream Status") + console.log(` ${bold("Active:")} ${data.active ? success("LIVE") : dim("offline")}`) + if (data.timecode) console.log(` ${bold("Timecode:")} ${data.timecode}`) + if (data.bytes) console.log(` ${bold("Sent:")} ${(data.bytes / 1024 / 1024).toFixed(1)} MB`) + if (data.skippedFrames) console.log(` ${bold("Dropped:")} ${data.skippedFrames}/${data.totalFrames} frames`) + } catch (e: any) { + prompts.log.error(e.message) + } + } else { + const sp = prompts.spinner() + sp.start(`${args.action === "start" ? "Starting" : "Stopping"} stream…`) + try { + await obsFetch(`/api/obs/stream/${args.action}`, "POST") + sp.stop(success(`Stream ${args.action === "start" ? "started" : "stopped"}`)) + } catch (e: any) { + sp.stop("Failed") + prompts.log.error(e.message) + } + } + }, +}) + +// ── record start|stop|status ── + +const RecordCmd = cmd({ + command: "record ", + aliases: ["rec"], + describe: "control recording (start|stop|status)", + builder: (y) => y.positional("action", { type: "string", choices: ["start", "stop", "status"], demandOption: true }), + async handler(args) { + UI.empty() + if (args.action === "status") { + try { + const data = await obsFetch("/api/obs/record/status") + prompts.intro("◈ Recording Status") + console.log(` ${bold("Active:")} ${data.active ? success("RECORDING") : dim("stopped")}`) + if (data.paused) console.log(` ${bold("Paused:")} yes`) + if (data.timecode) console.log(` ${bold("Timecode:")} ${data.timecode}`) + } catch (e: any) { + prompts.log.error(e.message) + } + } else { + const sp = prompts.spinner() + sp.start(`${args.action === "start" ? "Starting" : "Stopping"} recording…`) + try { + const result = await obsFetch(`/api/obs/record/${args.action}`, "POST") + sp.stop(success(`Recording ${args.action === "start" ? "started" : "stopped"}`)) + if (result.outputPath) console.log(` ${dim("File:")} ${result.outputPath}`) + } catch (e: any) { + sp.stop("Failed") + prompts.log.error(e.message) + } + } + }, +}) + +// ── marker ── + +const MarkerCmd = cmd({ + command: "marker [description]", + aliases: ["mark"], + describe: "create a stream/recording marker (for highlights)", + builder: (y) => y.positional("description", { type: "string", default: "Marker" }), + async handler(args) { + UI.empty() + try { + const result = await obsFetch("/api/obs/marker", "POST", { description: args.description }) + const m = result.marker + console.log(` ${success("●")} Marker at ${bold(m.timecode)} — ${m.description}`) + } catch (e: any) { + prompts.log.error(e.message) + } + }, +}) + +// ── mute ── + +const MuteCmd = cmd({ + command: "mute ", + describe: "toggle mute on an audio input", + builder: (y) => + y + .positional("input", { type: "string", demandOption: true }) + .option("unmute", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + try { + const result = await obsFetch("/api/obs/audio/mute", "POST", { + input: args.input, + muted: !args.unmute, + }) + console.log(` ${result.muted ? dim("🔇 Muted") : success("🔊 Unmuted")}: ${bold(args.input as string)}`) + } catch (e: any) { + prompts.log.error(e.message) + } + }, +}) + +// ── inputs ── + +const InputsCmd = cmd({ + command: "inputs", + aliases: ["sources"], + describe: "list audio/video inputs", + async handler() { + UI.empty() + prompts.intro("◈ OBS Inputs") + try { + const inputs = await obsFetch("/api/obs/inputs") + printDivider() + for (const i of inputs) { + console.log(` ${highlight(i.name)} ${dim(i.kind || "")}`) + } + printDivider() + } catch (e: any) { + prompts.log.error(e.message) + } + prompts.outro("Done") + }, +}) + +// ── status ── + +const StatusCmd = cmd({ + command: "status", + describe: "full OBS status (connection + stream + recording)", + async handler() { + UI.empty() + prompts.intro("◈ OBS Status") + try { + const health = await fetch(`${BRIDGE}/health`, { signal: AbortSignal.timeout(3000) }).then(r => r.json()) + const obs = health?.messaging?.obs ?? health?.obs ?? { status: "stopped" } + console.log(` ${bold("Connection:")} ${obs.status === "running" ? success("connected") : dim("disconnected")}`) + if (obs.host) console.log(` ${bold("Host:")} ${dim(obs.host)}`) + + if (obs.status === "running") { + try { + const stream = await obsFetch("/api/obs/stream/status") + console.log(` ${bold("Stream:")} ${stream.active ? success("LIVE") : dim("offline")}`) + if (stream.timecode) console.log(` ${bold("Uptime:")} ${stream.timecode}`) + } catch {} + try { + const rec = await obsFetch("/api/obs/record/status") + console.log(` ${bold("Recording:")} ${rec.active ? success("RECORDING") : dim("stopped")}`) + } catch {} + try { + const scenes = await obsFetch("/api/obs/scenes") + console.log(` ${bold("Scene:")} ${highlight(scenes.current || "?")}`) + } catch {} + } + } catch (e: any) { + prompts.log.error(`Bridge not running: ${e.message}`) + } + prompts.outro("Done") + }, +}) + +// ── dashboard ── + +const DashboardCmd = cmd({ + command: "dashboard [event-id]", + aliases: ["dash", "ui", "open"], + describe: "open the production dashboard in your browser", + builder: (y) => + y + .positional("event-id", { type: "number", describe: "event ID for timeline" }) + .option("phone", { type: "boolean", default: false, describe: "show the local network URL (same WiFi)" }) + .option("public", { type: "boolean", default: false, describe: "show the public ngrok URL (works anywhere)" }) + .option("share", { type: "string", describe: "send the URL to a phone number or email via iMessage" }), + async handler(args) { + const eventId = args["event-id"] + const qs = eventId ? `?event=${eventId}` : "" + + // Detect all available URLs + const urls: { local: string; phone?: string; public?: string } = { + local: `${BRIDGE}/obs-dashboard${qs}`, + } + + // Local network IP + try { + const { networkInterfaces } = await import("os") + const nets = networkInterfaces() + for (const name of Object.keys(nets)) { + for (const net of nets[name] ?? []) { + if (net.family === "IPv4" && !net.internal) { + urls.phone = `http://${net.address}:3200/obs-dashboard${qs}` + break + } + } + if (urls.phone) break + } + } catch {} + + // Ngrok public URL + try { + const res = await fetch("http://localhost:4040/api/tunnels", { signal: AbortSignal.timeout(2000) }) + if (res.ok) { + const data = (await res.json()) as any + const tunnel = (data.tunnels ?? []).find((t: any) => t.public_url?.startsWith("https")) + if (tunnel) { + urls.public = `${tunnel.public_url}/obs-dashboard${qs}` + } + } + } catch {} + + // --public: just print the public URL + if (args.public) { + if (urls.public) { + console.log() + console.log(` ${bold("Public URL:")} ${highlight(urls.public)}`) + console.log(` ${dim("Works from anywhere — share with anyone")}`) + } else { + console.log(` ${dim("No ngrok tunnel detected. Start one:")} ngrok http 3200`) + } + console.log() + return + } + + // --phone: just print the LAN URL + if (args.phone) { + if (urls.phone) { + console.log() + console.log(` ${bold("Phone URL:")} ${highlight(urls.phone)}`) + console.log(` ${dim("Open on your phone (same WiFi)")}`) + } else { + console.log(` ${dim("Could not detect local IP")}`) + } + console.log() + return + } + + // --share: send via iMessage + if (args.share) { + const shareUrl = urls.public || urls.phone || urls.local + try { + const { execSync } = await import("child_process") + const handle = String(args.share) + const msg = `🎬 Stream Control Dashboard — open this link:\n\n${shareUrl}\n\nTap scenes to switch cameras. Timeline tab for run-of-show.` + execSync(`osascript -e 'tell application "Messages" to send "${msg.replace(/"/g, '\\"')}" to participant "${handle}" of (1st account whose service type = iMessage)'`, { timeout: 10000 }) + console.log(` ${success("✓")} Sent to ${handle}`) + } catch (e: any) { + console.log(` ${dim("Failed to send:")} ${e.message?.slice(0, 80)}`) + console.log(` ${bold("URL:")} ${highlight(shareUrl)}`) + } + return + } + + // Auto-start ngrok if not running and ngrok is installed + if (!urls.public) { + try { + const { execSync, spawn } = await import("child_process") + const ngrokPath = execSync("which ngrok", { encoding: "utf-8" }).trim() + if (ngrokPath) { + const sp2 = prompts.spinner() + sp2.start("Starting ngrok tunnel…") + spawn(ngrokPath, ["http", "3200"], { detached: true, stdio: "ignore" }).unref() + // Wait for tunnel to come up + for (let i = 0; i < 10; i++) { + await new Promise(r => setTimeout(r, 1000)) + try { + const res = await fetch("http://localhost:4040/api/tunnels", { signal: AbortSignal.timeout(1000) }) + if (res.ok) { + const data = (await res.json()) as any + const tunnel = (data.tunnels ?? []).find((t: any) => t.public_url?.startsWith("https")) + if (tunnel) { + urls.public = `${tunnel.public_url}/obs-dashboard${qs}` + break + } + } + } catch {} + } + sp2.stop(urls.public ? success("Tunnel ready") : "Tunnel failed") + } + } catch {} + } + + // Show all URLs + console.log() + console.log(` ${bold("Local:")} ${dim(urls.local)}`) + if (urls.phone) console.log(` ${bold("Phone:")} ${highlight(urls.phone)} ${dim("(same WiFi)")}`) + if (urls.public) console.log(` ${bold("Public:")} ${success(urls.public)} ${dim("(works anywhere)")}`) + console.log() + + // Open best available URL — prefer public > phone > local + const openUrl = urls.public || urls.phone || urls.local + try { + const { exec } = await import("child_process") + exec(`open "${openUrl}"`) + console.log(` ${success("✓")} Opened in browser`) + } catch {} + }, +}) + +// ============================================================================ +// Root +// ============================================================================ + +export const PlatformObsCommand = cmd({ + command: "obs", + describe: "control OBS Studio — scenes, streaming, recording, markers, audio, dashboard", + builder: (y) => + y + .command(ConnectCmd) + .command(DisconnectCmd) + .command(ScenesCmd) + .command(SceneCmd) + .command(StreamCmd) + .command(RecordCmd) + .command(MarkerCmd) + .command(MuteCmd) + .command(InputsCmd) + .command(StatusCmd) + .command(DashboardCmd) + .demandCommand(1, "specify: connect, scenes, scene, stream, record, marker, mute, inputs, status, dashboard"), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/platform-run.ts b/packages/opencode/src/cli/cmd/platform-run.ts index 7304c0b13549..e759f308f6bc 100644 --- a/packages/opencode/src/cli/cmd/platform-run.ts +++ b/packages/opencode/src/cli/cmd/platform-run.ts @@ -61,6 +61,8 @@ const INTEGRATION_FUNCTIONS: Record() + +/** + * Normalize a phone/email/handle for search. + * Handles E.164 (+14695633672), parens ((469) 563-3672), and raw digits. + */ +function normalizeForSearch(identifier: string): string { + // Strip + prefix and all non-digits + const digits = identifier.replace(/[^0-9]/g, "") + // If it looks like a phone (7+ digits), take last 10 (strip country code) + if (digits.length >= 7) return digits.slice(-10) + return identifier +} + +/** + * Resolve a phone number, email, or handle to a lead name. + * Returns null if no match found. Results are cached per-session. + */ +export async function resolveContactName(identifier: string): Promise { + if (!identifier) return null + if (cache.has(identifier)) return cache.get(identifier) ?? null + + const search = normalizeForSearch(identifier) + + try { + const res = await irisFetch(`/api/v1/leads?search=${encodeURIComponent(search)}&per_page=1`) + if (res.ok) { + const data = (await res.json()) as any + const leads = data?.data?.data ?? data?.data ?? [] + if (Array.isArray(leads) && leads.length > 0) { + const name = leads[0].name ?? leads[0].first_name ?? null + cache.set(identifier, name) + return name + } + } + } catch {} + + cache.set(identifier, null) + return null +} + +/** + * Batch-resolve multiple identifiers to names. + * Resolves up to 10 in parallel, returns a Map of identifier → name. + */ +export async function resolveContactNames(identifiers: string[]): Promise> { + const result = new Map() + const unique = [...new Set(identifiers.filter(Boolean))] + const batch = unique.slice(0, 10) + + await Promise.allSettled( + batch.map(async (id) => { + const name = await resolveContactName(id) + if (name) result.set(id, name) + }) + ) + + return result +} + +/** + * Clear the cache (useful for testing or long-running sessions). + */ +export function clearContactCache(): void { + cache.clear() +} diff --git a/packages/opencode/src/cli/lib/imessage.ts b/packages/opencode/src/cli/lib/imessage.ts new file mode 100644 index 000000000000..e30cf108e557 --- /dev/null +++ b/packages/opencode/src/cli/lib/imessage.ts @@ -0,0 +1,217 @@ +/** + * iMessage SQLite utility — single source of truth for Messages.app access. + * + * Used by: platform-imessage.ts, platform-atlas-comms.ts, platform-customer.ts, platform-doctor.ts + */ + +import { execSync } from "child_process" +import { existsSync } from "fs" +import { homedir } from "os" + +const MESSAGES_DB = `${homedir()}/Library/Messages/chat.db` + +// ── Types ── + +export interface Message { + id: string + date: string + from_me: boolean + text: string + chat_identifier?: string +} + +export interface Chat { + identifier: string + message_count: number + last_message: string +} + +// ── Core ── + +/** + * Check if iMessage SQLite is accessible (macOS only + Full Disk Access). + */ +export function isAvailable(): boolean { + if (process.platform !== "darwin") return false + if (!existsSync(MESSAGES_DB)) return false + try { + execSync(`sqlite3 "${MESSAGES_DB}" "SELECT 1 FROM message LIMIT 1"`, { + encoding: "utf-8", + timeout: 3000, + }) + return true + } catch { + return false + } +} + +/** + * Run a raw SQL query against the Messages database. + * Escapes double quotes in the SQL string. + */ +export function query(sql: string): string { + const escaped = sql.replace(/"/g, '\\"') + return execSync(`sqlite3 "${MESSAGES_DB}" "${escaped}"`, { + encoding: "utf-8", + timeout: 10000, + }).trim() +} + +/** + * Normalize a phone/email/handle to a search-friendly format. + * Phones: strip non-digits, take last 10 digits. + */ +export function normalizeHandle(handle: string): string { + const digits = handle.replace(/[^0-9]/g, "") + if (digits.length >= 10) return digits.slice(-10) + return handle +} + +/** + * Search messages by phone number, email, or chat identifier. + */ +export function searchByHandle(handle: string, days = 30, limit = 50): Message[] { + const search = normalizeHandle(handle) + const cutoffSeconds = days * 86400 + + const sql = `SELECT + m.rowid, m.text, m.is_from_me, m.date, + datetime(m.date/1000000000 + strftime('%s','2001-01-01'), 'unixepoch', 'localtime') as sent_dt, + c.chat_identifier + FROM message m + JOIN chat_message_join cmj ON m.rowid = cmj.message_id + JOIN chat c ON cmj.chat_id = c.rowid + WHERE c.chat_identifier LIKE '%${search}%' + AND m.text IS NOT NULL AND m.text != '' + AND m.date > (strftime('%s','now') - ${cutoffSeconds} - strftime('%s','2001-01-01')) * 1000000000 + ORDER BY m.date DESC LIMIT ${limit}` + + try { + const raw = query(sql) + if (!raw) return [] + + return raw.split("\n").map((line) => { + const parts = line.split("|") + if (parts.length < 5) return null + return { + id: parts[0], + text: parts[1], + from_me: parts[2] === "1", + date: parts[4], + chat_identifier: parts[5] || search, + } satisfies Message + }).filter(Boolean) as Message[] + } catch { + return [] + } +} + +/** + * List recent conversations with message counts. + */ +export function listChats(days = 30, limit = 50): Chat[] { + const cutoffSeconds = days * 86400 + + const sql = `SELECT + c.chat_identifier, + COUNT(m.rowid) as msg_count, + MAX(datetime(m.date/1000000000 + strftime('%s','2001-01-01'), 'unixepoch', 'localtime')) as last_msg + FROM chat c + JOIN chat_message_join cmj ON c.rowid = cmj.chat_id + JOIN message m ON cmj.message_id = m.rowid + WHERE m.date > (strftime('%s','now') - ${cutoffSeconds} - strftime('%s','2001-01-01')) * 1000000000 + GROUP BY c.chat_identifier + ORDER BY last_msg DESC + LIMIT ${limit}` + + try { + const raw = query(sql) + if (!raw) return [] + + return raw.split("\n").map((line) => { + const parts = line.split("|") + if (parts.length < 3) return null + return { + identifier: parts[0], + message_count: parseInt(parts[1], 10), + last_message: parts[2], + } satisfies Chat + }).filter(Boolean) as Chat[] + } catch { + return [] + } +} + +// ── Contact Card (vCard) Support (#58893) ── + +export interface ContactCard { + filename: string + full_name: string + phones: string[] + emails: string[] + company?: string + sent_by: string + date: string + raw_vcard: string +} + +/** + * Find contact cards (vCards) shared via iMessage. + * Reads attachment metadata from SQLite + parses the .vcf files. + */ +export function getContactCards(options: { days?: number; limit?: number; chat?: string } = {}): ContactCard[] { + if (!isAvailable()) return [] + const days = options.days ?? 90 + const limit = options.limit ?? 20 + const cutoff = days * 86400 + + try { + let where = `(a.mime_type LIKE '%vcard%' OR a.uti LIKE '%vcard%' OR a.filename LIKE '%.vcf') + AND m.date/1000000000 + 978307200 > unixepoch('now') - ${cutoff}` + if (options.chat) { + where += ` AND c.chat_identifier LIKE '%${options.chat.replace(/'/g, "''")}%'` + } + + const sql = `SELECT a.filename, a.transfer_name, + datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') as msg_date, + c.chat_identifier + FROM attachment a + JOIN message_attachment_join maj ON a.ROWID = maj.attachment_id + JOIN message m ON maj.message_id = m.ROWID + JOIN chat_message_join cmj ON m.ROWID = cmj.message_id + JOIN chat c ON cmj.chat_id = c.ROWID + WHERE ${where} + ORDER BY m.date DESC LIMIT ${limit};`.replace(/\n/g, " ").trim() + + const raw = query(sql) + if (!raw) return [] + + const { readFileSync } = require("fs") + return raw.split("\n").map((line): ContactCard | null => { + const [filepath, transferName, date, chatId] = line.split("|") + if (!filepath) return null + + const fullPath = filepath.replace(/^~/, homedir()) + let rawVcard = "" + try { rawVcard = readFileSync(fullPath, "utf-8") } catch { return null } + + const getName = (vc: string) => vc.match(/^FN:(.+)$/m)?.[1]?.trim() ?? transferName?.replace(".vcf", "") ?? "Unknown" + const getPhones = (vc: string) => [...vc.matchAll(/TEL[^:]*:([+\d() -]+)/gm)].map(m => m[1].replace(/[^+\d]/g, "")) + const getEmails = (vc: string) => [...vc.matchAll(/EMAIL[^:]*:(.+)$/gm)].map(m => m[1].trim()) + const getOrg = (vc: string) => vc.match(/^ORG:(.+)$/m)?.[1]?.trim() + + return { + filename: transferName ?? filepath.split("/").pop() ?? "", + full_name: getName(rawVcard), + phones: getPhones(rawVcard), + emails: getEmails(rawVcard), + company: getOrg(rawVcard), + sent_by: chatId, + date, + raw_vcard: rawVcard, + } + }).filter(Boolean) as ContactCard[] + } catch { + return [] + } +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 9e7fbebb4710..dd93283ec712 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -70,6 +70,7 @@ import { PlatformAtlasCommsCommand } from "./cli/cmd/platform-atlas-comms" import { PlatformLeadsMeetingCommand } from "./cli/cmd/platform-leads-meeting" import { PlatformCampaignCommand } from "./cli/cmd/platform-campaign" import { PlatformDaemonCommand } from "./cli/cmd/platform-daemon" +import { PlatformObsCommand } from "./cli/cmd/platform-obs" import { PlatformDoctorCommand } from "./cli/cmd/platform-doctor" import { PlatformOnboardCommand } from "./cli/cmd/platform-onboard" import { PlatformProposalsCommand } from "./cli/cmd/platform-proposals" @@ -237,6 +238,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformCampaignCommand)) .command(reg(PlatformDaemonCommand)) .command(reg(PlatformDoctorCommand)) + .command(reg(PlatformObsCommand)) .command(reg(PlatformOnboardCommand)) .command(reg(PlatformProposalsCommand)) .command(reg(PlatformContractsCommand)) diff --git a/packages/opencode/test/cli/bug-fixes.test.ts b/packages/opencode/test/cli/bug-fixes.test.ts index 24607f554e14..54acf0d4d35d 100644 --- a/packages/opencode/test/cli/bug-fixes.test.ts +++ b/packages/opencode/test/cli/bug-fixes.test.ts @@ -385,3 +385,44 @@ describe("completeness score in source (#57686)", () => { expect(source).toContain("maskSecrets") }) }) + +// ============================================================================ +// #58778/#58779: Calendar event display — summary + start time +// ============================================================================ + +describe("calendar event display (#58778, #58779)", () => { + // Replicate displayArrayItems logic for calendar events + function getEventLabel(item: Record): string { + return String(item.name ?? item.title ?? item.summary ?? item.subject ?? item.id ?? "") + } + + function getStartTime(item: Record): string { + const rawStart = item.start + return typeof rawStart === "string" ? rawStart : ((rawStart as any)?.dateTime ?? (rawStart as any)?.date ?? "") + } + + test("uses summary field for Google Calendar events", () => { + const event = { id: "abc123", summary: "Song Wars Ep.1", start: "2026-04-18T19:00:00-05:00" } + expect(getEventLabel(event)).toBe("Song Wars Ep.1") + }) + + test("extracts start time from flat string", () => { + const event = { summary: "Test", start: "2026-04-18T09:00:00-05:00" } + expect(getStartTime(event)).toBe("2026-04-18T09:00:00-05:00") + }) + + test("extracts start time from nested object", () => { + const event = { summary: "Test", start: { dateTime: "2026-04-18T09:00:00Z" } } + expect(getStartTime(event)).toBe("2026-04-18T09:00:00Z") + }) + + test("handles missing start gracefully", () => { + const event = { summary: "Test" } + expect(getStartTime(event)).toBe("") + }) + + test("falls back to id when no summary/title/name", () => { + const event = { id: "7d4kh6431gmu" } + expect(getEventLabel(event)).toBe("7d4kh6431gmu") + }) +}) diff --git a/packages/opencode/test/platform/bug-regressions.test.ts b/packages/opencode/test/platform/bug-regressions.test.ts new file mode 100644 index 000000000000..c1fb988d0e35 --- /dev/null +++ b/packages/opencode/test/platform/bug-regressions.test.ts @@ -0,0 +1,466 @@ +/** + * Regression tests for Bloq #297 bug fixes (April 2026) + * + * These tests prevent recurrence of every bug class found during the + * client-facing QA session. Each test is tagged with its bug item ID. + * + * Categories: + * 1. Display / rendering — [object], truncated output, missing timestamps + * 2. Exit codes — non-zero on failures + * 3. --json output cleanliness — no ANSI, no spinner, no clack headers + * 4. Search / resolution — multi-word, name picker, non-interactive fallback + * 5. Channel routing — Gmail endpoint, message limits, 0-channel warning + * 6. URL extraction — shared links surfaced from messages + * 7. Endpoint contracts — correct API paths for pulse, gmail, pages + */ +import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test" +import { readFileSync } from "fs" +import { join } from "path" + +const SRC_DIR = join(import.meta.dir, "../../src/cli/cmd") +function readSource(filename: string): string { + return readFileSync(join(SRC_DIR, filename), "utf-8") +} + +// ============================================================================ +// 1. Display / rendering — #55730, #55622, #55740 +// ============================================================================ + +describe("displayResult rendering (#55730)", () => { + test("platform-run.ts: displayResult expands arrays with name/title/id", () => { + const src = readSource("platform-run.ts") + // displayArrayItems must exist and handle name, title, mimeType + expect(src).toContain("function displayArrayItems") + expect(src).toContain("item.name") + expect(src).toContain("item.title") + expect(src).toContain("item.mimeType") + }) + + test("platform-run.ts: displayResult drills into nested objects for arrays", () => { + const src = readSource("platform-run.ts") + // Must detect nested arrays inside objects (e.g. data.files) + expect(src).toContain("nestedArrays") + expect(src).toContain("Array.isArray(val)") + }) + + test("displayResult does not show raw [object] for integration responses", () => { + const src = readSource("platform-run.ts") + // The old bug: showing [object] for nested data + // The new code should not have a plain "[object]" fallback + const displayBlock = src.slice( + src.indexOf("function displayResult"), + src.indexOf("function displayResult") + 2000, + ) + expect(displayBlock).not.toContain('"[object]"') + }) + + test("displayArrayItems caps at 25 items with overflow message", () => { + const src = readSource("platform-run.ts") + expect(src).toContain("items.slice(0, 25)") + expect(src).toContain("items.length > 25") + }) +}) + +describe("Apple Mail timestamps (#55622, #55740)", () => { + test("platform-leads.ts: Apple Mail timestamp has fallback for blank dates", () => { + const src = readSource("platform-leads.ts") + // Must have a fallback when date is empty + expect(src).toContain('msg.date ?? msg.ts ?? dim("(no date)")') + }) + + test("bridge: AppleScript date field is wrapped in try/on error", () => { + const bridgePath = join(import.meta.dir, "../../../../../fl-docker-dev/coding-agent-bridge/index.js") + let bridgeSrc: string + try { + bridgeSrc = readFileSync(bridgePath, "utf-8") + } catch { + // Bridge lives outside iris-code — skip if not available + console.log(` (skipped — bridge not found at ${bridgePath})`) + return + } + // The date received getter must be inside a try block + const mailSearchBlock = bridgeSrc.slice( + bridgeSrc.indexOf("GET /api/mail/search"), + bridgeSrc.indexOf("GET /api/mail/search") + 3000, + ) + expect(mailSearchBlock).toContain("try") + expect(mailSearchBlock).toContain("set theDate to (date received of msg)") + expect(mailSearchBlock).toContain("on error") + }) +}) + +// ============================================================================ +// 2. Exit codes — #55722 +// ============================================================================ + +describe("non-zero exit codes on failure (#55722)", () => { + test("leads get: sets process.exitCode = 1 on API failure", () => { + const src = readSource("platform-leads.ts") + // The leads get handler must set exitCode on failure + const getBlock = src.slice( + src.indexOf('command: "get "'), + src.indexOf('command: "get "') + 3000, + ) + expect(getBlock).toContain("process.exitCode = 1") + }) + + test("leads pulse: sets process.exitCode = 1 on API failure", () => { + const src = readSource("platform-leads.ts") + const pulseBlock = src.slice( + src.indexOf('command: "pulse "'), + src.indexOf('command: "pulse "') + 4000, + ) + expect(pulseBlock).toContain("process.exitCode = 1") + }) + + test("leads get: exits non-zero when lead not found (null response)", () => { + const src = readSource("platform-leads.ts") + // Must check for empty lead data + expect(src).toContain("if (!l || !l.id)") + expect(src).toContain('"Lead not found"') + }) + + test("pages list: sets process.exitCode = 1 on failure", () => { + const src = readSource("platform-pages.ts") + const listBlock = src.slice( + src.indexOf('command: "list"'), + src.indexOf('command: "list"') + 1000, + ) + expect(listBlock).toContain("process.exitCode = 1") + }) +}) + +// ============================================================================ +// 3. --json output cleanliness — #55735 +// ============================================================================ + +describe("--json clean output (#55735)", () => { + test("exec handler: skips UI.empty() and prompts.intro() when --json", () => { + const src = readSource("platform-run.ts") + // The handler must gate UI chrome behind !args.json + const handlerBlock = src.slice( + src.indexOf('command: "exec '), + src.indexOf('command: "exec ') + 1000, + ) + expect(handlerBlock).toContain("if (!args.json)") + expect(handlerBlock).toContain("UI.empty()") + }) + + test("exec integration: --json path has no spinner", () => { + const src = readSource("platform-run.ts") + // The --json early return for integrations must not reference spinner + const jsonBlock = src.slice( + src.indexOf("Skip spinner/ANSI when --json"), + src.indexOf("Skip spinner/ANSI when --json") + 300, + ) + expect(jsonBlock).toContain("console.log(JSON.stringify(result, null, 2))") + expect(jsonBlock).not.toContain("spinner.start") + }) + + test("exec system tool: --json path outputs clean JSON", () => { + const src = readSource("platform-run.ts") + // Clean JSON output for tool execution + expect(src).toContain("Clean JSON output — no spinner/ANSI") + }) +}) + +// ============================================================================ +// 4. Search / resolution — #55719, #55742, leads search multi-word +// ============================================================================ + +describe("name picker non-interactive fallback (#55719, #55742)", () => { + test("leads get: auto-selects first match in non-interactive mode", () => { + const src = readSource("platform-leads.ts") + const getBlock = src.slice( + src.indexOf('command: "get "'), + src.indexOf('command: "get "') + 3000, + ) + expect(getBlock).toContain("isNonInteractive()") + }) + + test("leads pulse: auto-selects first match in non-interactive mode", () => { + const src = readSource("platform-leads.ts") + const pulseBlock = src.slice( + src.indexOf('command: "pulse "'), + src.indexOf('command: "pulse "') + 4000, + ) + expect(pulseBlock).toContain("isNonInteractive()") + }) + + test("leads notes: auto-selects first match in non-interactive mode", () => { + const src = readSource("platform-leads.ts") + const notesBlock = src.slice( + src.indexOf('command: "notes "'), + src.indexOf('command: "notes "') + 2000, + ) + expect(notesBlock).toContain("isNonInteractive()") + }) +}) + +describe("multi-word search fallback", () => { + test("leads search: splits multi-word queries when initial search returns 0", () => { + const src = readSource("platform-leads.ts") + const searchBlock = src.slice( + src.indexOf('command: "search "'), + src.indexOf('command: "search "') + 3000, + ) + // Must detect multi-word and try individual terms + expect(searchBlock).toContain('args.query.includes(" ")') + expect(searchBlock).toContain("split(/\\s+/)") + }) + + test("multi-word fallback filters results by ALL search terms", () => { + const src = readSource("platform-leads.ts") + // Must filter to only leads matching all words + expect(src).toContain("allWords.every") + }) + + test("multi-word fallback logic: matches all words case-insensitively", () => { + // Simulate the matching logic + const allWords = ["andrew", "gearhart"] + const lead = { name: "Andrew Gearhart", email: "a@test.com", company: "Acme" } + const haystack = `${lead.name} ${lead.email} ${lead.company}`.toLowerCase() + const matches = allWords.every((w) => haystack.includes(w)) + expect(matches).toBe(true) + }) + + test("multi-word fallback: skips short words (< 3 chars)", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("word.length < 3") + }) +}) + +// ============================================================================ +// 5. Channel routing — #55620, #55720, #55721, #55723, #55737, #55738 +// ============================================================================ + +describe("leads pulse Gmail routing (#55620, #55737, #55738)", () => { + test("pulse does NOT use MCP gmail/execute endpoint (was causing 422)", () => { + const src = readSource("platform-leads.ts") + const pulseBlock = src.slice( + src.indexOf('command: "pulse "'), + src.indexOf("// ====", src.indexOf('command: "pulse "') + 100), + ) + // Must NOT use the old MCP endpoint + expect(pulseBlock).not.toContain("/api/v1/mcp/gmail/execute") + }) + + test("pulse uses lead-specific Gmail threads endpoint", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("/api/v1/leads/${leadId}/gmail-threads") + }) + + test("pulse Gmail filters results by lead email (#55723, #55743)", () => { + const src = readSource("platform-leads.ts") + // Must have client-side email matching filter + expect(src).toContain("email.toLowerCase()") + expect(src).toContain("fromLower.includes") + }) +}) + +describe("message limits (#55620, #55621, #55720, #55739)", () => { + test("iMessage search: default limit is 50 (not 20)", () => { + const src = readSource("platform-imessage.ts") + const searchBlock = src.slice( + src.indexOf('command: "search "'), + src.indexOf('command: "search "') + 500, + ) + // Default should be 50 + expect(searchBlock).toContain("default: 50") + expect(searchBlock).not.toMatch(/default:\s*20/) + }) + + test("iMessage chats: default limit is 50 (not 20)", () => { + const src = readSource("platform-imessage.ts") + const chatsBlock = src.slice( + src.indexOf('command: "chats"'), + src.indexOf('command: "chats"') + 500, + ) + expect(chatsBlock).toContain("default: 50") + }) + + test("pulse Gmail: no artificial Math.min cap on results", () => { + const src = readSource("platform-leads.ts") + const pulseBlock = src.slice( + src.indexOf("Gmail threads endpoint"), + src.indexOf("Gmail threads endpoint") + 500, + ) + // Must NOT have Math.min(msgLimit, 20) + expect(pulseBlock).not.toContain("Math.min(msgLimit, 20)") + }) + + test("pulse Apple Mail: no artificial Math.min cap", () => { + const src = readSource("platform-leads.ts") + const pulseBlock = src.slice( + src.indexOf('command: "pulse "'), + src.indexOf("// ====", src.indexOf('command: "pulse "') + 100), + ) + // Must NOT have the old Math.min(msgLimit, 100) + expect(pulseBlock).not.toContain("Math.min(msgLimit, 100)") + }) +}) + +describe("zero channels warning (#55721)", () => { + test("pulse warns when lead has no email AND no phone", () => { + const src = readSource("platform-leads.ts") + // Must have explicit check + expect(src).toContain("!email && !phone") + expect(src).toContain("No channels available") + expect(src).toContain("no email or phone") + }) +}) + +// ============================================================================ +// 6. URL extraction — #55733, #55741 +// ============================================================================ + +describe("shared links extraction (#55733, #55741)", () => { + test("pulse extracts URLs from channel messages", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("Shared Links") + expect(src).toContain("urlRegex") + }) + + test("URL regex matches http and https links", () => { + const urlRegex = /https?:\/\/[^\s<>"')\]]+/g + const text = "Check https://docs.google.com/spreadsheets/d/abc123 and http://example.com" + const matches = text.match(urlRegex) + expect(matches).toBeTruthy() + expect(matches!.length).toBe(2) + expect(matches![0]).toContain("docs.google.com") + }) + + test("URL extraction deduplicates links", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("sharedLinks.some((l) => l.url === url)") + }) + + test("URL extraction includes channel source", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("channel: ch.name") + }) +}) + +// ============================================================================ +// 7. Subcommand completeness — leads notes, integrations list-connected +// ============================================================================ + +describe("leads notes subcommand", () => { + test("LeadsNotesCommand exists with 'notes ' command", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain('command: "notes "') + }) + + test("notes command is registered in parent leads command", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain(".command(LeadsNotesCommand)") + }) + + test("notes command accepts name or ID (not just numeric)", () => { + const src = readSource("platform-leads.ts") + const notesBlock = src.slice( + src.indexOf('command: "notes "'), + src.indexOf('command: "notes "') + 2000, + ) + expect(notesBlock).toContain("isNaN(leadId)") + }) +}) + +describe("integrations list-connected status honesty (#55734)", () => { + test("list-connected includes disclaimer about [active] meaning", () => { + const src = readSource("platform-run.ts") + // Verify list-connected renders status labels + expect(src).toContain("[active]") + }) +}) + +// ============================================================================ +// 8. Endpoint URL contracts — pulse, gmail, pages +// ============================================================================ + +describe("endpoint URL contracts: leads pulse", () => { + test("pulse fetches lead details from /api/v1/leads/{id}", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("/api/v1/leads/${leadId}") + }) + + test("pulse uses /api/v1/leads/{id}/gmail-threads for Gmail", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("/api/v1/leads/${leadId}/gmail-threads") + }) + + test("pulse uses bridge /api/imessage/search for iMessage", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("${BRIDGE_BASE}/api/imessage/search") + }) + + test("pulse uses bridge /api/mail/search for Apple Mail", () => { + const src = readSource("platform-leads.ts") + expect(src).toContain("${BRIDGE_BASE}/api/mail/search") + }) +}) + +describe("endpoint URL contracts: pages", () => { + test("pages list uses iris-api (not fl-api)", () => { + const src = readSource("platform-pages.ts") + expect(src).toContain("irisFetch(path, options ?? {}, IRIS_API)") + }) + + test("pages list requests /api/v1/pages", () => { + const src = readSource("platform-pages.ts") + expect(src).toContain("/api/v1/pages?per_page=50") + }) +}) + +// ============================================================================ +// 9. agents create --type flag — #B-04 +// ============================================================================ + +describe("agents create --type flag (B-04)", () => { + test("agents create builder includes --type option", () => { + const src = readSource("platform-agents.ts") + const createBlock = src.slice( + src.indexOf('command: "create"'), + src.indexOf('command: "create"') + 1000, + ) + expect(createBlock).toContain('.option("type"') + }) + + test("agents create payload includes type field", () => { + const src = readSource("platform-agents.ts") + expect(src).toContain('type: args.type ?? "content"') + }) + + test("agents create defaults type to 'content'", () => { + const src = readSource("platform-agents.ts") + const createBlock = src.slice( + src.indexOf('command: "create"'), + src.indexOf('command: "create"') + 800, + ) + expect(createBlock).toContain('default: "content"') + }) +}) + +// ============================================================================ +// 10. Model safety — must never use gpt-3.5-turbo +// ============================================================================ + +describe("model safety across all platform commands", () => { + const PLATFORM_FILES = [ + "platform-leads.ts", + "platform-run.ts", + "platform-agents.ts", + "platform-chat.ts", + "platform-pages.ts", + "platform-imessage.ts", + "platform-mail.ts", + ] + + for (const file of PLATFORM_FILES) { + test(`${file}: does not reference gpt-3.5-turbo`, () => { + const src = readSource(file) + expect(src).not.toContain("gpt-3.5-turbo") + expect(src).not.toContain("gpt-3.5") + }) + } +})