diff --git a/.env.example b/.env.example index b215ef4..77b1948 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,10 @@ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= # Legacy alias (also supported) # SUPABASE_SECRET_KEY= +# Optional: auth.users UUID that owns mashups published by `yarn generate:mashup`. +# Not a secret; no Dodo credits are debited. If unset, the CLI looks up the superadmin user. +# GENERATION_OPS_CREATOR_ID= + # Supabase Storage bucket id (see supabase/migrations/*storage*.sql) # The bucket is PUBLIC: generated images (and card/detail/og variants) are served # directly from Supabase's CDN. Only override these if you renamed the bucket. diff --git a/README.md b/README.md index d1f0c8a..0b47634 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,34 @@ Built with **Next.js** and **Supabase**. Optional: local Supabase (`yarn db:start`) and keys for image generation / payments—see comments in `.env.example`. +## Ship mashups (ops CLI) + +Do not generate mashups by clicking the website. `yarn generate:mashup` is the publish path: it reuses Style DNA merge, `buildGenerationPrompt`, `executeImageGeneration` (Vercel AI Gateway), sharp variants, the public `generation-images` bucket, and a **published** `generations` row. It does not debit Dodo credits. + +```bash +yarn generate:mashup --help +``` + +`--dry-run` (or `DRY_RUN=1`) prints the fully built prompt and skips the paid Gateway call, upload, and DB insert. + +Example pairings (live picker slugs; extras are prompt notes, not new catalog nouns): + +```bash +yarn generate:mashup --builder ikea --target figma --invented-name SKISSA \ + --extra-details 'Empty Figma canvas. Microcopy: "Some assembly required." The move tool is an Allen key.' + +yarn generate:mashup --builder apple-ios --target tinder --screen-type mobile --invented-name Halo \ + --extra-details 'Tinder deck plus a Personality slider.' + +yarn generate:mashup --builder duolingo --target apple-ios --screen-type mobile --invented-name Perch \ + --extra-details 'Lock screen. Streak dying.' + +yarn generate:mashup --builder google --target google-gmail --invented-name Burst \ + --extra-details 'Gmail compose with 8× Send.' +``` + +Always pass `--dry-run` first. Do not run paid generation from CI. + ## License MIT — see [LICENSE](./LICENSE). diff --git a/package.json b/package.json index 40c2b16..63f99c2 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "sb": "supabase", "sb:login": "supabase login", "ai:image": "tsx index.ts", + "generate:mashup": "tsx scripts/generate-mashup.ts", "test": "vitest --run" }, "dependencies": { diff --git a/scripts/generate-mashup.ts b/scripts/generate-mashup.ts new file mode 100644 index 0000000..4994b55 --- /dev/null +++ b/scripts/generate-mashup.ts @@ -0,0 +1,80 @@ +/** + * Ops mashup generator — ships published generations through the production + * pipeline. Do not generate mashups by clicking the website. + * + * Usage: + * yarn generate:mashup --help + * yarn generate:mashup --builder ikea --target figma --dry-run + * + * Secrets from env only (.env.local). Never commit keys. + */ + +import { config } from "dotenv"; +import { resolve } from "node:path"; + +import { + assertMashupArgs, + MASHUP_HELP, + parseMashupArgs, +} from "@/lib/ops/mashup-cli"; +import { + generateMashup, + type MashupDryRunResult, + type MashupPublishedResult, +} from "@/lib/ops/mashup-run"; +import { generationVariantObjectPath } from "@/lib/generation-media-url"; + +config({ path: resolve(process.cwd(), ".env.local"), quiet: true }); + +function printDryRun(result: MashupDryRunResult) { + console.log("--- mashup (dry-run) ---"); + console.log(`builder: ${result.builder.name} (${result.builder.id})`); + console.log(`target: ${result.target.name} (${result.target.id})`); + console.log(`screen: ${result.screenType}`); + console.log(`model: ${result.imageModel} (skipped)`); + console.log("Skipping AI Gateway, storage upload, and DB insert."); + console.log(""); + console.log("--- prompt ---"); + console.log(result.prompt); +} + +function printPublished(result: MashupPublishedResult) { + console.log("--- mashup published ---"); + console.log(`builder: ${result.builder.name} (${result.builder.id})`); + console.log(`target: ${result.target.name} (${result.target.id})`); + console.log(`id: ${result.id}`); + console.log(`slug: ${result.slug}`); + console.log(`image: ${result.imagePath}`); + console.log( + `variants: ${generationVariantObjectPath(result.imagePath, "card")}, ${generationVariantObjectPath(result.imagePath, "detail")}, ${generationVariantObjectPath(result.imagePath, "og")}`, + ); +} + +async function main() { + const args = parseMashupArgs(process.argv.slice(2)); + if (args.help || process.argv.slice(2).length === 0) { + console.log(MASHUP_HELP); + process.exit(args.help ? 0 : 1); + } + + assertMashupArgs(args); + const result = await generateMashup(args); + switch (result.kind) { + case "dry-run": + printDryRun(result); + return; + case "published": + printPublished(result); + return; + default: { + const _exhaustive: never = result; + throw new Error(`Unhandled mashup result: ${JSON.stringify(_exhaustive)}`); + } + } +} + +main().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.error(msg); + process.exit(1); +}); diff --git a/src/data/company-profiles.ts b/src/data/company-profiles.ts index dfdd6e6..fffb810 100644 --- a/src/data/company-profiles.ts +++ b/src/data/company-profiles.ts @@ -206,6 +206,34 @@ export async function listSelectableProfileIds(): Promise { return (data ?? []).map((r: { id: string }) => r.id); } +export type SelectableProfileLookup = { + id: string; + name: string; +}; + +/** + * Slug + display name for selectable picker nouns (companies + approved products). + * Used by the ops mashup CLI; catalog JSON is not a live source. + */ +export async function listSelectableProfileLookups(): Promise< + SelectableProfileLookup[] +> { + const supabase = createSupabaseServiceClient(); + const { data, error } = await supabase + .from("company_profiles") + .select("id, name") + .or( + "profile_type.eq.company,and(profile_type.eq.product,research_status.eq.approved)", + ) + .order("name"); + + if (error) throw error; + return (data ?? []).map((r: { id: string; name: string }) => ({ + id: r.id, + name: r.name, + })); +} + /** * Groups for the generator picker: all companies plus approved products only. */ diff --git a/src/data/generator-profile-options.ts b/src/data/generator-profile-options.ts index 70ff100..bbc7b4c 100644 --- a/src/data/generator-profile-options.ts +++ b/src/data/generator-profile-options.ts @@ -80,6 +80,62 @@ export function resolveProfileIdByName( return null; } +export type ProfileLookup = { + id: string; + name: string; +}; + +export class AmbiguousProfileError extends Error { + readonly matches: ProfileLookup[]; + + constructor(query: string, matches: ProfileLookup[]) { + super( + `Ambiguous profile "${query}". Matches: ${matches + .map((m) => `${m.id} (${m.name})`) + .join(", ")}. Use a unique slug.`, + ); + this.name = "AmbiguousProfileError"; + this.matches = matches; + } +} + +export class UnknownProfileError extends Error { + constructor(query: string) { + super( + `No selectable company_profiles row matches "${query}". Use a slug (ikea, apple-ios, google-gmail) or an exact name.`, + ); + this.name = "UnknownProfileError"; + } +} + +/** + * Resolve a picker noun by slug (`id`) first, then exact name (case-insensitive). + * Unlike {@link resolveProfileIdByName}, ambiguous names fail instead of taking the first hit. + */ +export function resolveProfileLookup( + query: string, + profiles: ProfileLookup[], +): ProfileLookup { + const q = query.trim().toLowerCase(); + if (!q) { + throw new UnknownProfileError(query); + } + + const idMatches = profiles.filter((p) => p.id.toLowerCase() === q); + if (idMatches.length === 1) return idMatches[0]!; + if (idMatches.length > 1) { + throw new AmbiguousProfileError(query, idMatches); + } + + const nameMatches = profiles.filter((p) => p.name.toLowerCase() === q); + if (nameMatches.length === 1) return nameMatches[0]!; + if (nameMatches.length > 1) { + throw new AmbiguousProfileError(query, nameMatches); + } + + throw new UnknownProfileError(query); +} + export function groupsFromProfiles(all: CompanyProfile[]): CompanyGroup[] { const companies = all.filter((p) => p.profileType === "company"); const products = all.filter( diff --git a/src/lib/ops/__tests__/mashup-cli.test.ts b/src/lib/ops/__tests__/mashup-cli.test.ts new file mode 100644 index 0000000..3cc9c93 --- /dev/null +++ b/src/lib/ops/__tests__/mashup-cli.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; + +import { + flattenGeneratorProfileGroups, + resolveProfileLookup, + AmbiguousProfileError, + UnknownProfileError, +} from "@/data/generator-profile-options"; +import { mockGeneratorProfileGroups } from "@/data/test-fixtures/profile-groups"; +import { + assembleMashupPrompt, + assertMashupArgs, + combineUserExtraDetails, + MASHUP_HELP, + parseMashupArgs, +} from "@/lib/ops/mashup-cli"; + +describe("parseMashupArgs", () => { + it("parses builder, target, extras, invented name, and screen type", () => { + const args = parseMashupArgs( + [ + "--builder", + "ikea", + "--target", + "figma", + "--invented-name", + "SKISSA", + "--extra-details", + 'Empty Figma canvas. "Some assembly required."', + "--screen-type", + "desktop", + ], + {}, + ); + expect(args.builder).toBe("ikea"); + expect(args.target).toBe("figma"); + expect(args.inventedName).toBe("SKISSA"); + expect(args.extraDetails).toContain("Some assembly required"); + expect(args.screenType).toBe("desktop"); + expect(args.dryRun).toBe(false); + expect(args.help).toBe(false); + }); + + it("parses short flags and --dry-run", () => { + const args = parseMashupArgs( + [ + "-b", + "apple-ios", + "-t", + "tinder", + "-e", + "Tinder deck plus a Personality slider.", + "--name", + "Halo", + "--screen", + "mobile", + "--dry-run", + ], + {}, + ); + expect(args.builder).toBe("apple-ios"); + expect(args.target).toBe("tinder"); + expect(args.inventedName).toBe("Halo"); + expect(args.screenType).toBe("mobile"); + expect(args.dryRun).toBe(true); + }); + + it("honors DRY_RUN=1 without hitting the gateway path", () => { + const args = parseMashupArgs(["--builder", "ikea", "--target", "figma"], { + DRY_RUN: "1", + }); + expect(args.dryRun).toBe(true); + }); + + it("parses --flag=value forms used by the four pairings", () => { + const ikea = parseMashupArgs( + ["--builder=ikea", "--target=figma", "--invented-name=SKISSA"], + {}, + ); + expect(ikea.builder).toBe("ikea"); + expect(ikea.target).toBe("figma"); + expect(ikea.inventedName).toBe("SKISSA"); + + const duolingo = parseMashupArgs( + [ + "--builder=duolingo", + "--target=apple-ios", + "--invented-name=Perch", + "--extra-details=Lock screen. Streak dying.", + ], + {}, + ); + expect(duolingo.builder).toBe("duolingo"); + expect(duolingo.target).toBe("apple-ios"); + + const google = parseMashupArgs( + ["--builder=google", "--target=google-gmail", "--invented-name=Burst"], + {}, + ); + expect(google.builder).toBe("google"); + expect(google.target).toBe("google-gmail"); + }); + + it("treats --help as documentation, not a generate", () => { + const args = parseMashupArgs(["--help"]); + expect(args.help).toBe(true); + assertMashupArgs(args); + }); + + it("requires builder and target unless --help", () => { + expect(() => assertMashupArgs(parseMashupArgs(["--dry-run"]))).toThrow( + /--builder and --target/, + ); + }); + + it("rejects unknown flags", () => { + expect(() => parseMashupArgs(["--midjourney"])).toThrow(/Unknown flag/); + }); +}); + +describe("MASHUP_HELP", () => { + it("documents flags and the four example pairings", () => { + expect(MASHUP_HELP).toContain("--builder"); + expect(MASHUP_HELP).toContain("--target"); + expect(MASHUP_HELP).toContain("--dry-run"); + expect(MASHUP_HELP).toContain("SKISSA"); + expect(MASHUP_HELP).toContain("Halo"); + expect(MASHUP_HELP).toContain("Perch"); + expect(MASHUP_HELP).toContain("Burst"); + expect(MASHUP_HELP).toContain("ikea"); + expect(MASHUP_HELP).toContain("apple-ios"); + expect(MASHUP_HELP).toContain("tinder"); + expect(MASHUP_HELP).toContain("duolingo"); + expect(MASHUP_HELP).toContain("google-gmail"); + }); +}); + +describe("resolveProfileLookup", () => { + const profiles = flattenGeneratorProfileGroups(mockGeneratorProfileGroups()).map( + (p) => ({ id: p.id, name: p.name }), + ); + + it("resolves slug before name", () => { + expect(resolveProfileLookup("ikea", profiles)).toEqual({ + id: "ikea", + name: "IKEA", + }); + expect(resolveProfileLookup("google-youtube", profiles).id).toBe( + "google-youtube", + ); + }); + + it("resolves exact name case-insensitively", () => { + expect(resolveProfileLookup("YouTube", profiles).id).toBe("google-youtube"); + }); + + it("fails clearly when a name is ambiguous", () => { + const withDupes = [ + ...profiles, + { id: "ios-lock", name: "iOS" }, + { id: "apple-ios", name: "iOS" }, + ]; + expect(() => resolveProfileLookup("iOS", withDupes)).toThrow( + AmbiguousProfileError, + ); + expect(() => resolveProfileLookup("iOS", withDupes)).toThrow(/apple-ios/); + expect(() => resolveProfileLookup("iOS", withDupes)).toThrow(/ios-lock/); + }); + + it("fails clearly when nothing matches", () => { + expect(() => resolveProfileLookup("midjourney", profiles)).toThrow( + UnknownProfileError, + ); + }); +}); + +describe("dry-run prompt assembly", () => { + it("includes style merge extras, invented name, and user notes in the built prompt", () => { + const prompt = assembleMashupPrompt({ + builder: "IKEA", + target: "Figma", + mergedExtraDetails: "Style DNA (IKEA): Tone: instruction-manual.", + extraDetails: + 'Empty Figma canvas. Microcopy: "Some assembly required." The move tool is an Allen key.', + inventedName: "SKISSA", + screenType: "desktop", + }); + expect(prompt).toContain("IKEA"); + expect(prompt).toContain("Figma"); + expect(prompt).toContain("Style DNA (IKEA)"); + expect(prompt).toContain("SKISSA"); + expect(prompt).toContain("Some assembly required"); + expect(prompt).toContain("Allen key"); + expect(prompt).toContain("16:9"); + expect(prompt).toContain("Additional notes from user"); + }); + + it("does not invent a second prompt path when extras are empty", () => { + const merged = "Style DNA (Google): Colors: blue."; + expect(combineUserExtraDetails(merged, { extraDetails: "", inventedName: "" })).toBe( + merged, + ); + }); +}); diff --git a/src/lib/ops/mashup-cli.ts b/src/lib/ops/mashup-cli.ts new file mode 100644 index 0000000..0be4fd6 --- /dev/null +++ b/src/lib/ops/mashup-cli.ts @@ -0,0 +1,206 @@ +import { buildGenerationPrompt } from "@/lib/prompt/build-generation-prompt"; +import { normalizeRenderMode } from "@/lib/screen-type"; + +export type MashupCliArgs = { + builder: string; + target: string; + extraDetails: string; + inventedName: string; + screenType: string | null; + dryRun: boolean; + help: boolean; + creatorId: string | null; +}; + +const FLAG_ALIASES: Record = { + "-b": "--builder", + "-t": "--target", + "-e": "--extra-details", + "--extra": "--extra-details", + "--name": "--invented-name", + "--screen": "--screen-type", + "-h": "--help", +}; + +export const MASHUP_HELP = `Usage: + yarn generate:mashup --builder --target [options] + +Ops CLI for shipping mashups. Do not generate by clicking the website. +Reuses the production path: Style DNA merge (mergeCompanyPair) → +buildGenerationPrompt → executeImageGeneration (Vercel AI Gateway, +default openai/gpt-image-2) → sharp card/detail/og variants → upload to +the public generation-images bucket → insert a published generations row. +Does not debit Dodo credits and does not require a logged-in session. + +Options: + --builder, -b Builder slug or exact name (live company_profiles) + --target, -t Target slug or exact name + --extra-details, -e Prompt extras (not new catalog nouns) + --invented-name Invented on-screen product name + --screen-type mobile | desktop (default: desktop, same as /api/generate) + --dry-run Print the fully built prompt; skip Gateway, upload, DB insert + --creator-id auth.users UUID that owns the published row + -h, --help Show this help + +Env: + DRY_RUN=1 Same as --dry-run + GENERATION_OPS_CREATOR_ID + Default creator UUID if --creator-id is omitted + AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN + SUPABASE_SERVICE_ROLE_KEY or SUPABASE_SECRET_KEY + NEXT_PUBLIC_SUPABASE_URL + Optional: AI_GATEWAY_IMAGE_MODEL, GENERATION_IMAGES_BUCKET, + NEXT_PUBLIC_GENERATION_IMAGES_BUCKET + +Catalog JSON in the repo is inert until imported. Nouns resolve from +Supabase company_profiles (slug first, then exact name). Ambiguous names fail. + +Example pairings (picker brands already live; extras are prompt notes): + + yarn generate:mashup --builder ikea --target figma --invented-name SKISSA \\ + --extra-details 'Empty Figma canvas. Microcopy: "Some assembly required." The move tool is an Allen key.' + + yarn generate:mashup --builder apple-ios --target tinder --screen-type mobile --invented-name Halo \\ + --extra-details 'Tinder deck plus a Personality slider.' + + yarn generate:mashup --builder duolingo --target apple-ios --screen-type mobile --invented-name Perch \\ + --extra-details 'Lock screen. Streak dying.' + + yarn generate:mashup --builder google --target google-gmail --invented-name Burst \\ + --extra-details 'Gmail compose with 8× Send.' + +Dry-run (no paid image call): + yarn generate:mashup --builder ikea --target figma --dry-run +`; + +function envDryRun(env: Record): boolean { + const raw = env.DRY_RUN?.trim(); + return raw === "1" || raw?.toLowerCase() === "true"; +} + +function canonicalFlag(raw: string): string { + return FLAG_ALIASES[raw] ?? raw; +} + +function splitFlag(arg: string): { flag: string; value: string | undefined } { + const eq = arg.indexOf("="); + if (eq === -1) return { flag: canonicalFlag(arg), value: undefined }; + return { + flag: canonicalFlag(arg.slice(0, eq)), + value: arg.slice(eq + 1), + }; +} + +export function parseMashupArgs( + argv: string[], + env: Record = process.env, +): MashupCliArgs { + const out: MashupCliArgs = { + builder: "", + target: "", + extraDetails: "", + inventedName: "", + screenType: null, + dryRun: envDryRun(env), + help: false, + creatorId: null, + }; + + const args = argv.filter((a) => a !== "--"); + + for (let i = 0; i < args.length; i++) { + const raw = args[i]!; + if (!raw.startsWith("-")) { + throw new Error(`Unexpected argument "${raw}". See --help.`); + } + + const { flag, value } = splitFlag(raw); + + const takeValue = (): string => { + if (value !== undefined) return value; + const next = args[i + 1]; + if (!next || next.startsWith("-")) { + throw new Error(`Missing value for ${flag}`); + } + i += 1; + return next; + }; + + switch (flag) { + case "--help": + out.help = true; + break; + case "--dry-run": + out.dryRun = true; + break; + case "--builder": + out.builder = takeValue(); + break; + case "--target": + out.target = takeValue(); + break; + case "--extra-details": + out.extraDetails = takeValue(); + break; + case "--invented-name": + out.inventedName = takeValue(); + break; + case "--screen-type": + out.screenType = takeValue(); + break; + case "--creator-id": + out.creatorId = takeValue(); + break; + default: + throw new Error(`Unknown flag "${raw}". See --help.`); + } + } + + return out; +} + +export function assertMashupArgs(args: MashupCliArgs): void { + if (args.help) return; + if (!args.builder.trim() || !args.target.trim()) { + throw new Error("Both --builder and --target are required. See --help."); + } +} + +export function combineUserExtraDetails( + mergedExtra: string, + opts: { extraDetails: string; inventedName: string }, +): string { + const notes: string[] = []; + const invented = opts.inventedName.trim(); + if (invented) { + notes.push( + `Invented on-screen product name: ${invented}. Use this invented name in the UI chrome; do not show real brand names as logos.`, + ); + } + const extra = opts.extraDetails.trim(); + if (extra) notes.push(extra); + if (notes.length === 0) return mergedExtra; + return `${mergedExtra} + +Additional notes from user: +${notes.join("\n")}`; +} + +export function assembleMashupPrompt(input: { + builder: string; + target: string; + mergedExtraDetails: string; + extraDetails: string; + inventedName: string; + screenType: string; +}): string { + return buildGenerationPrompt({ + builder: input.builder, + target: input.target, + extraDetails: combineUserExtraDetails(input.mergedExtraDetails, { + extraDetails: input.extraDetails, + inventedName: input.inventedName, + }), + screenType: normalizeRenderMode(input.screenType), + }); +} diff --git a/src/lib/ops/mashup-run.ts b/src/lib/ops/mashup-run.ts new file mode 100644 index 0000000..33b284b --- /dev/null +++ b/src/lib/ops/mashup-run.ts @@ -0,0 +1,258 @@ +import { listSelectableProfileLookups } from "@/data/company-profiles"; +import { + resolveProfileLookup, + type ProfileLookup, +} from "@/data/generator-profile-options"; +import { SUPERADMIN_EMAIL } from "@/lib/admin-constants"; +import { + assertAiGatewayConfigured, + getGenerationImagesBucket, +} from "@/lib/env-server"; +import { updateGenerationStatus } from "@/lib/generation/db"; +import { executeImageGeneration } from "@/lib/generation/execute-image"; +import { + assembleMashupPrompt, + combineUserExtraDetails, + type MashupCliArgs, +} from "@/lib/ops/mashup-cli"; +import { mergeCompanyPair } from "@/lib/prompt/merge-company-pair"; +import { normalizeRenderMode } from "@/lib/screen-type"; +import { makeGenerationSlugSnippet } from "@/lib/slug"; +import { createSupabaseServiceClient } from "@/lib/supabase/service"; +import { sanitizeVibeTags } from "@/lib/vibe-tags"; + +const DEFAULT_GATEWAY_IMAGE_MODEL = "openai/gpt-image-2"; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type MashupDryRunResult = { + kind: "dry-run"; + builder: ProfileLookup; + target: ProfileLookup; + screenType: string; + imageModel: string; + extraDetails: string; + prompt: string; +}; + +export type MashupPublishedResult = { + kind: "published"; + builder: ProfileLookup; + target: ProfileLookup; + screenType: string; + imageModel: string; + extraDetails: string; + prompt: string; + id: number; + slug: string; + imagePath: string; +}; + +export type MashupGenerateResult = MashupDryRunResult | MashupPublishedResult; + +function gatewayImageModel(): string { + return process.env.AI_GATEWAY_IMAGE_MODEL?.trim() || DEFAULT_GATEWAY_IMAGE_MODEL; +} + +function isUuid(value: string): boolean { + return UUID_RE.test(value.trim()); +} + +async function resolveOpsCreatorId(explicit: string | null): Promise { + const fromFlag = explicit?.trim() ?? ""; + const fromEnv = process.env.GENERATION_OPS_CREATOR_ID?.trim() ?? ""; + const candidate = fromFlag || fromEnv; + if (candidate) { + if (!isUuid(candidate)) { + throw new Error("creator id must be a UUID (auth.users id)"); + } + return candidate; + } + + const supabase = createSupabaseServiceClient(); + const perPage = 200; + for (let page = 1; page <= 10; page++) { + const { data, error } = await supabase.auth.admin.listUsers({ + page, + perPage, + }); + if (error) { + throw new Error(`Could not list auth users: ${error.message}`); + } + const match = data.users.find((u) => u.email === SUPERADMIN_EMAIL); + if (match) return match.id; + if (data.users.length < perPage) break; + } + + throw new Error( + "Could not resolve a creator for the published row. Pass --creator-id or set GENERATION_OPS_CREATOR_ID. No credits are debited.", + ); +} + +async function resolvePair( + builderQuery: string, + targetQuery: string, +): Promise<{ builder: ProfileLookup; target: ProfileLookup }> { + const profiles = await listSelectableProfileLookups(); + const builder = resolveProfileLookup(builderQuery, profiles); + const target = resolveProfileLookup(targetQuery, profiles); + if (builder.id === target.id) { + throw new Error("builder and target must resolve to different profiles"); + } + return { builder, target }; +} + +async function insertPublishedGeneration(args: { + creatorId: string; + builderName: string; + targetName: string; + extraDetails: string; + prompt: string; + screenType: string; + vibeTags: string[]; +}): Promise<{ id: number; slug: string; objectPath: string }> { + const supabase = createSupabaseServiceClient(); + const baseSlug = makeGenerationSlugSnippet({ + builder: args.builderName, + target: args.targetName, + }); + const plannedExt = "png"; + + for (let attempt = 0; attempt < 8; attempt++) { + const slug = + attempt === 0 ? baseSlug : `${baseSlug.slice(0, 32)}-${attempt}`; + const objectPath = `${args.creatorId}/${slug}.${plannedExt}`; + + const { data: row, error: insErr } = await supabase + .from("generations") + .insert({ + creator_id: args.creatorId, + slug, + builder: args.builderName, + target: args.targetName, + tone: "", + vibe_tags: sanitizeVibeTags(args.vibeTags), + screen_type: args.screenType, + region: "", + extra_details: args.extraDetails, + generated_prompt: args.prompt, + image_path: objectPath, + visibility: "published", + moderation_status: "visible", + status: "queued", + image_ready: false, + }) + .select("id, slug") + .maybeSingle(); + + if (!insErr && row) { + return { id: row.id, slug: row.slug, objectPath }; + } + if (insErr?.code === "23505") continue; + throw new Error( + `Could not insert generation: ${insErr?.message ?? "unknown error"}`, + ); + } + + throw new Error("Could not allocate unique slug"); +} + +/** + * Build the production prompt and either return it (--dry-run) or publish + * through executeImageGeneration (no Dodo debit). + */ +export async function generateMashup( + args: MashupCliArgs, +): Promise { + const { builder, target } = await resolvePair(args.builder, args.target); + const merged = await mergeCompanyPair(builder.id, target.id); + const screenType = normalizeRenderMode(args.screenType ?? "desktop"); + const extraDetails = combineUserExtraDetails(merged.extraDetails, { + extraDetails: args.extraDetails, + inventedName: args.inventedName, + }); + const prompt = assembleMashupPrompt({ + builder: merged.builder, + target: merged.target, + mergedExtraDetails: merged.extraDetails, + extraDetails: args.extraDetails, + inventedName: args.inventedName, + screenType, + }); + const imageModel = gatewayImageModel(); + + if (args.dryRun) { + return { + kind: "dry-run", + builder, + target, + screenType, + imageModel, + extraDetails, + prompt, + }; + } + + assertAiGatewayConfigured(); + const creatorId = await resolveOpsCreatorId(args.creatorId); + const inserted = await insertPublishedGeneration({ + creatorId, + builderName: merged.builder, + targetName: merged.target, + extraDetails, + prompt, + screenType, + vibeTags: merged.builderDefaultVibeTags, + }); + + try { + await updateGenerationStatus(inserted.id, { + status: "processing", + startedAt: new Date().toISOString(), + errorMessage: null, + }); + + const image = await executeImageGeneration({ + generationId: inserted.id, + userId: creatorId, + dodoCustomerId: "", + entitlementId: "", + bucket: getGenerationImagesBucket(), + objectPath: inserted.objectPath, + prompt, + imageModel, + builderId: builder.id, + builderName: merged.builder, + renderMode: screenType, + }); + + await updateGenerationStatus(inserted.id, { + status: "completed", + imagePath: image.imagePath, + imageReady: true, + completedAt: new Date().toISOString(), + errorMessage: null, + }); + + return { + kind: "published", + builder, + target, + screenType, + imageModel, + extraDetails, + prompt, + id: inserted.id, + slug: inserted.slug, + imagePath: image.imagePath, + }; + } catch (e) { + const msg = e instanceof Error ? e.message : "Generation failed"; + await updateGenerationStatus(inserted.id, { + status: "failed", + errorMessage: msg, + completedAt: new Date().toISOString(), + }); + throw e; + } +}