From 6801c9d0c8f3b39fccfd4f04dec665ed0f93188f Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 1 May 2026 16:20:27 -0500 Subject: [PATCH 1/4] fix: Remotion output renders to ~/.iris/remotion/out instead of CWD render and still commands didn't pass an output path, so Remotion defaulted to its project dir (~/.iris/remotion/out/). Now defaults to $PWD/out/. and prints the path. Fixes #77344, #77345 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/opencode/src/cli/cmd/platform-remotion.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-remotion.ts b/packages/opencode/src/cli/cmd/platform-remotion.ts index e4d94f7599ad..0c2bf7706d34 100644 --- a/packages/opencode/src/cli/cmd/platform-remotion.ts +++ b/packages/opencode/src/cli/cmd/platform-remotion.ts @@ -51,9 +51,11 @@ const RenderCommand = cmd({ describe: "JSON props for the composition", }), async handler(args) { - const cmdArgs = ["render", args.composition as string] - if (args.output) cmdArgs.push(args.output as string) + const comp = args.composition as string + const output = (args.output as string) || join(process.cwd(), "out", `${comp}.mp4`) + const cmdArgs = ["render", comp, output] if (args.props) cmdArgs.push("--props", args.props as string) + UI.println(`Output: ${output}`) runIrisRemotion(cmdArgs) }, }) @@ -78,9 +80,11 @@ const StillCommand = cmd({ describe: "JSON props for the composition", }), async handler(args) { - const cmdArgs = ["still", args.composition as string] - if (args.output) cmdArgs.push(args.output as string) + const comp = args.composition as string + const output = (args.output as string) || join(process.cwd(), "out", `${comp}.png`) + const cmdArgs = ["still", comp, output] if (args.props) cmdArgs.push("--props", args.props as string) + UI.println(`Output: ${output}`) runIrisRemotion(cmdArgs) }, }) From d9b0febec55980068a08fdcaeb7394eba48d3ef9 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 1 May 2026 16:35:48 -0500 Subject: [PATCH 2/4] feat: Atlas datasets CLI + Genesis validation (API-driven schema registry) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atlas Datasets CLI (iris atlas:datasets): - schemas list/show/create — manage dataset blueprints - records list/show/summary/add/update/delete/upsert — full CRUD - export — CSV/JSON export for QuickBooks Desktop - audit — data quality scan (flags $0 billing, missing fields) Genesis Validation: - validateComponents() now async — fetches valid types from API - Fallback to 152-type hardcoded set when API unreachable - pages set now validates before API call - VALID_COMPONENT_TYPES expanded from 30 → 152 (all Vue components) - COMPONENT_REGISTRY expanded with categories + required props - component-registry command fetches from API with category grouping How-To Recipes (3 new): - atlas-datasets.md — general dataset CLI usage - pathways-cfo-workflow.md — Servis AI → Atlas → QuickBooks pipeline - expose-dataset-api.md — REST API + pages + agent access Co-Authored-By: Claude Opus 4.6 (1M context) --- .../opencode/src/cli/cmd/command-groups.ts | 1 + .../src/cli/cmd/platform-atlas-datasets.ts | 242 +++++++++++++++++- .../opencode/src/cli/cmd/platform-pages.ts | 242 +++++++++++++++--- scaffold/how-to/README.md | 6 + scaffold/how-to/atlas-datasets.md | 118 +++++++++ scaffold/how-to/expose-dataset-api.md | 162 ++++++++++++ scaffold/how-to/pathways-cfo-workflow.md | 114 +++++++++ 7 files changed, 843 insertions(+), 42 deletions(-) create mode 100644 scaffold/how-to/atlas-datasets.md create mode 100644 scaffold/how-to/expose-dataset-api.md create mode 100644 scaffold/how-to/pathways-cfo-workflow.md diff --git a/packages/opencode/src/cli/cmd/command-groups.ts b/packages/opencode/src/cli/cmd/command-groups.ts index 15cb6cdf1ace..66138e0e26cc 100644 --- a/packages/opencode/src/cli/cmd/command-groups.ts +++ b/packages/opencode/src/cli/cmd/command-groups.ts @@ -88,6 +88,7 @@ export const COMMAND_CATEGORY_MAP: Record = { "atlas:meetings": "atlas", "atlas:brand-kit": "atlas", "atlas:comms": "atlas", + "atlas:datasets": "atlas", "good-deals": "atlas", // Knowledge & Content diff --git a/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts b/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts index 30070570319a..5a689908ff69 100644 --- a/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts +++ b/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts @@ -95,11 +95,74 @@ const SchemaShowCommand = cmd({ }, }) +const SchemaCreateCommand = cmd({ + command: "create", + aliases: ["new"], + describe: "create a new dataset schema", + builder: (y) => + y + .option("name", { type: "string", demandOption: true, describe: "schema name" }) + .option("slug", { type: "string", describe: "url-safe slug (auto from name if omitted)" }) + .option("bloq", { type: "number", describe: "bloq ID to scope to" }) + .option("fields", { type: "string", describe: "JSON fields definition or path to .json file" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Create Schema") + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + let fields: any = null + if (args.fields) { + try { + // Try as file path first + if (args.fields.endsWith(".json") && fs.existsSync(args.fields)) { + fields = JSON.parse(fs.readFileSync(args.fields, "utf8")) + } else { + fields = JSON.parse(args.fields) + } + } catch { + prompts.log.error("Invalid JSON for --fields") + prompts.outro("Done") + return + } + } else { + // Interactive: ask for fields + const fieldsDef = await prompts.text({ + message: "Define fields as JSON (or press Enter for empty schema):", + placeholder: '{"fields": [{"key": "name", "label": "Name", "type": "text", "required": true}]}', + }) + if (prompts.isCancel(fieldsDef)) { prompts.outro("Done"); return } + if (fieldsDef && String(fieldsDef).trim()) { + try { fields = JSON.parse(String(fieldsDef)) } catch { prompts.log.error("Invalid JSON"); prompts.outro("Done"); return } + } else { + fields = { fields: [] } + } + } + + const body: Record = { name: args.name, fields } + if (args.slug) body.slug = args.slug + if (args.bloq != null) body.bloq_id = args.bloq + + const spinner = prompts.spinner() + spinner.start("Creating…") + try { + const res = await irisFetch("/api/v1/atlas/schemas", { method: "POST", body: JSON.stringify(body) }) + const ok = await handleApiError(res, "Create schema"); if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + const data = ((await res.json()) as any)?.data + spinner.stop(`Created: ${bold(data?.slug ?? args.name)}`) + prompts.outro(`iris atlas:datasets records list --schema=${data?.slug ?? args.name}`) + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + const SchemasGroup = cmd({ command: "schemas", aliases: ["schema"], describe: "manage dataset schemas", - builder: (y) => y.command(SchemaListCommand).command(SchemaShowCommand).demandCommand(), + builder: (y) => y.command(SchemaListCommand).command(SchemaShowCommand).command(SchemaCreateCommand).demandCommand(), async handler() {}, }) @@ -478,6 +541,179 @@ const AuditCommand = cmd({ }, }) +// ── RECORDS WRITE COMMANDS ─────────────────────────────────────────────────── + +const RecordsAddCommand = cmd({ + command: "add", + aliases: ["create"], + describe: "add a record to a dataset", + builder: (y) => + y + .option("schema", { type: "string", demandOption: true, alias: "s" }) + .option("data", { type: "string", describe: "JSON data or path to .json file" }) + .option("external-id", { type: "string", describe: "external ID for dedup" }) + .option("bloq", { type: "number" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Add Record: ${args.schema}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + let data: any = {} + if (args.data) { + try { + if (args.data.endsWith(".json") && fs.existsSync(args.data)) { + data = JSON.parse(fs.readFileSync(args.data, "utf8")) + } else { + data = JSON.parse(args.data) + } + } catch { prompts.log.error("Invalid JSON for --data"); prompts.outro("Done"); return } + } else { + const raw = await prompts.text({ message: "Record data (JSON):", placeholder: '{"name": "value"}' }) + if (prompts.isCancel(raw)) { prompts.outro("Done"); return } + try { data = JSON.parse(String(raw)) } catch { prompts.log.error("Invalid JSON"); prompts.outro("Done"); return } + } + + const body: Record = { data } + if (args["external-id"]) body.external_id = args["external-id"] + if (args.bloq != null) body.bloq_id = args.bloq + + const spinner = prompts.spinner() + spinner.start("Creating…") + try { + const res = await irisFetch(`/api/v1/atlas/datasets/${args.schema}`, { method: "POST", body: JSON.stringify(body) }) + const ok = await handleApiError(res, "Create record"); if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + const result = ((await res.json()) as any)?.data + spinner.stop(`Created #${result?.id ?? "?"}`) + prompts.outro(`iris atlas:datasets records show ${result?.id ?? ""} -s ${args.schema}`) + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const RecordsUpdateCommand = cmd({ + command: "update ", + aliases: ["edit"], + describe: "update a record", + builder: (y) => + y + .positional("id", { type: "number", demandOption: true }) + .option("schema", { type: "string", demandOption: true, alias: "s" }) + .option("data", { type: "string", describe: "JSON data to merge" }) + .option("set", { type: "string", describe: "key=value pairs (repeatable)", array: true }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Update Record #${args.id}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + let data: any = {} + if (args.data) { + try { data = JSON.parse(args.data) } catch { prompts.log.error("Invalid JSON for --data"); prompts.outro("Done"); return } + } + // Parse --set key=value pairs + for (const s of args.set ?? []) { + const [key, ...rest] = s.split("=") + if (key && rest.length) { + let val: any = rest.join("=") + try { val = JSON.parse(val) } catch { /* keep as string */ } + data[key] = val + } + } + + if (Object.keys(data).length === 0) { + prompts.log.error("No data provided. Use --data '{...}' or --set key=value") + prompts.outro("Done") + return + } + + const spinner = prompts.spinner() + spinner.start("Updating…") + try { + const res = await irisFetch(`/api/v1/atlas/datasets/${args.schema}/${args.id}`, { + method: "PATCH", + body: JSON.stringify({ data }), + }) + const ok = await handleApiError(res, "Update record"); if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + spinner.stop("Updated") + prompts.outro(`iris atlas:datasets records show ${args.id} -s ${args.schema}`) + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const RecordsDeleteCommand = cmd({ + command: "delete ", + aliases: ["rm", "remove"], + describe: "delete a record", + builder: (y) => + y + .positional("id", { type: "number", demandOption: true }) + .option("schema", { type: "string", demandOption: true, alias: "s" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Delete Record #${args.id}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const confirm = await prompts.confirm({ message: `Delete record #${args.id}?` }) + if (prompts.isCancel(confirm) || !confirm) { prompts.outro("Cancelled"); return } + + const spinner = prompts.spinner() + spinner.start("Deleting…") + try { + const res = await irisFetch(`/api/v1/atlas/datasets/${args.schema}/${args.id}`, { method: "DELETE" }) + const ok = await handleApiError(res, "Delete record"); if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + spinner.stop("Deleted") + prompts.outro("Done") + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const RecordsUpsertCommand = cmd({ + command: "upsert", + aliases: ["sync"], + describe: "create or update a record by external ID", + builder: (y) => + y + .option("schema", { type: "string", demandOption: true, alias: "s" }) + .option("external-id", { type: "string", demandOption: true, describe: "external ID for dedup" }) + .option("data", { type: "string", demandOption: true, describe: "JSON data" }) + .option("bloq", { type: "number" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Upsert: ${args["external-id"]}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + let data: any + try { data = JSON.parse(args.data) } catch { prompts.log.error("Invalid JSON"); prompts.outro("Done"); return } + + const body: Record = { external_id: args["external-id"], data } + if (args.bloq != null) body.bloq_id = args.bloq + + const spinner = prompts.spinner() + spinner.start("Upserting…") + try { + const res = await irisFetch(`/api/v1/atlas/datasets/${args.schema}/upsert`, { method: "POST", body: JSON.stringify(body) }) + const ok = await handleApiError(res, "Upsert"); if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + const result = ((await res.json()) as any) + spinner.stop(result?.message ?? "Done") + prompts.outro("Done") + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + // ── COMMAND GROUPS ──────────────────────────────────────────────────────────── const RecordsGroup = cmd({ @@ -485,7 +721,9 @@ const RecordsGroup = cmd({ aliases: ["data", "rows"], describe: "manage records in a dataset", builder: (y) => - y.command(RecordsListCommand).command(RecordsShowCommand).command(RecordsSummaryCommand).demandCommand(), + y.command(RecordsListCommand).command(RecordsShowCommand).command(RecordsSummaryCommand) + .command(RecordsAddCommand).command(RecordsUpdateCommand).command(RecordsDeleteCommand) + .command(RecordsUpsertCommand).demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index f6a705be100d..a952f1dff5c7 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -21,7 +21,7 @@ function publicUrl(slugOrPage: string | { public_url?: string; slug?: string }): const env = process.env.IRIS_ENV ?? "production" return env === "local" ? `http://local.iris.freelabel.net:9300/p/${slug}` - : `https://heyiris.io/p/${slug}` + : `https://freelabel.net/p/${slug}` } // Pages CRUD routes through iris-api (which proxies to fl-api with service token). @@ -243,6 +243,21 @@ const SetCmd = cmd({ const json = page.json_content ?? {} const parsed = parseValue(args.value) setNestedValue(json, args.path, parsed) + + // Validate components if the update touches json_content.components + if (args.path.startsWith("json_content.components") || args.path === "json_content") { + const target = args.path === "json_content" ? parsed : json + const validation = await validateComponents(target) + if (!validation.valid) { + sp.stop("Validation failed", 1) + for (const err of validation.errors) { + if (err) prompts.log.error(err) + } + prompts.outro("Done") + return + } + } + const res = await pagesFetch(`/api/v1/pages/${page.id}`, { method: "PUT", body: JSON.stringify({ json_content: json }), @@ -337,7 +352,7 @@ const PushCmd = cmd({ } // Validate component types BEFORE pushing - const validation = validateComponents(jsonContent) + const validation = await validateComponents(jsonContent) if (!validation.valid) { sp.stop("Validation failed", 1) for (const err of validation.errors) { @@ -679,19 +694,73 @@ const RollbackCmd = cmd({ // ============================================================================ // Component Validation — reject invalid types before push/create +// +// SINGLE SOURCE OF TRUTH: .schema.json files in iris-api PageBuilder directory. +// The CLI fetches valid types from the API at /v1/pages/schema-registry. +// Fallback to a hardcoded set if the API is unreachable. // ============================================================================ -const VALID_COMPONENT_TYPES = new Set([ - "Hero", "SiteNavigation", "SiteFooter", "AnnouncementBanner", - "TestimonialsSection", "TeamSection", "ContactSection", "LogoMarquee", - "FeatureShowcase", "ComparisonMatrix", "ClientGrid", "CareersListing", - "PortfolioGallery", "ProductGrid", "ServiceMenu", "EventGrid", - "FundingTiers", "BeforeAfter", "MapSection", "NewsletterSignup", - "StepWizard", "FileUpload", "ShoppingCart", "OrderConfirmation", - "ProtectionPicker", "VehicleGrid", +// Fallback list — only used when API is unreachable. +// Auto-generated from 152 .schema.json files in PageBuilder/ +const FALLBACK_COMPONENT_TYPES = new Set([ + "ActivityFeed", "AgencyHero", "AgentCompatibilityStrip", "AgentExamples", "AllCasesGrid", "AnnouncementBanner", + "ApexChart", "AppDownloadCard", "AppDownloadGrid", "ArticleAuthorBlock", "ArticleBodyBlock", "ArticleHeroBlock", + "BeforeAfter", "BenefitsSection", "BlogGrid", "BookingCalendar", "BookingWizard", "ButtonCTA", + "CareersListing", "CaseCard", "CaseEconomics", "CaseEditorChatPanel", "CaseEditorContent", "CaseEditorModal", + "CaseEditorSidebar", "CasePipelineBoard", "CaseSlidePanel", "CategoryFilterBar", "ChatPanel", "ClientGrid", + "CodeShowcase", "CommunityCTA", "ComparisonCards", "ComparisonMatrix", "ContactSection", "DataChart", + "DataTable", "DemandTracker", "EarningsTable", "EditorialComparison", "EditorialSection", "EnrollmentForm", + "EventAdminPanel", "EventCalendar", "EventGrid", "EventHeroBlock", "EventStaffBlock", "EventTicketsBlock", + "EventVendorsBlock", "FAQAccordion", "FeatureCardsGrid", "FeatureComparisonTable", "FeatureGrid", "FeatureIconsGrid", + "FeatureShowcase", "FeatureTabs", "FeedCard", "FeedFilterBar", "FeedHero", "FeedLayout", + "FeedSidebar", "FileUpload", "FilterTabBar", "FundingTiers", "GettingStartedSteps", "Hero", + "IconBlockGrid", "ImageBanner", "ImageBlock", "ImageGallery", "InstagramFeed", "InstallInstructions", + "IntegrationsGrid", "IrisNavigation", "JumbotronHero", "KanbanBoard", "LeadershipGrid", "LogoMarquee", + "LogoStrip", "MapSection", "MarketingHero", "MembershipCards", "NewsletterBodyBlock", "NewsletterHeaderBlock", + "NewsletterSignup", "NodeSpecsGrid", "OrderConfirmation", "PortfolioGallery", "PortfolioGrid", "PricingPlans", + "PricingRows", "PricingTiers", "ProcessSteps", "ProcessTimeline", "ProductCard", "ProductDetailCard", + "ProductGrid", "ProductQuickView", "ProductReviews", "ProductShowcase", "ProfileContent", "ProfileEvents", + "ProfileHeader", "ProfileMemberships", "ProfileServices", "ProfileSocialFeed", "ProfileTwitchEmbed", "ProgressTracker", + "ProjectTimeline", "PromoBanner", "ProtectionPicker", "QuickActions", "QuoteBlock", "RoleSelector", + "ScatteredImageHero", "ScrollShowcase", "Section", "ServiceDetail", "ServiceListing", "ServiceMenu", + "ServicesGrid", "ShopNavigation", "ShoppingCart", "SiteFooter", "SiteNavigation", "SkillsGrid", + "SplitAccordion", "SplitContent", "StatsCounter", "StatsSection", "StepWizard", "Survey", + "TaskQueueList", "TeamSection", "TestimonialBlock", "TestimonialsSection", "TextBlock", "TimelineCarousel", + "UnifiedCheckout", "ValuePillars", "VariantSelector", "VehicleCard", "VehicleGrid", "VideoBlock", + "WidgetAreaChartCard", "WidgetChecklistCard", "WidgetProjectCard", "WidgetStatsRow", "WidgetTeamGrid", "WidgetWorkspaceBanner", + "WorkflowTrigger", "WorkspaceStudio", ]) -function validateComponents(jsonContent: any): { valid: boolean; errors: string[] } { +let _cachedValidTypes: Set | null = null + +/** + * Fetch valid component types from the API schema registry. + * Falls back to hardcoded set if API is unreachable. + */ +async function getValidComponentTypes(): Promise> { + if (_cachedValidTypes) return _cachedValidTypes + + try { + const { IRIS_API } = await import("./iris-api") + const res = await irisFetch("/api/v1/pages/schema-registry", {}, IRIS_API) + if (res.ok) { + const body = (await res.json()) as any + const types: string[] = body?.data?.types ?? [] + if (types.length > 0) { + _cachedValidTypes = new Set(types) + return _cachedValidTypes + } + } + } catch { + // API unreachable — use fallback + } + + _cachedValidTypes = FALLBACK_COMPONENT_TYPES + return _cachedValidTypes +} + +async function validateComponents(jsonContent: any): Promise<{ valid: boolean; errors: string[] }> { + const validTypes = await getValidComponentTypes() const components = jsonContent?.components ?? [] const errors: string[] = [] @@ -701,7 +770,7 @@ function validateComponents(jsonContent: any): { valid: boolean; errors: string[ errors.push(`components[${i}]: missing "type" field`) continue } - if (!VALID_COMPONENT_TYPES.has(c.type)) { + if (!validTypes.has(c.type)) { errors.push(`components[${i}]: "${c.type}" is not a valid component type`) } if (!c.id) { @@ -711,7 +780,7 @@ function validateComponents(jsonContent: any): { valid: boolean; errors: string[ if (errors.length > 0) { errors.push("") - errors.push(`Valid types: ${[...VALID_COMPONENT_TYPES].join(", ")}`) + errors.push(`Valid types: ${[...validTypes].join(", ")}`) errors.push(`Run: iris pages component-registry`) } @@ -723,30 +792,76 @@ function validateComponents(jsonContent: any): { valid: boolean; errors: string[ // ============================================================================ const COMPONENT_REGISTRY: { type: string; description: string; requiredProps: string[] }[] = [ + // Core layout { type: "Hero", description: "Full-width hero banner with title, subtitle, CTA buttons", requiredProps: ["title"] }, { type: "SiteNavigation", description: "Top navigation bar with logo, links, CTA button", requiredProps: ["logo"] }, - { type: "SiteFooter", description: "Footer with brand name, links, copyright", requiredProps: ["brandName"] }, + { type: "SiteFooter", description: "Footer with brand name, links, copyright", requiredProps: ["copyright"] }, + { type: "TextBlock", description: "Markdown/rich text content block", requiredProps: ["content"] }, { type: "AnnouncementBanner", description: "Dismissible banner strip at top of page", requiredProps: ["text"] }, - { type: "TestimonialsSection", description: "Customer testimonials with avatars and quotes", requiredProps: ["testimonials"] }, - { type: "TeamSection", description: "Team member grid with photos and roles", requiredProps: ["members"] }, - { type: "ContactSection", description: "Contact form with configurable fields", requiredProps: ["heading"] }, - { type: "LogoMarquee", description: "Auto-scrolling logo carousel (trusted by...)", requiredProps: ["logos"] }, + // Content sections { type: "FeatureShowcase", description: "Feature highlights with icons and descriptions", requiredProps: ["features"] }, - { type: "ComparisonMatrix", description: "Pricing/feature comparison table", requiredProps: ["columns", "rows"] }, - { type: "ClientGrid", description: "Client/partner logo grid", requiredProps: ["clients"] }, - { type: "CareersListing", description: "Job listings with department filters", requiredProps: ["jobs"] }, + { type: "FeatureTabs", description: "Tabbed feature showcase with images", requiredProps: ["tabs"] }, + { type: "FeatureGrid", description: "Icon grid with stat callouts", requiredProps: ["features"] }, + { type: "FeatureIconsGrid", description: "Simple icon + text feature grid", requiredProps: [] }, + { type: "ScrollShowcase", description: "Full-width scrolling cards with images (service pages)", requiredProps: ["items"] }, + { type: "ProcessSteps", description: "Numbered process steps with icons and callouts", requiredProps: ["heading", "steps"] }, + { type: "StatsSection", description: "Key metrics/stats with optional image", requiredProps: ["stats"] }, + { type: "StatsCounter", description: "Animated stat counters", requiredProps: ["stats"] }, + { type: "BenefitsSection", description: "Benefit cards with icons", requiredProps: [] }, + { type: "GettingStartedSteps", description: "Numbered getting started guide", requiredProps: [] }, + { type: "SplitContent", description: "Side-by-side text + image section", requiredProps: [] }, + { type: "EditorialSection", description: "Long-form editorial content block", requiredProps: [] }, + { type: "QuoteBlock", description: "Pull quote with attribution and CTA", requiredProps: ["quote"] }, + { type: "FAQAccordion", description: "Collapsible FAQ section", requiredProps: ["items"] }, + { type: "CommunityCTA", description: "Community join CTA (Discord, etc.)", requiredProps: [] }, + // Media + { type: "ImageBlock", description: "Single image with caption", requiredProps: ["imageUrl"] }, + { type: "VideoBlock", description: "Embedded video player", requiredProps: ["videoUrl"] }, + { type: "BeforeAfter", description: "Before/after image slider comparison", requiredProps: ["beforeImage", "afterImage"] }, { type: "PortfolioGallery", description: "Image/project gallery grid with lightbox", requiredProps: ["items"] }, - { type: "ProductGrid", description: "E-commerce product cards with prices", requiredProps: ["products"] }, - { type: "ServiceMenu", description: "Service/menu items with prices and descriptions", requiredProps: ["categories"] }, - { type: "EventGrid", description: "Event cards with dates and venues", requiredProps: ["events"] }, - { type: "FundingTiers", description: "Pricing/funding tier cards", requiredProps: ["tiers"] }, - { type: "BeforeAfter", description: "Before/after image slider comparison", requiredProps: ["before", "after"] }, - { type: "MapSection", description: "Interactive map with location markers", requiredProps: ["locations"] }, + { type: "BlogGrid", description: "Blog post card grid", requiredProps: [] }, + // Social proof + { type: "TestimonialsSection", description: "Customer testimonials (text, name, role, rating)", requiredProps: ["testimonials"] }, + { type: "TeamSection", description: "Team member grid with photos and roles", requiredProps: ["members"] }, + { type: "LogoMarquee", description: "Auto-scrolling logo carousel", requiredProps: ["logos"] }, + { type: "ClientGrid", description: "Client/partner logo grid", requiredProps: ["clients"] }, + // Conversion + { type: "ContactSection", description: "Contact form with configurable fields", requiredProps: ["heading"] }, { type: "NewsletterSignup", description: "Email signup form", requiredProps: ["heading"] }, + { type: "MapSection", description: "Interactive map with location pin", requiredProps: ["latitude", "longitude"] }, + { type: "PricingTiers", description: "Pricing tier cards with features", requiredProps: ["tiers"] }, + { type: "ComparisonMatrix", description: "Feature comparison table", requiredProps: ["plans", "features"] }, + { type: "ServiceMenu", description: "Service/menu items with prices", requiredProps: ["categories"] }, + // E-commerce + { type: "ProductGrid", description: "Product cards with prices", requiredProps: ["products"] }, + { type: "ShoppingCart", description: "Shopping cart with line items", requiredProps: [] }, + { type: "OrderConfirmation", description: "Order confirmation/receipt", requiredProps: [] }, + { type: "ProtectionPicker", description: "Protection plan selector", requiredProps: [] }, + { type: "VehicleGrid", description: "Vehicle inventory grid", requiredProps: [] }, + // Events + { type: "EventGrid", description: "Event cards with dates and venues", requiredProps: ["events"] }, + { type: "FundingTiers", description: "Funding/sponsorship tier cards", requiredProps: ["tiers"] }, + { type: "CareersListing", description: "Job listings with filters", requiredProps: ["jobs"] }, + // Interactive { type: "StepWizard", description: "Multi-step form wizard", requiredProps: ["steps"] }, { type: "FileUpload", description: "File upload dropzone", requiredProps: [] }, - { type: "ShoppingCart", description: "Shopping cart with line items", requiredProps: [] }, - { type: "OrderConfirmation", description: "Order confirmation/receipt page", requiredProps: [] }, + { type: "BookingWizard", description: "Appointment booking flow", requiredProps: [] }, + { type: "Survey", description: "Survey/questionnaire form", requiredProps: [] }, + // Dashboard widgets + { type: "WidgetWorkspaceBanner", description: "Dashboard workspace header", requiredProps: [] }, + { type: "WidgetStatsRow", description: "Row of stat cards", requiredProps: ["stats"] }, + { type: "WidgetTeamGrid", description: "Team member widget grid", requiredProps: [] }, + { type: "FilterTabBar", description: "Tab-based filter bar", requiredProps: [] }, + { type: "DataTable", description: "Sortable/searchable data table", requiredProps: ["columns"] }, + { type: "DataChart", description: "Chart visualization (bar, line, pie)", requiredProps: [] }, + { type: "ActivityFeed", description: "Chronological activity feed", requiredProps: ["items"] }, + { type: "QuickActions", description: "Quick action button grid", requiredProps: ["actions"] }, + { type: "CasePipelineBoard", description: "Kanban-style case pipeline", requiredProps: [] }, + { type: "TaskQueueList", description: "Task queue with status badges", requiredProps: ["tasks"] }, + { type: "ProgressTracker", description: "Step-by-step progress tracker", requiredProps: ["steps"] }, + { type: "CaseCard", description: "Individual case summary card", requiredProps: [] }, + { type: "DemandTracker", description: "Demand/settlement tracker", requiredProps: [] }, + { type: "CaseEconomics", description: "Case financial breakdown", requiredProps: ["lineItems"] }, ] const ComposeCmd = cmd({ @@ -832,33 +947,80 @@ const ComposeCmd = cmd({ const ComponentRegistryCmd = cmd({ command: "component-registry", aliases: ["registry", "available-components"], - describe: "list available component types for the page builder", + describe: "list available component types for the page builder (fetched from API)", builder: (y) => y.option("json", { type: "boolean" }), async handler(args) { UI.empty() prompts.intro("◈ Page Component Registry") + // Try to fetch from API (single source of truth) + let registry: { type: string; description: string; category: string; props: any }[] = [] + let source = "api" + + try { + const { IRIS_API } = await import("./iris-api") + const res = await irisFetch("/api/v1/pages/schema-registry", {}, IRIS_API) + if (res.ok) { + const body = (await res.json()) as any + const schemas = body?.data?.schemas ?? {} + registry = Object.values(schemas).map((s: any) => ({ + type: s.type, + description: s.description ?? "", + category: s.category ?? "other", + props: s.props ?? {}, + })) + } + } catch { + source = "fallback" + } + + // Fallback to hardcoded COMPONENT_REGISTRY + if (registry.length === 0) { + source = "fallback" + registry = COMPONENT_REGISTRY.map(c => ({ + type: c.type, + description: c.description, + category: "other", + props: {}, + })) + } + if (args.json) { - console.log(JSON.stringify(COMPONENT_REGISTRY, null, 2)) + console.log(JSON.stringify(registry, null, 2)) prompts.outro("Done") return } + // Group by category + const byCategory: Record = {} + for (const c of registry) { + const cat = c.category || "other" + byCategory[cat] = byCategory[cat] || [] + byCategory[cat]!.push(c) + } + console.log() console.log(` ${bold("Available components for Genesis pages:")}`) + console.log(` ${dim(`Source: ${source} · ${registry.length} components`)}`) console.log() - for (const c of COMPONENT_REGISTRY) { - console.log(` ${highlight(c.type)}`) - console.log(` ${dim(c.description)}`) - if (c.requiredProps.length) { - console.log(` ${dim("Required props: " + c.requiredProps.join(", "))}`) + + for (const [category, components] of Object.entries(byCategory).sort()) { + console.log(` ${bold(category.toUpperCase())}`) + for (const c of components) { + const requiredProps = Object.entries(c.props) + .filter(([, v]: [string, any]) => v?.required) + .map(([k]: [string, any]) => k) + console.log(` ${highlight(c.type)}`) + console.log(` ${dim(c.description)}`) + if (requiredProps.length) { + console.log(` ${dim("Required: " + requiredProps.join(", "))}`) + } } console.log() } - console.log(` ${dim(`${COMPONENT_REGISTRY.length} components available`)}`) - console.log() - prompts.log.info(`Example: ${dim('iris pages set my-page "components.1" \'{"type":"Hero","props":{"title":"Hello"}}\'')}`) - prompts.log.info(`Reference: ${dim("iris pages pull component-showcase")} — pull a working example`) + + prompts.log.info(`Schema source: ${dim(".schema.json files in iris-api/PageBuilder/")}`) + prompts.log.info(`Add new component: ${dim("create Component.schema.json next to Component.vue")}`) prompts.outro("Done") }, }) diff --git a/scaffold/how-to/README.md b/scaffold/how-to/README.md index a8f0f91653ee..207cfa34b121 100644 --- a/scaffold/how-to/README.md +++ b/scaffold/how-to/README.md @@ -12,6 +12,12 @@ This directory contains step-by-step recipes for common IRIS workflows. Each fil | "send a proposal", "create a deal", "invoice a client", "contract", "payment gate" | `lead-to-proposal.md` | | "manage deals", "deal pipeline", "deal status", "payment reminder", "stale deals", "win-back", "recover deal" | `deals.md` | | "build a page", "create a landing page", "genesis", "add components", "page builder" | `pages.md` | +| "dataset", "schema", "custom data", "store records", "atlas datasets", "create a tracker" | `atlas-datasets.md` | +| "expose data", "REST API", "public endpoint", "serve data", "embed dataset", "dashboard API" | `expose-dataset-api.md` | +| "pathways", "CFO", "cases", "servis ai", "quickbooks", "billing audit", "service AI sync" | `pathways-cfo-workflow.md` | +| "track finances", "ledger", "transactions", "revenue", "expenses", "accounts" | `track-finances-atlas-ledger.md` | +| "staff", "contractors", "team", "contracts", "signing" | `manage-staff-and-contracts.md` | +| "events", "venue", "stages", "set times", "vendors", "tickets" | `event-production.md` | ## How to use these files diff --git a/scaffold/how-to/atlas-datasets.md b/scaffold/how-to/atlas-datasets.md new file mode 100644 index 000000000000..08733544b588 --- /dev/null +++ b/scaffold/how-to/atlas-datasets.md @@ -0,0 +1,118 @@ +# How to: Use Atlas Datasets (schema-driven data) + +## What this does +Create custom datasets for any business vertical — cases, invoices, inventory, medical records, fleet vehicles — without writing code or running migrations. Define a schema once, store records against it, query/export/audit from CLI. + +## Prerequisites +- IRIS CLI authenticated (`iris auth`) +- Atlas dataset migration deployed on fl-api + +## Steps + +### 1. View available schemas +```bash +$ iris atlas:datasets schemas list +``` + +### 2. View a schema's field definitions +```bash +$ iris atlas:datasets schemas show cases +``` + +### 3. List records in a dataset +```bash +# All records +$ iris atlas:datasets records list --schema=cases + +# Filter by field value +$ iris atlas:datasets records list -s cases --filter stage_name=Negotiating + +# Search across all fields +$ iris atlas:datasets records list -s cases --search "Usman" + +# Limit results +$ iris atlas:datasets records list -s cases --limit=10 + +# Raw JSON output (for piping) +$ iris atlas:datasets records list -s cases --json +``` + +### 4. View a single record +```bash +$ iris atlas:datasets records show 1 --schema=cases +$ iris atlas:datasets records show 1 -s cases --json +``` + +### 5. Get summary stats +```bash +# Group by stage +$ iris atlas:datasets records summary -s cases --group-by stage_name + +# Sum a money field +$ iris atlas:datasets records summary -s cases --sum invoice_total + +# Both +$ iris atlas:datasets records summary -s cases --group-by stage_name --sum invoice_total +``` + +### 6. Export to CSV (for QuickBooks, Excel, etc.) +```bash +# Default CSV export (all fields) +$ iris atlas:datasets export --schema=cases + +# Specific fields only +$ iris atlas:datasets export -s cases --fields=servis_case_id,patient_name,invoice_total + +# Custom output path +$ iris atlas:datasets export -s cases --out=pathways-cases.csv + +# JSON export +$ iris atlas:datasets export -s cases --format=json -o cases.json +``` + +### 7. Run a data quality audit +```bash +$ iris atlas:datasets audit --schema=cases + +# Machine-readable output +$ iris atlas:datasets audit -s cases --json +``` + +## Expected output + +**Records list** shows case ID, patient name, stage, and key fields inline: +``` + #1 Ayesha Usman CAS103544 + dob: 1982-12-10 · stage_name: Negotiating · severity: High +``` + +**Summary** shows totals, groupings, and sums: +``` + Total Records: 22 + Sum (invoice_total): $881,386.23 + By stage_name: + Treating 16 + Negotiating 1 + Awaiting Payment 1 +``` + +**Audit** flags data quality issues by severity: +``` + WARNINGS (56) + ⚠️ CAS106139 services.Merge Health $0 billing + INFO (3) + ℹ️ CAS112725 Dirshelle Washington No services +``` + +## Common errors + +| Error | Fix | +|-------|-----| +| "Schema not found" | Check slug with `iris atlas:datasets schemas list` | +| "Authentication required" | Run `iris auth` to log in | +| Empty results | Check `--bloq` filter or remove filters | + +## Related recipes +- `track-finances-atlas-ledger` — Atlas financial transactions +- `payment-gate-contracts` — Invoicing and payment collection +- `lead-to-proposal` — Lead management pipeline diff --git a/scaffold/how-to/expose-dataset-api.md b/scaffold/how-to/expose-dataset-api.md new file mode 100644 index 000000000000..505fa2a23cce --- /dev/null +++ b/scaffold/how-to/expose-dataset-api.md @@ -0,0 +1,162 @@ +# How to: Expose Atlas dataset as a REST API + +## What this does +Serve Atlas dataset records via authenticated REST API endpoints so external apps, dashboards, or client systems can consume the data. Three methods: direct API, BloqItem public sharing, and Pages (Genesis) dashboard embedding. + +## Prerequisites +- IRIS CLI authenticated +- Atlas schema created with records +- API token (Bearer auth) for authenticated access + +## Method 1: Direct REST API (Authenticated) + +The Atlas dataset endpoints are available at `/api/v1/atlas/datasets/{schema-slug}`. These require a Bearer token (Passport OAuth or service token). + +### List records +```bash +$ curl -s https://raichu.heyiris.io/api/v1/atlas/datasets/cases \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Accept: application/json" +``` + +### Filter by field +```bash +$ curl -s "https://raichu.heyiris.io/api/v1/atlas/datasets/cases?filter[stage_name]=Negotiating" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### Search +```bash +$ curl -s "https://raichu.heyiris.io/api/v1/atlas/datasets/cases?search=Usman" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### Get summary stats +```bash +$ curl -s "https://raichu.heyiris.io/api/v1/atlas/datasets/cases/summary?group_by=stage_name&sum=invoice_total" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### Upsert (sync external data) +```bash +$ curl -s -X POST "https://raichu.heyiris.io/api/v1/atlas/datasets/cases/upsert" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_id": "CAS103544", + "data": { + "servis_case_id": "CAS103544", + "patient_name": "Ayesha Usman", + "stage_name": "Negotiating", + "invoice_total": 1940908 + } + }' +``` + +### Available endpoints +``` +GET /api/v1/atlas/schemas List all schemas +POST /api/v1/atlas/schemas Create schema +GET /api/v1/atlas/schemas/{slug} Get schema definition +PATCH /api/v1/atlas/schemas/{slug} Update schema (creates new version) + +GET /api/v1/atlas/datasets/{slug} List records (paginated) +POST /api/v1/atlas/datasets/{slug} Create record +GET /api/v1/atlas/datasets/{slug}/summary Aggregate stats +POST /api/v1/atlas/datasets/{slug}/upsert Upsert by external_id +GET /api/v1/atlas/datasets/{slug}/{id} Get single record +PATCH /api/v1/atlas/datasets/{slug}/{id} Update record +DELETE /api/v1/atlas/datasets/{slug}/{id} Soft delete record +``` + +### Query parameters for listing +| Param | Example | Description | +|-------|---------|-------------| +| filter[field] | filter[stage_name]=Treating | Exact match on JSON field | +| search | search=Usman | Full-text search across all fields | +| sort | sort=invoice_total | Sort by JSON field | +| dir | dir=desc | Sort direction (asc/desc) | +| per_page | per_page=50 | Records per page (max 200) | +| bloq_id | bloq_id=40 | Filter by bloq | +| external_id | external_id=CAS103544 | Filter by external ID | + +## Method 2: BloqItem Public Sharing (No Auth) + +Atlas records are automatically projected into BloqItems for RAG search. Each BloqItem can be made public with a UUID link. + +```bash +# Get the bloq item for a case +$ iris bloqs get 40 # Lists items in the Cases bloq list + +# Make an item public (generates shareable URL) +# This is done via the API: +$ curl -X POST "https://raichu.heyiris.io/api/v1/users/1/bloqs/40/items/{item_id}/toggle-public" \ + -H "Authorization: Bearer YOUR_TOKEN" + +# Public URL (no auth needed): +# https://elon.freelabel.net/iris/bloq/item/{public_uuid} +``` + +## Method 3: Genesis Dashboard Page + +Build a dashboard page that renders dataset data live. The Pages system fetches data from iris-api's app-data proxy. + +```bash +# Create a dashboard page for Pathways +$ iris pages compose "Pathways CFO Dashboard showing: + - Pipeline overview: cases by stage with totals + - Audit flags: services with $0 billing + - Top 10 cases by invoice value + - Financial summary: total pipeline value" + +# The page will be served at: +# https://freelabel.net/p/pathways-cfo-dashboard +``` + +The dashboard page can pull live data from the Atlas dataset API on each page load. + +## Method 4: Agent Integration (Chat-Based Access) + +IRIS agents can query datasets directly via the `manage_dataset` integration: + +```bash +# Chat with an agent that has Atlas access +$ iris agents chat "Show me all cases in Negotiating stage with invoice total over $10,000" + +# The agent calls: +# atlas manage_dataset action=query schema=cases filters={stage_name: "Negotiating"} +# Then filters results by invoice_total > 1000000 (cents) +``` + +## Example: Building a Client Dashboard + +```bash +# 1. Create the schema (one-time) +$ iris atlas:datasets schemas show cases + +# 2. Populate with data +# (via Servis AI sync or manual upsert) + +# 3. Build the page +$ iris pages compose "Dashboard for Pathways Injury Consultants" + +# 4. Share the URL with Haroon +# https://freelabel.net/p/pathways-dashboard + +# 5. Set up daily audit email +$ iris schedules create \ + --agent= \ + --frequency=daily \ + --prompt="Run audit on cases dataset, email summary to rdelgado@vanguardhcs.com" +``` + +## Security notes +- REST API requires Bearer token auth (Passport OAuth or service token) +- BloqItem public sharing is opt-in per item (is_public flag) +- Pages are public by default when published (use unpublish to restrict) +- Agent access scoped by bloq_id (user_id + bloq_id tenancy boundary) + +## Related recipes +- `atlas-datasets` — Atlas datasets CLI usage +- `pathways-cfo-workflow` — End-to-end Pathways accounting pipeline +- `pages` — Genesis page builder diff --git a/scaffold/how-to/pathways-cfo-workflow.md b/scaffold/how-to/pathways-cfo-workflow.md new file mode 100644 index 000000000000..753c54c1bda4 --- /dev/null +++ b/scaffold/how-to/pathways-cfo-workflow.md @@ -0,0 +1,114 @@ +# How to: Run the Pathways CFO Workflow (Service AI → Atlas → QuickBooks) + +## What this does +Pull case data from Servis AI, aggregate into Atlas datasets, run audits for data quality, and export to QuickBooks Desktop-compatible CSV. This is the end-to-end financial accounting pipeline for Pathways Injury Consultants. + +## Prerequisites +- IRIS CLI authenticated +- Servis AI integration connected (Client Credentials OAuth2) +- Atlas "cases" schema created (slug: `cases`, bloq: 40) + +## Steps + +### 1. Check current dataset status +```bash +# How many cases do we have? +$ iris atlas:datasets records summary -s cases --group-by stage_name --sum invoice_total + +# List all cases sorted by invoice total +$ iris atlas:datasets records list -s cases --sort invoice_total --limit=50 +``` + +### 2. Pull cases from Servis AI +Cases are ingested from Servis AI using `get_case_details` + `list_services`. Each case gets: +- Patient info (name, DOB, DOI, address) +- Case status (stage, severity, type, law firm, attorney, case manager) +- Financial data (policy limit, AR balance, invoice total) +- All services (provider, amount, dates, LOP status, type) +- Google Drive folder link + +To run a batch sync (via agent or workflow): +```bash +$ iris agents chat "Sync the latest 20 cases from Servis AI into the cases dataset" +``` + +### 3. Run the audit +```bash +# Full audit — checks for: +# - Missing required fields +# - $0 billing on services (missing amounts) +# - Cases with no services attached +# - Missing Google Drive links +$ iris atlas:datasets audit -s cases + +# JSON output for piping to other tools +$ iris atlas:datasets audit -s cases --json +``` + +### 4. Review specific cases +```bash +# Find cases in Negotiating stage +$ iris atlas:datasets records list -s cases --filter stage_name=Negotiating + +# Search by patient name +$ iris atlas:datasets records list -s cases --search "Usman" + +# View full case detail (shows all services) +$ iris atlas:datasets records show 1 -s cases +``` + +### 5. Export for QuickBooks Desktop +```bash +# Full CSV export +$ iris atlas:datasets export -s cases --out=pathways-export.csv + +# Just the fields QuickBooks needs +$ iris atlas:datasets export -s cases \ + --fields=servis_case_id,patient_name,law_firm,invoice_total,date_of_referral \ + --out=qb-import.csv +``` + +### 6. Check pipeline by stage +```bash +$ iris atlas:datasets records summary -s cases --group-by stage_name +``` + +Expected stages (from Servis AI): +``` + Intake → Coordinating Care → Treating → Packaging → + Legal Review → Negotiating → Awaiting Payment → + Processing Payment → Closed +``` + +## Data flow diagram +``` + Service AI ──→ IRIS Agent ──→ Atlas Dataset (cases) ──→ CSV Export + ↓ ↓ ↓ ↓ + Case details Aggregates Audit flags QuickBooks + + Services from Drive $0 billing Desktop + + Billing + Email Missing docs import +``` + +## Key case fields +| Field | Source | Type | +|-------|--------|------| +| servis_case_id | Servis AI seq_id (CAS######) | text | +| patient_name | Servis AI patient_name | text | +| stage_name | Servis AI stage (computed from stage_sequence) | text | +| invoice_total | Sum of all service amounts (cents) | money | +| services | Array of provider records with billing | array | +| g_drive_link | Servis AI case record | url | +| law_firm | Servis AI law_firm reference | text | + +## Common errors + +| Error | Fix | +|-------|-----| +| "Schema not found" | Schema slug is `cases` — check with `schemas list` | +| Servis AI 401 | Check SERVIS_AI_CLIENT_ID/SECRET env vars | +| $0 billing on services | Usually means billing not yet entered in Service AI — flag for Robyn | +| Duplicate case on sync | System uses `external_id` (CAS######) for dedup — safe to re-run | + +## Related recipes +- `atlas-datasets` — General Atlas datasets usage +- `track-finances-atlas-ledger` — Atlas financial transactions From 6ac5f7c31f107cff5f85c64a47014ab9ccccb3e5 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 1 May 2026 21:26:04 -0500 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20deals=20CLI=20=E2=80=94=20correct=20?= =?UTF-8?q?interval=20values,=20add=20delete/update/fees=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix deals create: billing_interval → interval (wrong payload key) - Fix deals create: choices one_time/monthly → one-time/month (API mismatch) - Add deals delete: cancel existing payment gate via CLI - Add deals update: modify amount/scope/interval/fees on existing gate - Add --pass-fees/--absorb-fees/--fee-percent/--fee-flat to create & update - Update command preserves interval from existing deal billing_type Co-Authored-By: Claude Opus 4.6 (1M context) --- .../opencode/src/cli/cmd/platform-leads.ts | 131 +++++++++++++++++- 1 file changed, 129 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-leads.ts b/packages/opencode/src/cli/cmd/platform-leads.ts index 8ded5e03aed3..f332541a4665 100644 --- a/packages/opencode/src/cli/cmd/platform-leads.ts +++ b/packages/opencode/src/cli/cmd/platform-leads.ts @@ -3215,7 +3215,11 @@ const DealsCreateCommand = cmd({ .option("bloq", { alias: "b", describe: "bloq ID", type: "number" }) .option("package", { alias: "p", describe: "package ID", type: "number" }) .option("packages", { describe: "comma-separated package IDs for multi-tier", type: "string" }) - .option("interval", { alias: "i", describe: "billing interval", type: "string", choices: ["one_time", "monthly", "quarterly", "yearly"] }) + .option("interval", { alias: "i", describe: "billing interval", type: "string", choices: ["one-time", "month", "quarter", "year"] }) + .option("pass-fees", { describe: "pass Stripe processing fees to the client (default 2.9% + $0.30)", type: "boolean" }) + .option("absorb-fees", { describe: "absorb Stripe processing fees (you pay them)", type: "boolean" }) + .option("fee-percent", { describe: "processing fee percentage (default 2.9)", type: "number" }) + .option("fee-flat", { describe: "processing fee flat amount (default 0.30)", type: "number" }) .option("no-auto-remind", { describe: "disable auto-send reminders", type: "boolean" }) .option("json", { describe: "JSON output", type: "boolean" }), async handler(args) { @@ -3227,8 +3231,13 @@ const DealsCreateCommand = 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.billing_interval = args.interval + if (args.interval) body.interval = args.interval if (args["no-auto-remind"]) body.auto_send_reminders = false + if (args["pass-fees"] || args["absorb-fees"] || args["fee-percent"] || args["fee-flat"]) { + body.processing_fee_mode = args["absorb-fees"] ? "absorb" : "pass_to_client" + if (args["fee-percent"] !== undefined) body.processing_fee_percent = args["fee-percent"] + if (args["fee-flat"] !== undefined) body.processing_fee_flat = args["fee-flat"] + } const res = await irisFetch(`/api/v1/leads/${args.id}/payment-gate`, { method: "POST", @@ -3253,6 +3262,122 @@ const DealsCreateCommand = cmd({ }, }) +const DealsDeleteCommand = cmd({ + command: "delete ", + aliases: ["cancel", "rm"], + describe: "delete/cancel an existing payment gate for a lead", + 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 + + // First check the deal exists + const statusRes = await irisFetch(`/api/v1/leads/${args.id}/deal-status`) + if (!(await handleApiError(statusRes, "Get deal status"))) return + + const statusResult = await statusRes.json().catch(() => ({})) + const status = statusResult?.data ?? statusResult + + if (!status?.has_payment_gate) { + prompts.log.error(`No payment gate for lead #${args.id}`) + return + } + + const res = await irisFetch(`/api/v1/leads/${args.id}/payment-gate`, { + method: "DELETE", + }) + if (!(await handleApiError(res, "Delete payment gate"))) return + + const result = await res.json().catch(() => ({})) + + if (args.json) { console.log(JSON.stringify(result, null, 2)); return } + + if (result?.success) { + prompts.log.success(`Payment gate deleted for lead #${args.id}`) + } else { + prompts.log.error(result?.message ?? "Failed to delete payment gate") + } + }, +}) + +const DealsUpdateCommand = cmd({ + command: "update ", + aliases: ["edit"], + describe: "update an existing payment gate (amount, scope, interval)", + builder: (yargs) => + yargs + .positional("id", { describe: "lead ID", type: "number", demandOption: true }) + .option("amount", { alias: "a", describe: "new amount in dollars", type: "number" }) + .option("scope", { alias: "s", describe: "new scope of work", type: "string" }) + .option("interval", { alias: "i", describe: "billing interval", type: "string", choices: ["one-time", "month", "quarter", "year"] }) + .option("pass-fees", { describe: "pass Stripe processing fees to the client", type: "boolean" }) + .option("absorb-fees", { describe: "absorb Stripe processing fees (you pay them)", type: "boolean" }) + .option("fee-percent", { describe: "processing fee percentage (default 2.9)", type: "number" }) + .option("fee-flat", { describe: "processing fee flat amount (default 0.30)", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + if (!args.amount && !args.scope && !args.interval && !args["pass-fees"] && !args["absorb-fees"]) { + prompts.log.error("Provide at least one field to update: --amount, --scope, --interval, or --pass-fees") + return + } + + // Get current deal to find step_id + const statusRes = await irisFetch(`/api/v1/leads/${args.id}/deal-status`) + if (!(await handleApiError(statusRes, "Get deal status"))) return + + const statusResult = await statusRes.json().catch(() => ({})) + const status = statusResult?.data ?? statusResult + + if (!status?.has_payment_gate) { + prompts.log.error(`No payment gate for lead #${args.id} — create one first: iris deals create ${args.id}`) + return + } + + // Delete existing and recreate with updated values + const delRes = await irisFetch(`/api/v1/leads/${args.id}/payment-gate`, { method: "DELETE" }) + if (!(await handleApiError(delRes, "Remove existing payment gate"))) return + + const body: Record = { + amount: args.amount ?? status.amount, + scope: args.scope ?? status.scope, + } + // Always preserve interval — check explicit flag first, then infer from existing billing_type + if (args.interval) { + body.interval = args.interval + } else if (status.billing_type) { + const mapped = { monthly: "month", quarterly: "quarter", yearly: "year", one_time: "one-time" } as Record + body.interval = mapped[status.billing_type] ?? status.billing_type + } + if (args["pass-fees"] || args["absorb-fees"] || args["fee-percent"] || args["fee-flat"]) { + body.processing_fee_mode = args["absorb-fees"] ? "absorb" : "pass_to_client" + if (args["fee-percent"] !== undefined) body.processing_fee_percent = args["fee-percent"] + if (args["fee-flat"] !== undefined) body.processing_fee_flat = args["fee-flat"] + } + + const createRes = await irisFetch(`/api/v1/leads/${args.id}/payment-gate`, { + method: "POST", + body: JSON.stringify(body), + }) + if (!(await handleApiError(createRes, "Recreate payment gate"))) return + + const result = await createRes.json().catch(() => ({})) + + if (args.json) { console.log(JSON.stringify(result, null, 2)); return } + + const data = result?.data ?? result + prompts.log.success(`Payment gate updated for lead #${args.id}`) + if (args.amount) printKV("Amount", `$${args.amount}`) + if (args.scope) printKV("Scope", args.scope.substring(0, 80) + (args.scope.length > 80 ? "…" : "")) + if (args.interval) printKV("Interval", args.interval) + if (data?.proposal_url) printKV("Proposal", data.proposal_url) + console.log(dim(`\nTrack: iris deals status ${args.id}`)) + }, +}) + export const PlatformDealsCommand = cmd({ command: "deals", aliases: ["deal", "pipeline"], @@ -3262,6 +3387,8 @@ export const PlatformDealsCommand = cmd({ .command(DealsListCommand) .command(DealsStatusCommand) .command(DealsCreateCommand) + .command(DealsUpdateCommand) + .command(DealsDeleteCommand) .command(DealsRemindCommand) .command(DealsRecoverCommand) .demandCommand(), From d435b07686a9ea034ffa07514f86d9972912ebc8 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 1 May 2026 21:41:33 -0500 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20iris=20hive=20domains=20=E2=80=94?= =?UTF-8?q?=20proxy,=20list,=20remove=20domain=20mappings=20from=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `iris hive domains proxy ` which: 1. Creates Cloudflare DNS record (via wrangler) 2. Creates Cloudflare Worker route (via wrangler) 3. Creates domain mapping in fl-api Also adds `iris hive domains list` and `iris hive domains remove`. Example: iris hive domains proxy comic https://comic-book-factory.vercel.app → comic.heyiris.io proxied to the Vercel app Co-Authored-By: Claude Opus 4.6 (1M context) --- .../opencode/src/cli/cmd/platform-hive.ts | 256 +++++++++++++++++- 1 file changed, 255 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-hive.ts b/packages/opencode/src/cli/cmd/platform-hive.ts index 2737e90b550e..d512328a40b4 100644 --- a/packages/opencode/src/cli/cmd/platform-hive.ts +++ b/packages/opencode/src/cli/cmd/platform-hive.ts @@ -4,7 +4,7 @@ import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success, highlight, getBridgeToken } from "./iris-api" // Use iris-api base for Hive endpoints -const IRIS_API = process.env.IRIS_API_URL ?? "https://heyiris.io" +const IRIS_API = process.env.IRIS_API_URL ?? "https://freelabel.net" async function hiveFetch(path: string, options: RequestInit = {}) { return irisFetch(path, options, IRIS_API) @@ -2678,6 +2678,258 @@ const HiveCredentialsCommand = cmd({ async handler() {}, }) +// ============================================================================ +// Domain Management (Cloudflare + Domain Mappings) +// ============================================================================ + +const FL_API = process.env.FL_API_URL ?? "https://raichu.heyiris.io" + +async function flApiFetch(path: string, options: RequestInit = {}) { + return irisFetch(path, options, FL_API) +} + +const HiveDomainsProxyCommand = cmd({ + command: "proxy ", + describe: "proxy a subdomain to an external URL via Cloudflare + domain mapping", + builder: (yargs) => + yargs + .positional("subdomain", { describe: "subdomain (e.g. 'comic' → comic.heyiris.io)", type: "string", demandOption: true }) + .positional("target", { describe: "target URL to proxy to (e.g. https://my-app.vercel.app)", type: "string", demandOption: true }) + .option("base-domain", { describe: "base domain", type: "string", default: "heyiris.io" }) + .option("skip-cf", { describe: "skip Cloudflare DNS/route setup (domain mapping only)", type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Domain Proxy Setup") + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const sub = args.subdomain as string + const target = args.target as string + const baseDomain = args["base-domain"] as string + const domain = sub.includes(".") ? sub : `${sub}.${baseDomain}` + const skipCf = args["skip-cf"] as boolean + + const spinner = prompts.spinner() + + // Step 1: Cloudflare DNS + Worker route + if (!skipCf) { + spinner.start("Creating Cloudflare DNS record…") + try { + const { execSync } = await import("child_process") + + // Create DNS A record (proxied, dummy IP — Worker intercepts) + try { + execSync( + `npx wrangler dns create ${baseDomain} --type A --name ${sub} --content 192.0.2.1 --proxied`, + { stdio: "pipe", timeout: 30000 } + ) + spinner.stop(success("DNS record created")) + } catch (dnsErr: any) { + const msg = dnsErr?.stderr?.toString() || dnsErr?.message || "" + if (msg.includes("already exists") || msg.includes("Record already")) { + spinner.stop(dim("DNS record already exists")) + } else { + spinner.stop("DNS failed — may need manual setup", 1) + prompts.log.warn(msg.slice(0, 200)) + } + } + + // Create Worker route + spinner.start("Creating Cloudflare Worker route…") + try { + execSync( + `npx wrangler routes create ${baseDomain} --pattern "*${domain}/*" --script iris-domain-proxy`, + { stdio: "pipe", timeout: 30000 } + ) + spinner.stop(success("Worker route created")) + } catch (routeErr: any) { + const msg = routeErr?.stderr?.toString() || routeErr?.message || "" + if (msg.includes("already exists") || msg.includes("duplicate")) { + spinner.stop(dim("Worker route already exists")) + } else { + spinner.stop("Route failed — may need manual setup", 1) + prompts.log.warn(msg.slice(0, 200)) + } + } + } catch (err) { + spinner.stop("Cloudflare setup failed", 1) + prompts.log.warn("Install wrangler or use --skip-cf. You can set up CF manually.") + } + } + + // Step 2: Create domain mapping in fl-api + spinner.start("Creating domain mapping…") + try { + const res = await flApiFetch("/api/v1/domain-mappings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + domain, + mapping_type: "proxy", + mapping_mode: "proxy", + proxy_target: target, + status: "active", + }), + }) + + if (res.status === 422) { + const err = await res.json() as Record + const errors = err.errors as Record | undefined + if (errors?.domain?.[0]?.includes("already")) { + spinner.stop(dim("Domain mapping already exists — updating")) + // Fetch existing mapping and update it + const listRes = await flApiFetch("/api/v1/domain-mappings") + const listJson = await listRes.json() as Record + const mappings = (listJson.data ?? []) as Record[] + const existing = mappings.find((m) => m.domain === domain) + if (existing) { + await flApiFetch(`/api/v1/domain-mappings/${existing.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ proxy_target: target, mapping_mode: "proxy", mapping_type: "proxy" }), + }) + prompts.log.success("Domain mapping updated") + } + } else { + spinner.stop("Failed", 1) + prompts.log.error(JSON.stringify(errors)) + prompts.outro("Done"); return + } + } else { + const ok = await handleApiError(res, "Create domain mapping") + if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + spinner.stop(success("Domain mapping created")) + } + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done"); return + } + + // Summary + printDivider() + printKV("Domain", bold(domain)) + printKV("Target", target) + printKV("Mode", "proxy") + console.log() + console.log(dim(` Test: curl -sI https://${domain}/`)) + console.log(dim(` DNS may take 1-5 min to propagate`)) + + prompts.outro("Done") + }, +}) + +const HiveDomainsListCommand = cmd({ + command: "list", + aliases: ["ls"], + describe: "list all domain mappings", + builder: (yargs) => yargs, + async handler() { + UI.empty() + prompts.intro("◈ Domain Mappings") + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const spinner = prompts.spinner() + spinner.start("Loading…") + + try { + const res = await flApiFetch("/api/v1/domain-mappings") + const ok = await handleApiError(res, "List domains") + if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + + const json = await res.json() as Record + const mappings = (json.data ?? []) as Record[] + + spinner.stop(`${mappings.length} mapping(s)`) + printDivider() + + if (mappings.length === 0) { + console.log(dim(" No domains configured. Add one with: iris hive domains proxy ")) + } else { + for (const m of mappings) { + const mode = String(m.mapping_mode ?? m.mapping_type ?? "?") + const target = m.proxy_target || (m.page_id ? `page #${m.page_id}` : m.site_id ? `site #${m.site_id}` : "?") + const status = m.status === "active" + ? `${UI.Style.TEXT_SUCCESS}● active${UI.Style.TEXT_NORMAL}` + : dim(String(m.status ?? "pending")) + console.log(` ${bold(String(m.domain))} ${dim(`[${mode}]`)} → ${target} ${status}`) + } + } + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + } + prompts.outro("Done") + }, +}) + +const HiveDomainsRemoveCommand = cmd({ + command: "remove ", + aliases: ["rm", "delete"], + describe: "remove a domain mapping", + builder: (yargs) => + yargs.positional("domain", { describe: "domain to remove", type: "string", demandOption: true }), + async handler(args) { + UI.empty() + prompts.intro("◈ Remove Domain Mapping") + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const domain = args.domain as string + const spinner = prompts.spinner() + spinner.start("Finding mapping…") + + try { + const listRes = await flApiFetch("/api/v1/domain-mappings") + const listJson = await listRes.json() as Record + const mappings = (listJson.data ?? []) as Record[] + const mapping = mappings.find((m) => String(m.domain) === domain) + + if (!mapping) { + spinner.stop("Not found", 1) + prompts.log.error(`No mapping found for ${domain}`) + prompts.outro("Done"); return + } + + spinner.stop(`Found: ${domain} → ${mapping.proxy_target || `page #${mapping.page_id}`}`) + + const confirmed = await prompts.confirm({ message: `Delete mapping for ${bold(domain)}?` }) + if (!confirmed || prompts.isCancel(confirmed)) { + prompts.outro("Cancelled"); return + } + + spinner.start("Deleting…") + const res = await flApiFetch(`/api/v1/domain-mappings/${mapping.id}`, { method: "DELETE" }) + const ok = await handleApiError(res, "Delete mapping") + if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + + spinner.stop(success("Domain mapping removed")) + console.log(dim(" Note: Cloudflare DNS record and Worker route are still active.")) + console.log(dim(" Remove those manually in Cloudflare dashboard if needed.")) + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + } + prompts.outro("Done") + }, +}) + +const HiveDomainsCommand = cmd({ + command: "domains", + describe: "manage domain mappings and proxies", + builder: (yargs) => + yargs + .command(HiveDomainsProxyCommand) + .command(HiveDomainsListCommand) + .command(HiveDomainsRemoveCommand) + .demandCommand(1, "Specify: proxy, list, or remove"), + async handler() {}, +}) + // ============================================================================ // Root command // ============================================================================ @@ -2722,6 +2974,8 @@ export const PlatformHiveCommand = cmd({ .command(HiveExecCommand) // Credentials .command(HiveCredentialsCommand) + // Domain management + .command(HiveDomainsCommand) .demandCommand(), async handler() {}, })