diff --git a/packages/opencode/package.json b/packages/opencode/package.json index d51e5eefebd1..0a97c94b5a9d 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.2.4", + "version": "1.2.5", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index 9e199d733b7e..8898ea3713fe 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -119,6 +119,12 @@ const BloqsGetCommand = cmd({ const data = (await res.json()) as { data?: any } const b = data?.data ?? data + if (!b || (!b.name && !b.id)) { + spinner.stop("Empty response", 1) + prompts.log.error("API returned no bloq data. The server may not support this endpoint yet.") + prompts.outro("Done") + return + } spinner.stop(String(b.name ?? `Bloq #${b.id}`)) printDivider() @@ -267,13 +273,76 @@ const BloqsCreateCommand = cmd({ }, }) +/** + * Auto-detect which CSV column should be used as the bloq item title. + */ +function detectTitleColumn(headers: string[], rows: Record[]): string { + const namePatterns = ["name", "title", "item", "product", "subject", "label", "horse", "rider"] + for (const pattern of namePatterns) { + const match = headers.find((h) => h.toLowerCase().includes(pattern)) + if (match) return match + } + // Fallback: first column with unique string values + for (const h of headers) { + const vals = rows.map((r) => r[h]).filter(Boolean) + const unique = new Set(vals) + if (unique.size === vals.length && vals.every((v) => typeof v === "string" && !/^\d+(\.\d+)?$/.test(v))) { + return h + } + } + return headers[0] +} + +/** + * Parse CSV text into an array of objects using the first row as headers. + */ +function parseCsv(text: string): { headers: string[]; rows: Record[] } { + const lines = text.split("\n").map((l) => l.trim()).filter(Boolean) + if (lines.length < 2) return { headers: [], rows: [] } + + // Simple CSV parser — handles quoted fields with commas + const parseLine = (line: string): string[] => { + const fields: string[] = [] + let current = "" + let inQuotes = false + for (let i = 0; i < line.length; i++) { + const ch = line[i] + if (ch === '"') { + if (inQuotes && line[i + 1] === '"') { current += '"'; i++ } + else inQuotes = !inQuotes + } else if (ch === "," && !inQuotes) { + fields.push(current.trim()) + current = "" + } else { + current += ch + } + } + fields.push(current.trim()) + return fields + } + + const headers = parseLine(lines[0]) + const rows = lines.slice(1).map((line) => { + const vals = parseLine(line) + const obj: Record = {} + headers.forEach((h, i) => { obj[h] = vals[i] ?? "" }) + return obj + }) + + return { headers, rows } +} + const BloqsIngestCommand = cmd({ command: "ingest ", - describe: "upload a file into a bloq", + describe: "upload a file into a bloq (CSV files are parsed into a dataset item)", builder: (yargs) => yargs .positional("id", { describe: "bloq ID", type: "number", demandOption: true }) .positional("file", { describe: "path to file", type: "string", demandOption: true }) + .option("list", { alias: "l", describe: "target list name", type: "string" }) + .option("as", { describe: "CSV mode: dataset (single item, default) or items (one item per row)", type: "string", choices: ["dataset", "items"], default: "dataset" }) + .option("key", { describe: "column name for upsert dedup on re-import (--as items only)", type: "string" }) + .option("title-column", { describe: "column to use as item title (auto-detected if omitted)", type: "string" }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { UI.empty() @@ -285,11 +354,10 @@ const BloqsIngestCommand = cmd({ const userId = await requireUserId(args["user-id"]) if (!userId) { prompts.outro("Done"); return } - // Read file const filename = path.basename(args.file) + const ext = path.extname(args.file).toLowerCase() const spinner = prompts.spinner() - spinner.start(`Uploading ${dim(filename)}…`) try { const file = Bun.file(args.file) @@ -300,6 +368,188 @@ const BloqsIngestCommand = cmd({ return } + // CSV files: parse rows into a structured dataset bloq item + if (ext === ".csv") { + spinner.start(`Parsing ${dim(filename)}…`) + const text = await file.text() + const { headers, rows } = parseCsv(text) + + if (rows.length === 0) { + spinner.stop("Empty CSV", 1) + prompts.log.error("No data rows found in CSV") + prompts.outro("Done") + return + } + + spinner.stop(`${success("✓")} Parsed ${rows.length} rows × ${headers.length} columns`) + + // Preview first 3 rows + for (const row of rows.slice(0, 3)) { + const preview = headers.slice(0, 4).map((h) => `${dim(h)}=${row[h] ?? ""}`).join(" ") + console.log(` ${preview}`) + } + if (rows.length > 3) console.log(` ${dim(`…and ${rows.length - 3} more`)}`) + console.log() + + // Resolve target list + let listId: number | null = null + 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 ?? [] + if (args.list) { + const match = lists.find((l: any) => (l.name ?? "").toLowerCase() === args.list!.toLowerCase()) + if (match) listId = match.id + } + if (!listId && lists.length > 0) listId = lists[0].id + } + + if (!listId) { + spinner.stop("No list found", 1) + prompts.log.error("Bloq has no lists. Create one first.") + prompts.outro("Done") + return + } + + const mode = args.as as string + + // ── Mode: items — one bloq item per CSV row ── + if (mode === "items") { + const titleCol = args["title-column"] ?? detectTitleColumn(headers, rows) + const keyCol = args.key ?? null + + // Fetch existing items for dedup if --key is specified + let existingItems: any[] = [] + if (keyCol) { + spinner.start(`Checking for existing items (dedup by ${dim(keyCol)})…`) + const existRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${args.id}/items?per_page=500`) + if (existRes.ok) { + const existData = (await existRes.json()) as { data?: any } + const raw = existData?.data?.items ?? existData?.data?.data ?? existData?.data ?? [] + existingItems = Array.isArray(raw) ? raw : Object.values(raw) + } + spinner.stop(`${existingItems.length} existing item(s)`) + } + + // Dedup + let toCreate = rows + let toUpdate: { item: any; row: Record }[] = [] + if (keyCol && existingItems.length > 0) { + for (const row of rows) { + const keyVal = row[keyCol] + if (!keyVal) { toCreate.push(row); continue } + const match = existingItems.find((item: any) => { + if (item.title === keyVal) return true + try { + const c = typeof item.content === "string" ? JSON.parse(item.content) : item.content + if (c?.type === "dataset") return false + return c?.[keyCol] === keyVal + } catch { return false } + }) + if (match) toUpdate.push({ item: match, row }) + } + // Remove matched rows from toCreate + const matchedKeys = new Set(toUpdate.map((u) => u.row[keyCol])) + toCreate = rows.filter((r) => !matchedKeys.has(r[keyCol]) || !r[keyCol]) + } + + spinner.start(`Creating ${toCreate.length} item(s)${toUpdate.length > 0 ? `, updating ${toUpdate.length}` : ""}…`) + + let created = 0 + let updated = 0 + let failed = 0 + + // Create new items + for (const row of toCreate) { + const title = row[titleCol] || `Row ${created + 1}` + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args.id}/items`, { + method: "POST", + body: JSON.stringify({ + title, + content: JSON.stringify(row), + type: "default", + bloq_list_id: listId, + }), + }) + if (res.ok) created++ + else failed++ + } + + // Update existing items + for (const { item, row } of toUpdate) { + const title = row[titleCol] || item.title + const res = await irisFetch(`/api/v1/user/bloqs/list/item/${item.id}`, { + method: "PUT", + body: JSON.stringify({ + title, + content: JSON.stringify(row), + }), + }) + if (res.ok) updated++ + else failed++ + } + + spinner.stop(`${success("✓")} ${created} created, ${updated} updated${failed > 0 ? `, ${failed} failed` : ""}`) + + printDivider() + printKV("Mode", "items (one per row)") + printKV("Title Column", titleCol) + if (keyCol) printKV("Dedup Key", keyCol) + printKV("Created", created) + if (updated > 0) printKV("Updated", updated) + if (failed > 0) printKV("Failed", failed) + printDivider() + + prompts.outro(dim(`iris bloqs get ${args.id}`)) + return + } + + // ── Mode: dataset (default) — single bloq item with all rows ── + spinner.start(`Saving dataset to Bloq #${args.id}…`) + + const dataset = { + type: "dataset", + source_file: filename, + headers, + row_count: rows.length, + rows, + } + + const itemRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${args.id}/items`, { + method: "POST", + body: JSON.stringify({ + title: filename.replace(/\.csv$/i, ""), + content: JSON.stringify(dataset), + type: "default", + bloq_list_id: listId, + }), + }) + + if (!itemRes.ok) { + spinner.stop("Failed", 1) + await handleApiError(itemRes, "Create dataset item") + prompts.outro("Done") + return + } + + const itemData = (await itemRes.json()) as { data?: any } + const item = itemData?.data ?? itemData + spinner.stop(`${success("✓")} Dataset saved — ${rows.length} rows`) + + printDivider() + printKV("Item ID", item?.id ?? "(unknown)") + printKV("Type", "dataset") + printKV("Rows", rows.length) + printKV("Columns", headers.join(", ")) + printDivider() + + prompts.outro(dim(`iris bloqs get ${args.id}`)) + return + } + + // Non-CSV files: upload as cloud file attachment (existing behavior) + spinner.start(`Uploading ${dim(filename)}…`) + const blob = await file.arrayBuffer() const formData = new FormData() formData.append("file", new Blob([blob]), filename) diff --git a/packages/opencode/src/cli/cmd/platform-bug.ts b/packages/opencode/src/cli/cmd/platform-bug.ts index d2a42f2e5154..c99a60a7c9eb 100644 --- a/packages/opencode/src/cli/cmd/platform-bug.ts +++ b/packages/opencode/src/cli/cmd/platform-bug.ts @@ -216,7 +216,7 @@ const ReportCommand = cmd({ await submitBug({ title: title!, description, - severity, + severity: severity.toLowerCase(), command: args.command, error: args.error, json: args.json, diff --git a/packages/opencode/src/cli/cmd/platform-diary.ts b/packages/opencode/src/cli/cmd/platform-diary.ts index 42b22c48fccc..d258d50de6c6 100644 --- a/packages/opencode/src/cli/cmd/platform-diary.ts +++ b/packages/opencode/src/cli/cmd/platform-diary.ts @@ -1,133 +1,187 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, printDivider, dim, bold, success } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, printDivider, dim, bold, success, IRIS_API } from "./iris-api" +import { existsSync, readFileSync } from "fs" +import { join } from "path" // Endpoints (DiaryResource): -// GET /api/v6/diary ?agent_id=&bloq_id= -// GET /api/v6/diary/list ?agent_id=&days= -// GET /api/v6/diary/{date} ?agent_id= -// POST /api/v6/diary { agent_id, content, bloq_id? } +// GET /api/v6/diary ?agent_id=&bloq_id=&user_id= +// GET /api/v6/diary/list ?agent_id=&bloq_id=&user_id=&days= +// GET /api/v6/diary/{date} ?agent_id=&bloq_id=&user_id= +// POST /api/v6/diary { agent_id?, bloq_id?, user_id?, content } +// +// Scopes: +// iris diary → user-level (default, "My Diary") +// iris diary --agent 11 → agent-level +// iris diary --bloq 325 → bloq/project-level -function buildParams(agentId: number | undefined, bloqId: number | undefined, extra: Record = {}): URLSearchParams { +function getSdkUserId(): string | undefined { + const envPath = join(process.env.HOME || "~", ".iris", "sdk", ".env") + if (existsSync(envPath)) { + const content = readFileSync(envPath, "utf-8") + const match = content.match(/IRIS_USER_ID=(\d+)/) + if (match) return match[1] + } + return undefined +} + +function buildParams(args: Record, extra: Record = {}): URLSearchParams { const p = new URLSearchParams() - if (agentId) p.set("agent_id", String(agentId)) - if (bloqId) p.set("bloq_id", String(bloqId)) + if (args.agent) p.set("agent_id", String(args.agent)) + if (args.bloq) p.set("bloq_id", String(args.bloq)) + // If neither agent nor bloq, send user_id for user-level diary + if (!args.agent && !args.bloq) { + const userId = getSdkUserId() + if (userId) p.set("user_id", userId) + } for (const [k, v] of Object.entries(extra)) if (v !== undefined) p.set(k, String(v)) return p } +function scopeLabel(args: Record): string { + if (args.agent) return `Agent #${args.agent}` + if (args.bloq) return `Bloq #${args.bloq}` + return "My Diary" +} + +const sharedOptions = (yargs: any) => + yargs + .option("agent", { alias: "a", describe: "agent ID (agent-level diary)", type: "number" }) + .option("bloq", { alias: "b", describe: "bloq ID (project-level diary)", type: "number" }) + .option("json", { type: "boolean", default: false }) + const DiaryTodayCommand = cmd({ - command: "today [agentId]", + command: "today", describe: "show today's diary timeline", - builder: (yargs) => - yargs - .positional("agentId", { type: "number" }) - .option("bloq", { alias: "b", type: "number" }) - .option("json", { type: "boolean", default: false }), + builder: sharedOptions, async handler(args) { UI.empty() - prompts.intro("◈ Diary — Today") + prompts.intro(`◈ Diary — Today (${scopeLabel(args)})`) const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } - if (!args.agentId && !args.bloq) { prompts.log.error("agent_id or --bloq required"); process.exitCode = 1; prompts.outro("Done"); return } - const params = buildParams(args.agentId, args.bloq) - const res = await irisFetch(`/api/v6/diary?${params}`) + const params = buildParams(args) + const res = await irisFetch(`/api/v6/diary?${params}`, {}, IRIS_API) const ok = await handleApiError(res, "Today's diary") if (!ok) { prompts.outro("Done"); return } const data = (await res.json()) as any if (args.json) { console.log(JSON.stringify(data, null, 2)); prompts.outro("Done"); return } + + if (data.bloq_name) console.log(` ${dim(`Bloq: ${data.bloq_name}`)}`) + if (data.agent_name) console.log(` ${dim(`Agent: ${data.agent_name}`)}`) + console.log() + printDivider() const timeline: any[] = data?.timeline ?? data?.data?.timeline ?? data?.entries ?? [] - if (timeline.length === 0) console.log(` ${dim("(no entries)")}`) + if (timeline.length === 0) console.log(` ${dim("(no entries today)")}`) else for (const e of timeline) { const ts = e.timestamp ?? e.created_at ?? "" - console.log(` ${dim(String(ts).slice(11, 19))} ${String(e.content ?? e.summary ?? "").slice(0, 100)}`) + const source = e.source === "heartbeat" ? dim(" [heartbeat]") : "" + console.log(` ${bold(String(ts).slice(11, 19))} ${String(e.content ?? e.summary ?? "").slice(0, 100)}${source}`) } printDivider() - prompts.outro("Done") + prompts.outro(dim(`iris diary add "your entry here"${args.agent ? ` --agent ${args.agent}` : args.bloq ? ` --bloq ${args.bloq}` : ""}`)) }, }) const DiaryListCommand = cmd({ - command: "list [agentId]", + command: "list", aliases: ["ls"], describe: "list recent diary entries", - builder: (yargs) => - yargs - .positional("agentId", { type: "number" }) - .option("bloq", { alias: "b", type: "number" }) - .option("days", { alias: "d", type: "number", default: 14 }) - .option("json", { type: "boolean", default: false }), + builder: (yargs: any) => sharedOptions(yargs).option("days", { alias: "d", type: "number", default: 14 }), async handler(args) { UI.empty() - prompts.intro("◈ Diary — List") + prompts.intro(`◈ Diary — Last ${args.days} Days (${scopeLabel(args)})`) const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } - if (!args.agentId && !args.bloq) { prompts.log.error("agent_id or --bloq required"); process.exitCode = 1; prompts.outro("Done"); return } - const params = buildParams(args.agentId, args.bloq, { days: args.days }) - const res = await irisFetch(`/api/v6/diary/list?${params}`) + const params = buildParams(args, { days: args.days }) + const res = await irisFetch(`/api/v6/diary/list?${params}`, {}, IRIS_API) const ok = await handleApiError(res, "List diary") if (!ok) { prompts.outro("Done"); return } const data = (await res.json()) as any if (args.json) { console.log(JSON.stringify(data, null, 2)); prompts.outro("Done"); return } + + if (data.bloq_name) console.log(` ${dim(`Bloq: ${data.bloq_name}`)}`) + console.log() + const entries: any[] = data?.entries ?? data?.data ?? (Array.isArray(data) ? data : []) printDivider() - for (const e of entries) console.log(` ${bold(String(e.date ?? e.created_at ?? "?"))} ${dim(String(e.summary ?? e.content ?? "").slice(0, 80))}`) + if (entries.length === 0) { + console.log(` ${dim("(no entries)")}`) + } else { + for (const e of entries) { + const indicators = [] + if (e.has_diary) indicators.push(`${e.diary_sections} sections`) + if (e.has_heartbeats) indicators.push(`${e.heartbeat_count} heartbeats`) + const meta = indicators.length > 0 ? dim(` (${indicators.join(", ")})`) : "" + console.log(` ${bold(String(e.date ?? "?"))}${meta}`) + if (e.summary) console.log(` ${dim(String(e.summary).slice(0, 100))}`) + } + } printDivider() - prompts.outro("Done") + prompts.outro(`${data.total_entries ?? entries.length} entries`) }, }) const DiaryViewCommand = cmd({ - command: "view ", + command: "view ", describe: "view a specific day's diary", - builder: (yargs) => - yargs - .positional("agentId", { type: "number", demandOption: true }) - .positional("date", { type: "string", demandOption: true }) - .option("bloq", { alias: "b", type: "number" }) - .option("json", { type: "boolean", default: false }), + builder: sharedOptions, async handler(args) { UI.empty() - prompts.intro(`◈ Diary — ${args.date}`) + prompts.intro(`◈ Diary — ${args.date} (${scopeLabel(args)})`) const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } - const params = buildParams(args.agentId, args.bloq) - const res = await irisFetch(`/api/v6/diary/${args.date}?${params}`) + const params = buildParams(args) + const res = await irisFetch(`/api/v6/diary/${args.date}?${params}`, {}, IRIS_API) const ok = await handleApiError(res, "View diary") if (!ok) { prompts.outro("Done"); return } const data = (await res.json()) as any if (args.json) { console.log(JSON.stringify(data, null, 2)); prompts.outro("Done"); return } + + if (data.diary_content) { + console.log() + console.log(data.diary_content) + } + printDivider() const timeline: any[] = data?.timeline ?? data?.data?.timeline ?? [] - for (const e of timeline) console.log(` ${dim(String(e.timestamp ?? "").slice(11, 19))} ${String(e.content ?? e.summary ?? "").slice(0, 100)}`) + if (timeline.length === 0 && !data.diary_content) { + console.log(` ${dim("(no entries)")}`) + } else { + for (const e of timeline) { + const source = e.source === "heartbeat" ? dim(" [heartbeat]") : "" + console.log(` ${bold(e.time ?? "?")} ${String(e.content ?? e.summary ?? "").slice(0, 100)}${source}`) + } + } printDivider() prompts.outro("Done") }, }) const DiaryAddCommand = cmd({ - command: "add ", - describe: "append a manual diary entry", - builder: (yargs) => - yargs - .positional("agentId", { type: "number", demandOption: true }) - .positional("content", { type: "string", demandOption: true }) - .option("bloq", { alias: "b", type: "number" }), + command: "add ", + describe: "append a diary entry", + builder: sharedOptions, async handler(args) { UI.empty() - prompts.intro("◈ Diary — Add") + prompts.intro(`◈ Diary — Add (${scopeLabel(args)})`) const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } - const payload: any = { agent_id: args.agentId, content: args.content } + const payload: any = { content: args.content } + if (args.agent) payload.agent_id = args.agent if (args.bloq) payload.bloq_id = args.bloq - const res = await irisFetch(`/api/v6/diary`, { method: "POST", body: JSON.stringify(payload) }) + if (!args.agent && !args.bloq) { + const userId = getSdkUserId() + if (userId) payload.user_id = parseInt(userId, 10) + } + const res = await irisFetch(`/api/v6/diary`, { method: "POST", body: JSON.stringify(payload) }, IRIS_API) const ok = await handleApiError(res, "Add diary") if (!ok) { prompts.outro("Done"); return } - prompts.outro(`${success("✓")} Added`) + const data = (await res.json()) as any + prompts.outro(`${success("✓")} Added to ${data.date ?? "today"} at ${data.time ?? "now"}`) }, }) export const PlatformDiaryCommand = cmd({ command: "diary", - describe: "view & manage agent daily diary entries", + describe: "daily diary — user-level by default, --agent or --bloq for scoped diaries", builder: (yargs) => yargs .command(DiaryTodayCommand) diff --git a/packages/opencode/src/cli/cmd/platform-domains.ts b/packages/opencode/src/cli/cmd/platform-domains.ts index 02fc974c57a8..db9253916fb2 100644 --- a/packages/opencode/src/cli/cmd/platform-domains.ts +++ b/packages/opencode/src/cli/cmd/platform-domains.ts @@ -122,6 +122,7 @@ const DomainsConnectCommand = cmd({ .option("site", { describe: "site slug to serve", type: "string" }) .option("site-id", { describe: "site ID to serve", type: "number" }) .option("provider", { describe: "DNS provider: cloudflare (full proxy) or godaddy (CNAME only)", type: "string", default: "cloudflare" }) + .option("yes", { alias: "y", describe: "skip confirmation prompt", type: "boolean", default: false }) .check((argv) => { if (!argv.page && !argv["page-id"] && !argv.site && !argv["site-id"]) { throw new Error("Provide at least one target: --page , --page-id , --site , or --site-id ") @@ -158,13 +159,15 @@ const DomainsConnectCommand = cmd({ sp.stop(`Found page: ${bold(page?.title ?? pageSlug)} (#${pageId})`) } - // Confirm before proceeding - const confirm = await prompts.confirm({ - message: `Connect ${bold(domain)} → ${pageSlug ? `/p/${pageSlug}` : siteSlug ? `/s/${siteSlug}` : `#${pageId ?? siteId}`} via ${provider}?`, - }) - if (!confirm || prompts.isCancel(confirm)) { - prompts.outro("Cancelled") - return + // Confirm before proceeding (skip with --yes) + if (!args.yes) { + const confirm = await prompts.confirm({ + message: `Connect ${bold(domain)} → ${pageSlug ? `/p/${pageSlug}` : siteSlug ? `/s/${siteSlug}` : `#${pageId ?? siteId}`} via ${provider}?`, + }) + if (!confirm || prompts.isCancel(confirm)) { + prompts.outro("Cancelled") + return + } } const sp = prompts.spinner() @@ -317,7 +320,8 @@ const DomainsRemoveCommand = cmd({ describe: "disconnect a custom domain and remove DNS records", builder: (yargs) => yargs - .positional("domain", { describe: "the domain to remove", type: "string", demandOption: true }), + .positional("domain", { describe: "the domain to remove", type: "string", demandOption: true }) + .option("yes", { alias: "y", describe: "skip confirmation prompt", type: "boolean", default: false }), async handler(args) { UI.empty() prompts.intro("◈ Remove Domain") @@ -350,12 +354,14 @@ const DomainsRemoveCommand = cmd({ sp.stop(`Found: ${bold(domain)} (${providerBadge(match.provider)})`) - const confirm = await prompts.confirm({ - message: `Remove ${bold(domain)}? This will delete DNS records and the domain mapping.`, - }) - if (!confirm || prompts.isCancel(confirm)) { - prompts.outro("Cancelled") - return + if (!args.yes) { + const confirm = await prompts.confirm({ + message: `Remove ${bold(domain)}? This will delete DNS records and the domain mapping.`, + }) + if (!confirm || prompts.isCancel(confirm)) { + prompts.outro("Cancelled") + return + } } const sp2 = prompts.spinner() diff --git a/packages/opencode/src/cli/cmd/platform-invoices.ts b/packages/opencode/src/cli/cmd/platform-invoices.ts index 5d7d3bbb7f0c..4419f9a78f1b 100644 --- a/packages/opencode/src/cli/cmd/platform-invoices.ts +++ b/packages/opencode/src/cli/cmd/platform-invoices.ts @@ -235,6 +235,55 @@ const SendCmd = cmd({ }, }) +// ── mark-paid (offline/cash) ── + +const MarkPaidCmd = cmd({ + command: "mark-paid ", + aliases: ["paid"], + describe: "record an offline/cash payment for a lead", + builder: (yargs) => + yargs + .positional("lead-id", { describe: "lead ID", type: "number", demandOption: true }) + .option("amount", { describe: "amount in dollars", type: "number", demandOption: true }) + .option("method", { + describe: "payment method", + type: "string", + choices: ["cash", "check", "wire", "ach", "zelle", "venmo", "paypal", "crypto", "barter", "other"], + default: "cash", + }) + .option("date", { describe: "payment date (YYYY-MM-DD)", type: "string" }) + .option("notes", { describe: "optional notes", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + const payload: Record = { + amount: args.amount, + method: args.method, + } + if (args.date) payload.paid_at = args.date + if (args.notes) payload.notes = args.notes + + const res = await irisFetch(`/api/v1/leads/${args.leadId}/invoice/mark-paid`, { + method: "POST", + body: JSON.stringify(payload), + }) + if (!(await handleApiError(res, "Mark paid"))) return + const body = await getJson(res) + + if (args.json) { console.log(JSON.stringify(body, null, 2)); return } + + if (body.success) { + prompts.log.success(`${success("✓")} Offline payment recorded`) + printKV("Amount", fmtMoney(args.amount)) + printKV("Method", String(args.method)) + printKV("Total Received", fmtMoney(body.total_received)) + if (args.notes) printKV("Notes", args.notes) + } else { + prompts.log.error(`Failed: ${body.error ?? body.message ?? "Unknown error"}`) + } + }, +}) + export const PlatformInvoicesCommand = cmd({ command: "invoices", describe: "create, view, and send invoices for leads", @@ -246,6 +295,7 @@ export const PlatformInvoicesCommand = cmd({ .command(ShowCmd) .command(CheckoutCmd) .command(SendCmd) + .command(MarkPaidCmd) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-leads.ts b/packages/opencode/src/cli/cmd/platform-leads.ts index fe4a46c4ccbc..8d44c794ebcd 100644 --- a/packages/opencode/src/cli/cmd/platform-leads.ts +++ b/packages/opencode/src/cli/cmd/platform-leads.ts @@ -212,6 +212,7 @@ const LeadsListCommand = cmd({ .option("search", { alias: "s", describe: "search query", type: "string" }) .option("limit", { describe: "max results", type: "number", default: 20 }) .option("bloq-id", { describe: "filter by bloq (CRM)", type: "number" }) + .option("all", { describe: "include Prospected leads (hidden by default)", type: "boolean", default: false }) .option("json", { describe: "JSON output", type: "boolean", default: false }), async handler(args) { UI.empty() @@ -224,7 +225,9 @@ const LeadsListCommand = cmd({ spinner.start("Loading leads…") try { - const params = new URLSearchParams({ per_page: String(args.limit) }) + // Fetch more than requested so we can filter + sort client-side + const fetchLimit = args.all || args.status || args.search ? args.limit : Math.max(args.limit * 5, 100) + const params = new URLSearchParams({ per_page: String(fetchLimit) }) if (args.status) params.set("status", args.status) if (args.search) params.set("search", args.search) if (args["bloq-id"]) params.set("bloq_id", String(args["bloq-id"])) @@ -234,9 +237,39 @@ const LeadsListCommand = cmd({ if (!ok) { spinner.stop("Failed", 1); process.exitCode = 1; prompts.outro("Done"); return } const data = (await res.json()) as { data?: any[]; total?: number; meta?: { total?: number } } - const leads: any[] = data?.data ?? [] + let leads: any[] = data?.data ?? [] + + // Default: hide Prospected leads (mass-scraped venue/SOM leads) + // Use --all or --status to see everything + if (!args.all && !args.status && !args.search) { + leads = leads.filter((l: any) => { + const s = (l.status ?? "").toLowerCase() + return s !== "prospected" + }) + } + + // Sort by status priority: active clients first + const statusPriority: Record = { + won: 0, + "in negotiation": 1, + interested: 2, + contacted: 3, + qualified: 4, + new: 5, + prospected: 6, + lost: 7, + unresponsive: 8, + } + leads.sort((a: any, b: any) => { + const pa = statusPriority[(a.status ?? "").toLowerCase()] ?? 5 + const pb = statusPriority[(b.status ?? "").toLowerCase()] ?? 5 + return pa - pb + }) + + // Trim to requested limit + leads = leads.slice(0, args.limit) const total = data?.meta?.total ?? leads.length - spinner.stop(`${total} lead(s)`) + spinner.stop(`${leads.length} lead(s)${!args.all && !args.status ? dim(` (${total} total — use --all to see Prospected)`) : ""}`) if (args.json) { console.log(JSON.stringify(leads, null, 2)) @@ -813,7 +846,7 @@ const LeadsUpdateCommand = cmd({ .option("phone", { describe: "new phone", type: "string" }) .option("company", { describe: "new company", type: "string" }) .option("status", { describe: "new status", type: "string" }) - .option("bloq-id", { describe: "CRM bloq ID to associate", type: "number" }) + .option("bloq-id", { alias: "bloq", describe: "CRM bloq ID to associate", type: "number" }) .option("website", { describe: "website URL", type: "string" }) .option("source", { describe: "lead source", type: "string" }) .option("stage", { describe: "pipeline stage", type: "string" }) @@ -1758,7 +1791,7 @@ const LeadsPulseCommand = cmd({ ) } - // iMessage (via local bridge daemon) — 1:1 by phone/email handle + // iMessage (via local bridge daemon) — 1:1 by phone/email handle or contact name const handle = phone || email if (handle) { fetches.push( @@ -1774,9 +1807,23 @@ const LeadsPulseCommand = cmd({ }) .catch((e) => { channels.push({ name: "iMessage", messages: [], error: e.message }) }), ) + } else if (name) { + // Fallback: search by contact name via Contacts.app resolution + fetches.push( + fetch(`${BRIDGE_BASE}/api/imessage/search?name=${encodeURIComponent(name)}&days=${days}&limit=${msgLimit}`) + .then(async (r) => { + if (r.ok) { + const d = (await r.json()) as any + channels.push({ name: "iMessage", messages: d?.messages ?? [] }) + } else { + const body = await r.text().catch(() => "") + channels.push({ name: "iMessage", messages: [], error: body || `HTTP ${r.status}` }) + } + }) + .catch((e) => { channels.push({ name: "iMessage", messages: [], error: e.message }) }), + ) } else { - // #57669: Warn when iMessage scan is skipped due to missing phone - channels.push({ name: "iMessage", messages: [], error: `No phone number — add with: iris leads update ${leadId} --phone "..."` }) + channels.push({ name: "iMessage", messages: [], error: `No phone, email, or name — add with: iris leads update ${leadId} --phone "..."` }) } // #57668: iMessage group chats — scan linked chat IDs from contact_info.chat_ids @@ -2153,6 +2200,12 @@ const LeadsPaymentGateCommand = cmd({ .option("scope", { alias: "s", describe: "scope of work", type: "string", demandOption: true }) .option("bloq", { alias: "b", describe: "bloq ID", type: "number" }) .option("package", { alias: "p", describe: "service package ID (auto-fills amount + scope)", type: "number" }) + .option("packages", { describe: "multiple package IDs for selectable tiers (comma-separated)", type: "string" }) + .option("interval", { alias: "i", describe: "billing interval", type: "string", choices: ["one-time", "month", "quarter", "year"] }) + .option("term", { alias: "t", describe: "duration in months (for recurring)", type: "number" }) + .option("deposit", { describe: "deposit percentage (0-100)", type: "number" }) + .option("list-price", { describe: "original list price (shows strikethrough discount)", type: "number" }) + .option("discount", { describe: "discount percentage (0-100)", type: "number" }) .option("no-auto-remind", { describe: "disable D+1/D+3/D+7 auto-reminders", type: "boolean" }) .option("json", { describe: "JSON output", type: "boolean" }), async handler(args) { @@ -2165,6 +2218,12 @@ const LeadsPaymentGateCommand = cmd({ } if (args.bloq) body.bloq_id = args.bloq if (args.package) body.package_id = args.package + if (args.packages) body.package_ids = args.packages.split(",").map(Number) + if (args.interval) body.interval = args.interval + if (args.term) body.duration_months = args.term + if (args.deposit != null) body.deposit_percent = args.deposit + if (args["list-price"]) body.list_price = args["list-price"] + if (args.discount != null) body.discount_percent = args.discount const res = await irisFetch(`/api/v1/leads/${args.id}/payment-gate`, { method: "POST", @@ -2201,6 +2260,80 @@ const LeadsPaymentGateCommand = cmd({ }, }) +// ============================================================================ +// Update Payment Gate +// ============================================================================ + +const LeadsUpdatePaymentGateCommand = cmd({ + command: "update-gate ", + aliases: ["update-invoice"], + describe: "update an existing payment gate (amount, scope)", + builder: (yargs) => + yargs + .positional("id", { describe: "lead ID", type: "number", demandOption: true }) + .option("amount", { alias: "a", describe: "new amount", type: "number" }) + .option("scope", { alias: "s", describe: "new scope of work", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + if (!args.amount && !args.scope) { + prompts.log.error("Provide at least --amount or --scope to update") + return + } + + const body: Record = {} + if (args.amount) body.amount = args.amount + if (args.scope) body.scope = args.scope + + const res = await irisFetch(`/api/v1/leads/${args.id}/payment-gate`, { + method: "PUT", + body: JSON.stringify(body), + }) + if (!(await handleApiError(res, "Update payment gate"))) return + + const data = await res.json().catch(() => ({})) + if (args.json) { console.log(JSON.stringify(data, null, 2)); return } + + if (data.success) { + console.log(success("Payment gate updated")) + } else { + prompts.log.error(data.message || "Failed to update payment gate") + } + }, +}) + +// ============================================================================ +// Delete Payment Gate +// ============================================================================ + +const LeadsDeletePaymentGateCommand = cmd({ + command: "delete-gate ", + aliases: ["delete-invoice", "rm-gate"], + describe: "delete a lead's payment gate", + builder: (yargs) => + yargs + .positional("id", { describe: "lead ID", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const res = await irisFetch(`/api/v1/leads/${args.id}/payment-gate`, { + method: "DELETE", + }) + if (!(await handleApiError(res, "Delete payment gate"))) return + + const data = await res.json().catch(() => ({})) + if (args.json) { console.log(JSON.stringify(data, null, 2)); return } + + if (data.success) { + console.log(success(data.message || "Payment gate deleted")) + } else { + prompts.log.error(data.message || "Failed to delete payment gate") + } + }, +}) + // ============================================================================ // Deal Status — show payment gate progress // ============================================================================ @@ -2306,6 +2439,136 @@ const LeadsPackagesCommand = cmd({ }, }) +// ============================================================================ +// Create Package — create a service package for a bloq +// ============================================================================ + +const LeadsCreatePackageCommand = cmd({ + command: "create-package ", + aliases: ["add-package", "new-package"], + describe: "create a service package for a bloq (used in multi-tier proposals)", + builder: (yargs) => + yargs + .positional("bloq", { describe: "bloq ID", type: "number", demandOption: true }) + .option("name", { alias: "n", describe: "package name", type: "string", demandOption: true }) + .option("price", { alias: "a", describe: "price (or use --amount)", type: "number", demandOption: true }) + .option("billing", { alias: "b", describe: "billing type", type: "string", choices: ["one_time", "monthly", "yearly", "milestone"], default: "monthly" }) + .option("scope", { alias: "s", describe: "scope of work template", type: "string" }) + .option("features", { alias: "f", describe: "features (comma-separated)", type: "string" }) + .option("description", { alias: "d", describe: "package description", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const body: Record = { + name: args.name, + price: args.price, + billing_type: args.billing, + } + if (args.scope) body.scope_template = args.scope + if (args.description) body.description = args.description + if (args.features) body.features = args.features.split(/,(?!\d{3}(?!\d))/).map((f: string) => f.trim()) + + const res = await irisFetch(`/api/v1/bloqs/${args.bloq}/packages`, { + method: "POST", + body: JSON.stringify(body), + }) + + const data = await res.json().catch(() => ({})) + + if (args.json) { console.log(JSON.stringify(data, null, 2)); return } + + if (!res.ok || !data.success) { + prompts.log.error(data.message || "Failed to create package") + if (data.errors) { + for (const [field, msgs] of Object.entries(data.errors)) { + console.log(` ${dim(field)}: ${(msgs as string[]).join(", ")}`) + } + } + if (data.hint) { + console.log("") + console.log(dim("Required: " + (data.hint as any).required?.join(", "))) + } + return + } + + const pkg = data.data + console.log("") + console.log(success(`Package created: #${pkg.id}`)) + printDivider() + printKV("Name", pkg.name) + printKV("Price", `$${Number(pkg.price).toFixed(2)}`) + printKV("Billing", pkg.billing_type) + if (pkg.scope_template) printKV("Scope", pkg.scope_template.slice(0, 80)) + if (pkg.features?.length) printKV("Features", pkg.features.join(", ")) + printDivider() + }, +}) + +// ============================================================================ +// Update Package — update an existing service package +// ============================================================================ + +const LeadsUpdatePackageCommand = cmd({ + command: "update-package ", + aliases: ["edit-package"], + describe: "update a service package (name, price, billing, features, scope)", + builder: (yargs) => + yargs + .positional("bloq", { describe: "bloq ID", type: "number", demandOption: true }) + .positional("packageId", { describe: "package ID", type: "number", demandOption: true }) + .option("name", { alias: "n", describe: "package name", type: "string" }) + .option("price", { alias: "a", describe: "price", type: "number" }) + .option("billing", { alias: "b", describe: "billing type", type: "string", choices: ["one_time", "monthly", "yearly", "milestone"] }) + .option("scope", { alias: "s", describe: "scope of work template", type: "string" }) + .option("features", { alias: "f", describe: "features (comma-separated)", type: "string" }) + .option("description", { alias: "d", describe: "package description", type: "string" }) + .option("active", { describe: "set active/inactive", type: "boolean" }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const body: Record = {} + if (args.name) body.name = args.name + if (args.price != null) body.price = args.price + if (args.billing) body.billing_type = args.billing + if (args.scope) body.scope_template = args.scope + if (args.description) body.description = args.description + if (args.active != null) body.is_active = args.active + if (args.features) body.features = args.features.split(/,(?!\d{3}(?!\d))/).map((f: string) => f.trim()) + + if (Object.keys(body).length === 0) { + prompts.log.error("Nothing to update — provide at least one flag (--name, --price, --billing, etc.)") + return + } + + const res = await irisFetch(`/api/v1/bloqs/${args.bloq}/packages/${args.packageId}`, { + method: "PATCH", + body: JSON.stringify(body), + }) + + const data = await res.json().catch(() => ({})) + + if (args.json) { console.log(JSON.stringify(data, null, 2)); return } + + if (!res.ok || !data.success) { + prompts.log.error(data.message || "Failed to update package") + return + } + + const pkg = data.data + console.log("") + console.log(success(`Package #${pkg.id} updated`)) + printDivider() + printKV("Name", pkg.name) + printKV("Price", `$${Number(pkg.price).toFixed(2)}`) + printKV("Billing", `${pkg.billing_type} (interval: ${pkg.billing_interval})`) + if (pkg.scope_template) printKV("Scope", pkg.scope_template.slice(0, 80)) + if (pkg.features?.length) printKV("Features", pkg.features.join(", ")) + printDivider() + }, +}) + // ============================================================================ // Regenerate Checkout — force-refresh a stale Stripe session // ============================================================================ @@ -2546,8 +2809,12 @@ export const PlatformLeadsCommand = cmd({ .command(LeadsNoteCommand) .command(LeadsTasksCommand) .command(LeadsPaymentGateCommand) + .command(LeadsUpdatePaymentGateCommand) + .command(LeadsDeletePaymentGateCommand) .command(LeadsDealStatusCommand) .command(LeadsPackagesCommand) + .command(LeadsCreatePackageCommand) + .command(LeadsUpdatePackageCommand) .command(LeadsRegenCheckoutCommand) .demandCommand(), async handler() {}, diff --git a/packages/opencode/src/cli/cmd/platform-som.ts b/packages/opencode/src/cli/cmd/platform-som.ts index a7b9996b0d1b..706d8e8cfd85 100644 --- a/packages/opencode/src/cli/cmd/platform-som.ts +++ b/packages/opencode/src/cli/cmd/platform-som.ts @@ -361,6 +361,127 @@ const SomHelpCommand = cmd({ }, }) +// ── Toggle command ── + +function findSomConfig(): string | null { + const { existsSync } = require("fs") + const { join, resolve } = require("path") + let dir = process.cwd() + for (let i = 0; i < 10; i++) { + const candidate = join(dir, "tests", "e2e", "som-config.js") + if (existsSync(candidate)) return candidate + const parent = resolve(dir, "..") + if (parent === dir) break + dir = parent + } + return null +} + +const SomToggleCommand = cmd({ + command: "toggle [state]", + describe: "turn a campaign on or off", + builder: (yargs) => + yargs + .positional("campaign", { describe: "campaign name (courses, creators, beatbox, mayo, venues, atxbeauty, gooddeals)", type: "string", demandOption: true }) + .positional("state", { describe: "on or off (toggles if omitted)", type: "string" }), + async handler(args) { + const { readFileSync, writeFileSync } = require("fs") + UI.empty() + prompts.intro("◈ SOM Toggle") + + const configPath = findSomConfig() + if (!configPath) { + prompts.log.error("som-config.js not found. Run from the freelabel project root.") + prompts.outro("Done") + return + } + + const content = readFileSync(configPath, "utf-8") + const campaign = (args.campaign as string).toLowerCase() + + // Find the campaign line + const regex = new RegExp(`(${campaign}:\\s*\\{[^}]*active:\\s*)(true|false)`, "i") + const match = content.match(regex) + + if (!match) { + prompts.log.error(`Campaign "${campaign}" not found in som-config.js`) + const available = content.match(/^\s+(\w+):\s*\{/gm)?.map((m: string) => m.trim().replace(/:\s*\{/, "")) ?? [] + prompts.log.info(`Available: ${available.join(", ")}`) + prompts.outro("Done") + return + } + + const currentState = match[2] === "true" + let newState: boolean + + if (args.state === "on") newState = true + else if (args.state === "off") newState = false + else newState = !currentState // toggle + + if (currentState === newState) { + prompts.log.info(`${campaign} is already ${newState ? "ON" : "OFF"}`) + prompts.outro("Done") + return + } + + const updated = content.replace(regex, `$1${newState}`) + writeFileSync(configPath, updated) + + prompts.log.info(`${bold(campaign)} ${currentState ? "ON → OFF" : "OFF → ON"}`) + + // Show all campaign states + const allCampaigns = updated.match(/(\w+):\s*\{[^}]*active:\s*(true|false)/gi) ?? [] + console.log("") + for (const line of allCampaigns) { + const nameMatch = line.match(/^(\w+):/) + const activeMatch = line.match(/active:\s*(true|false)/) + if (nameMatch && activeMatch) { + const n = nameMatch[1] + const a = activeMatch[1] === "true" + console.log(` ${a ? "✅" : "❌"} ${n}`) + } + } + console.log("") + + prompts.outro(dim("Changes take effect on next som:all run")) + }, +}) + +const SomStatusCommand = cmd({ + command: "status", + describe: "show which campaigns are on/off", + builder: (yargs) => yargs, + async handler() { + const { readFileSync } = require("fs") + UI.empty() + prompts.intro("◈ SOM Status") + + const configPath = findSomConfig() + if (!configPath) { + prompts.log.error("som-config.js not found") + prompts.outro("Done") + return + } + + const content = readFileSync(configPath, "utf-8") + const allCampaigns = content.match(/(\w+):\s*\{[^}]*active:\s*(true|false)/gi) ?? [] + + let on = 0, off = 0 + for (const line of allCampaigns) { + const nameMatch = line.match(/^(\w+):/) + const activeMatch = line.match(/active:\s*(true|false)/) + if (nameMatch && activeMatch) { + const n = nameMatch[1] + const a = activeMatch[1] === "true" + console.log(` ${a ? "✅" : "❌"} ${n}`) + a ? on++ : off++ + } + } + console.log("") + prompts.outro(`${on} active, ${off} off`) + }, +}) + // ── Parent command ── export const PlatformSomCommand = cmd({ @@ -370,6 +491,8 @@ export const PlatformSomCommand = cmd({ yargs .command(SomOverviewCommand) .command(SomEditCommand) + .command(SomToggleCommand) + .command(SomStatusCommand) .command(SomHelpCommand) // Default to overview when no subcommand .option("campaign", { alias: "c", describe: "show only one campaign", type: "string" }) diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts index ab3eb2b1181b..f65255ea04e7 100644 --- a/packages/opencode/src/cli/cmd/upgrade.ts +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -74,10 +74,12 @@ export const UpgradeCommand = { const installedVersion = verifyResult.trim() if (installedVersion && installedVersion !== target) { prompts.log.warn(`Expected v${target} but binary reports v${installedVersion}`) - prompts.log.info(`Try: curl -fsSL https://heyiris.io/install-iris.sh | bash`) + prompts.log.info(`Your shell may cache the old binary path. Run: hash -r && iris --version`) + prompts.log.info(`Or try: curl -fsSL https://heyiris.io/install-iris.sh | bash`) } else { prompts.log.success(`Verified: v${installedVersion}`) } + prompts.log.info(`If iris --version still shows old, run: hash -r`) // Also update SDK and bridge if present const home = process.env.HOME || "" diff --git a/scaffold/how-to/event-production.md b/scaffold/how-to/event-production.md new file mode 100644 index 000000000000..40e9134b8260 --- /dev/null +++ b/scaffold/how-to/event-production.md @@ -0,0 +1,154 @@ +# Event Production — How-To + +Set up a live event with ticket sales, QR check-in, door payments, and production management — all from the CLI. + +## Quick Reference + +```bash +iris events list # list all events +iris events get # show event details +iris events tickets # list ticket tiers +iris events tickets-pull # download tickets to JSON +iris events tickets-push # sync local JSON to API +iris events tickets-diff # preview changes +iris events ticket-checkout # generate Stripe checkout link +``` + +## Full Playbook (Song Wars example) + +### 1. Create the event + +```bash +# Create via API or frontend at web.freelabel.net/dashboard +# Event #1343: Song Wars Live ATX Edition +# Set: title, date, time, venue, description, photo +``` + +### 2. Set up ticket tiers + +```bash +# Pull tickets (creates .iris/events/{id}-tickets.json) +iris events tickets-pull 1343 + +# Edit the JSON: +{ + "event_id": 1343, + "tickets": [ + { + "title": "Online Ticket", + "price": "10", + "description": "Early bird entry", + "sale_end_date": "2026-04-19T00:00:00", + "quantity_total": 30, + "max_per_order": 5, + "sort_order": 0 + }, + { + "title": "Door Entry", + "price": "15", + "sale_start_date": "2026-04-19T00:00:00", + "sale_end_date": "2026-04-19T04:00:00", + "max_per_order": 5, + "sort_order": 1 + }, + { + "title": "Membership", + "price": "25", + "sale_end_date": "2026-04-19T04:00:00", + "quantity_total": 15, + "max_per_order": 1, + "sort_order": 2 + } + ] +} + +# Push to create/update/delete tiers +iris events tickets-push 1343 +``` + +**Timezone warning:** All dates are UTC. For CDT (Austin), add 5 hours. 7PM CDT = midnight UTC next day. + +### 3. Stripe checkout + +Tickets auto-generate Stripe Checkout sessions. Buyers pay via Apple Pay / Google Pay / card. + +```bash +# Generate a checkout link for door sales +iris events ticket-checkout 1343 +# → pick ticket → enter email → get Stripe URL + +# Non-interactive (for scripts) +iris events ticket-checkout 1343 --ticket 12 --email door@venue.com --open +``` + +### 4. QR check-in + +After payment, buyer sees a QR code on the success page. Staff scans with phone camera. + +``` +Staff scans QR → opens freelabel.net/checkin/{token} +→ shows ticket info (name, email, tier, quantity) +→ taps "Check In Now" +→ green checkmark (prevents double entry) +``` + +Guest list: `GET /api/v1/events/1343/purchases` — all purchases with check-in status. + +### 5. Door sales (Apple Pay) + +The event page has a "Pay at Door" panel (owner-only) with QR codes per tier. Customer scans with phone → email prompt → Stripe Checkout → Apple Pay → done. No card reader needed. + +### 6. Production management + +Set up equipment, stages, sponsors, venue deal via the admin panel at `web.freelabel.net/events/{id}` (logged in as owner). + +**Equipment** — stored as AtlasInventoryItem with category='equipment': +``` +Camera A → Judges Stage → Twitch +Camera B → Host Stage → YouTube +Mixer → All Stages +4x Wireless Lavs → Judges Stage +``` + +**Venue deal** — stored in event_venue_deals: +``` +Remedy Elixer House — barter deal, 90-day booking rights +``` + +**Admin panel** shows: readiness score, checklist, stats, equipment grid, sponsors, stages, timeline, contracts, budget. + +### 7. Day-of toolkit + +```bash +iris obs dashboard 1343 # OBS control from phone +iris obs scene "CAM 1" # Switch cameras +iris obs stream start # Go live +iris obs marker "highlight" # Mark for clips +iris events production -e 1343 runsheet # Run-of-show +iris events production -e 1343 checklist # Todo list +``` + +## Ticket Fields Reference + +| Field | Type | Description | +|-------|------|-------------| +| title | string | Tier name (GA, VIP, Membership) | +| price | string | Dollar amount ("10", "25.00") | +| description | string | What's included | +| sale_start_date | datetime | When tickets go on sale (UTC) | +| sale_end_date | datetime | When sales close (UTC) | +| quantity_total | int/null | Max inventory (null = unlimited) | +| quantity_sold | int | Auto-incremented on checkout | +| max_per_order | int | Max tickets per purchase (default 10) | +| min_per_order | int | Min tickets per purchase (default 1) | +| is_visible | boolean | Show/hide from buyers | +| sort_order | int | Display order | +| status | enum | active, paused, sold_out, ended | + +## Revenue Math (50-person event) + +| Scenario | Ticket Revenue | Membership Upsell | Total | +|----------|---------------|-------------------|-------| +| Conservative (60/40 online/door) | $600 | $250 (10 converts) | $850 | +| Expected (50/50 + 15 members) | $625 | $375 | $1,000 | +| Aggressive (full + 25 members) | $750 | $625 | $1,375 | diff --git a/scaffold/how-to/iris-platform.md b/scaffold/how-to/iris-platform.md new file mode 100644 index 000000000000..c9720f7c297d --- /dev/null +++ b/scaffold/how-to/iris-platform.md @@ -0,0 +1,187 @@ +# IRIS Platform — Connect Any Frontend to IRIS as Its Backend + +Use IRIS as a complete backend-as-a-service for any React, Vue, or mobile app. Zero server code. Your client's frontend calls IRIS APIs on a staging subdomain — same domain, no CORS. + +## What the client gets + +| Capability | Endpoint | Replaces | +|-----------|----------|----------| +| Database (CRUD) | `/api/v1/public/bloqs/{id}/items` | Firebase / Supabase | +| AI Chat | `/api/v6/chat/stream` | OpenAI / Google AI Studio | +| Payments | `/api/v1/events/{id}/tickets/{id}/checkout` | Custom Stripe | +| Lead CRM | `/api/v1/public/form/submissions` | HubSpot | +| Events + QR | `/api/v1/events/*` | Eventbrite | +| Pages | `/api/v1/pages/*` | Webflow | +| Compute | `/api/v6/nodes/tasks` | AWS Lambda | +| Staging URL | `clientapp.heyiris.io` | Vercel Preview | + +## Quick Start + +### 1. Create workspace + data store + +```bash +iris bloqs create "ClientApp" --description "Client's app data" +# Save the bloq_id + +# Create data lists (like database tables) +iris bloqs create-list {bloqId} "Users" +iris bloqs create-list {bloqId} "Products" +iris bloqs create-list {bloqId} "Orders" +``` + +### 2. Create AI agent + +```bash +iris agents create \ + --name "ClientApp AI" \ + --model gpt-4o-mini \ + --bloq {bloqId} \ + --system-prompt "You are a helpful assistant for ClientApp." +``` + +### 3. Set up staging subdomain + +**If client has no app yet** — serve a Genesis landing page: +```bash +iris pages create client-landing "ClientApp" +iris pages publish client-landing +# Then create domain mapping with mapping_mode='page' +``` + +**If client has an existing app** (React on Cloud Run, Vercel, etc.): +```bash +# 1. Add domain mapping to DB: +# domain: clientapp.heyiris.io +# mapping_type: proxy +# mapping_mode: proxy +# proxy_target: https://their-app.run.app +# status: active + +# 2. Add Cloudflare Worker route: +# Pattern: *clientapp.heyiris.io/* +# Worker: iris-domain-proxy +# Failure mode: Fail open +``` + +### 4. Wire the frontend + +```javascript +const IRIS_API = 'https://clientapp.heyiris.io' // same domain = no CORS +const SDK_KEY = process.env.REACT_APP_IRIS_SDK_KEY + +// AI Chat (replaces Google AI Studio / OpenAI) +const res = await fetch(`${IRIS_API}/api/v6/chat/stream`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${SDK_KEY}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentId: AGENT_ID, message: 'Hello' }) +}) + +// Read data (replaces Firebase reads) +const items = await fetch( + `${IRIS_API}/api/v1/public/bloqs/${BLOQ_ID}/items?list=Products`, + { headers: { 'Authorization': `Bearer ${SDK_KEY}` } } +).then(r => r.json()) + +// Write data (replaces Firebase writes) +await fetch(`${IRIS_API}/api/v1/public/bloqs/${BLOQ_ID}/items`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${SDK_KEY}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Widget', content: '{"price": 29.99}', type: 'default' }) +}) + +// Dispatch background compute (replaces Lambda) +await fetch(`${IRIS_API}/api/v6/nodes/tasks`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${SDK_KEY}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: USER_ID, + type: 'custom', + prompt: 'process uploaded file', + config: { callback_url: 'https://clientapp.heyiris.io/api/webhook/result' } + }) +}) +``` + +### 5. Lead capture (no auth needed) + +```html +
+ + + +
+``` + +## How it works + +``` +clientapp.heyiris.io + │ + │ Cloudflare Worker (iris-domain-proxy) + │ Sets X-Original-Host, forwards to Railway + ▼ +┌─ iris-api ───────────────────────────────────┐ +│ │ +│ /api/* → iris-api handles directly │ +│ (AI chat, bloqs, events, tools) │ +│ │ +│ /* → StagingProxyController │ +│ reverse-proxies to client's app │ +│ (Cloud Run, Vercel, Netlify, etc.) │ +└───────────────────────────────────────────────┘ +``` + +## Bloq CRUD cheat sheet + +```bash +# List items +curl https://clientapp.heyiris.io/api/v1/public/bloqs/{bloqId}/items \ + -H "Authorization: Bearer {SDK_KEY}" + +# Filter by list +curl ".../items?list=Products" + +# Create +curl -X POST ".../items" \ + -H "Content-Type: application/json" \ + -d '{"title":"Widget","content":"{\"price\":29.99}","type":"default"}' + +# Update +curl -X PUT ".../items/{itemId}" \ + -d '{"title":"Updated Widget"}' + +# Delete +curl -X DELETE ".../items/{itemId}" +``` + +**Note:** `type` is required on create. Use `"default"` unless you have custom types. + +## New client checklist + +- [ ] Create bloq: `iris bloqs create "AppName"` +- [ ] Create lists: `iris bloqs create-list {id} "TableName"` +- [ ] Create agent: `iris agents create --name "AppName AI" --bloq {id}` +- [ ] Add domain mapping in DB (proxy or page mode) +- [ ] Add Cloudflare Worker route for subdomain +- [ ] Give client SDK key +- [ ] Test health: `curl https://subdomain.heyiris.io/api/health` +- [ ] Test proxy: `curl https://subdomain.heyiris.io/` +- [ ] Test CRUD: `curl https://subdomain.heyiris.io/api/v1/public/bloqs/{id}/items` + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| "Application not found" | Worker route missing | Add `*subdomain.heyiris.io/*` in Cloudflare | +| HTML loads, JS/CSS 404 | Old deploy without catch-all route | Redeploy iris-api | +| CRUD returns 422 | Missing `type` field | Add `"type": "default"` to POST body | +| Stale proxy target | 5-min domain mapping cache | Wait 5 min or clear cache | +| CORS errors | App on different domain | Use staging subdomain (same domain = no CORS) | + +## Pricing + +| | Price | Includes | +|-|-------|---------| +| Starter | $99 onboard | 1 bloq, 1 agent, staging URL | +| Pro | $99 + $29/mo | 5 bloqs, 5 agents, custom domain | +| Business | $99 + $79/mo | Unlimited, priority support |