Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ Optional: local Supabase (`yarn db:start`) and keys for image generation / payme

## 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.
Do not generate mashups by clicking the website. `yarn generate:mashup` reuses Style DNA merge, `buildGenerationPrompt`, `executeImageGeneration` (Vercel AI Gateway), sharp variants, the public `generation-images` bucket, and a `generations` row. The default insert is **draft** (not on the public feed, sitemap, or locker). Pass `--publish` only after reviewing the image. 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.
`--dry-run` (or `DRY_RUN=1`) prints the fully built prompt and skips the paid Gateway call, upload, and DB insert. `--publish` inserts `visibility=published` so the row is listed publicly.

Example pairings (live picker slugs; extras are prompt notes, not new catalog nouns):

Expand Down
30 changes: 23 additions & 7 deletions scripts/generate-mashup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Ops mashup generator — ships published generations through the production
* pipeline. Do not generate mashups by clicking the website.
* Ops mashup generator — ships generations through the production pipeline.
* Default insert is draft (unlisted). Pass --publish only after review.
* Do not generate mashups by clicking the website.
*
* Usage:
* yarn generate:mashup --help
Expand All @@ -20,7 +21,7 @@ import {
import {
generateMashup,
type MashupDryRunResult,
type MashupPublishedResult,
type MashupInsertedResult,
} from "@/lib/ops/mashup-run";
import { generationVariantObjectPath } from "@/lib/generation-media-url";

Expand All @@ -38,16 +39,31 @@ function printDryRun(result: MashupDryRunResult) {
console.log(result.prompt);
}

function printPublished(result: MashupPublishedResult) {
console.log("--- mashup published ---");
function printInserted(result: MashupInsertedResult) {
switch (result.visibility) {
case "published":
console.log("--- mashup published ---");
break;
case "draft":
console.log("--- mashup saved (draft) ---");
break;
default: {
const _exhaustive: never = result.visibility;
throw new Error(`Unhandled mashup visibility: ${String(_exhaustive)}`);
}
}
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(`visibility: ${result.visibility}`);
console.log(`image: ${result.imagePath}`);
console.log(
`variants: ${generationVariantObjectPath(result.imagePath, "card")}, ${generationVariantObjectPath(result.imagePath, "detail")}, ${generationVariantObjectPath(result.imagePath, "og")}`,
);
if (result.visibility === "draft") {
console.log("Not listed on the public feed, sitemap, or locker. Pass --publish after review.");
}
}

async function main() {
Expand All @@ -63,8 +79,8 @@ async function main() {
case "dry-run":
printDryRun(result);
return;
case "published":
printPublished(result);
case "inserted":
printInserted(result);
return;
default: {
const _exhaustive: never = result;
Expand Down
29 changes: 29 additions & 0 deletions src/lib/ops/__tests__/mashup-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
assembleMashupPrompt,
assertMashupArgs,
combineUserExtraDetails,
mashupListingFields,
MASHUP_HELP,
parseMashupArgs,
} from "@/lib/ops/mashup-cli";
Expand Down Expand Up @@ -38,6 +39,7 @@ describe("parseMashupArgs", () => {
expect(args.extraDetails).toContain("Some assembly required");
expect(args.screenType).toBe("desktop");
expect(args.dryRun).toBe(false);
expect(args.publish).toBe(false);
expect(args.help).toBe(false);
});

Expand All @@ -63,6 +65,16 @@ describe("parseMashupArgs", () => {
expect(args.inventedName).toBe("Halo");
expect(args.screenType).toBe("mobile");
expect(args.dryRun).toBe(true);
expect(args.publish).toBe(false);
});

it("parses --publish without changing --dry-run", () => {
const args = parseMashupArgs(
["--builder", "ikea", "--target", "figma", "--publish"],
{},
);
expect(args.publish).toBe(true);
expect(args.dryRun).toBe(false);
});

it("honors DRY_RUN=1 without hitting the gateway path", () => {
Expand Down Expand Up @@ -118,11 +130,28 @@ describe("parseMashupArgs", () => {
});
});

describe("mashupListingFields", () => {
it("defaults to draft so the row is not publicly listed", () => {
expect(mashupListingFields(false)).toEqual({
visibility: "draft",
moderation_status: "visible",
});
});

it("maps --publish to published + visible", () => {
expect(mashupListingFields(true)).toEqual({
visibility: "published",
moderation_status: "visible",
});
});
});

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("--publish");
expect(MASHUP_HELP).toContain("SKISSA");
expect(MASHUP_HELP).toContain("Halo");
expect(MASHUP_HELP).toContain("Perch");
Expand Down
32 changes: 30 additions & 2 deletions src/lib/ops/mashup-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,28 @@ export type MashupCliArgs = {
inventedName: string;
screenType: string | null;
dryRun: boolean;
publish: boolean;
help: boolean;
creatorId: string | null;
};

/** Listing columns written on CLI insert. Matches generations_visibility_ck / feed filters. */
export type MashupListingFields = {
visibility: "draft" | "published";
moderation_status: "visible";
};

/**
* Default insert is unlisted (`draft`). `--publish` is the only way to land a
* public feed/sitemap/locker row (`published` + `visible`).
*/
export function mashupListingFields(publish: boolean): MashupListingFields {
return {
visibility: publish ? "published" : "draft",
moderation_status: "visible",
};
}

const FLAG_ALIASES: Record<string, string> = {
"-b": "--builder",
"-t": "--target",
Expand All @@ -29,7 +47,9 @@ 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.
the public generation-images bucket → insert a generations row.
Default insert is visibility=draft (not on the public feed, sitemap, or
locker). Pass --publish only after the image has been reviewed.
Does not debit Dodo credits and does not require a logged-in session.

Options:
Expand All @@ -39,7 +59,8 @@ Options:
--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
--publish Insert visibility=published (public feed). Default is draft
--creator-id auth.users UUID that owns the row
-h, --help Show this help

Env:
Expand Down Expand Up @@ -71,6 +92,9 @@ Example pairings (picker brands already live; extras are prompt notes):

Dry-run (no paid image call):
yarn generate:mashup --builder ikea --target figma --dry-run

Publish after review (same flags, plus --publish):
yarn generate:mashup --builder ikea --target figma --invented-name SKISSA --publish
`;

function envDryRun(env: Record<string, string | undefined>): boolean {
Expand Down Expand Up @@ -102,6 +126,7 @@ export function parseMashupArgs(
inventedName: "",
screenType: null,
dryRun: envDryRun(env),
publish: false,
help: false,
creatorId: null,
};
Expand Down Expand Up @@ -133,6 +158,9 @@ export function parseMashupArgs(
case "--dry-run":
out.dryRun = true;
break;
case "--publish":
out.publish = true;
break;
case "--builder":
out.builder = takeValue();
break;
Expand Down
30 changes: 19 additions & 11 deletions src/lib/ops/mashup-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import { executeImageGeneration } from "@/lib/generation/execute-image";
import {
assembleMashupPrompt,
combineUserExtraDetails,
mashupListingFields,
type MashupCliArgs,
type MashupListingFields,
} from "@/lib/ops/mashup-cli";
import { mergeCompanyPair } from "@/lib/prompt/merge-company-pair";
import { normalizeRenderMode } from "@/lib/screen-type";
Expand All @@ -35,8 +37,9 @@ export type MashupDryRunResult = {
prompt: string;
};

export type MashupPublishedResult = {
kind: "published";
export type MashupInsertedResult = {
kind: "inserted";
visibility: MashupListingFields["visibility"];
builder: ProfileLookup;
target: ProfileLookup;
screenType: string;
Expand All @@ -48,7 +51,7 @@ export type MashupPublishedResult = {
imagePath: string;
};

export type MashupGenerateResult = MashupDryRunResult | MashupPublishedResult;
export type MashupGenerateResult = MashupDryRunResult | MashupInsertedResult;

function gatewayImageModel(): string {
return process.env.AI_GATEWAY_IMAGE_MODEL?.trim() || DEFAULT_GATEWAY_IMAGE_MODEL;
Expand Down Expand Up @@ -85,7 +88,7 @@ async function resolveOpsCreatorId(explicit: string | null): Promise<string> {
}

throw new Error(
"Could not resolve a creator for the published row. Pass --creator-id <auth.users uuid> or set GENERATION_OPS_CREATOR_ID. No credits are debited.",
"Could not resolve a creator for the row. Pass --creator-id <auth.users uuid> or set GENERATION_OPS_CREATOR_ID. No credits are debited.",
);
}

Expand All @@ -102,14 +105,15 @@ async function resolvePair(
return { builder, target };
}

async function insertPublishedGeneration(args: {
async function insertGenerationRow(args: {
creatorId: string;
builderName: string;
targetName: string;
extraDetails: string;
prompt: string;
screenType: string;
vibeTags: string[];
listing: MashupListingFields;
}): Promise<{ id: number; slug: string; objectPath: string }> {
const supabase = createSupabaseServiceClient();
const baseSlug = makeGenerationSlugSnippet({
Expand Down Expand Up @@ -137,8 +141,8 @@ async function insertPublishedGeneration(args: {
extra_details: args.extraDetails,
generated_prompt: args.prompt,
image_path: objectPath,
visibility: "published",
moderation_status: "visible",
visibility: args.listing.visibility,
moderation_status: args.listing.moderation_status,
status: "queued",
image_ready: false,
})
Expand All @@ -158,8 +162,9 @@ async function insertPublishedGeneration(args: {
}

/**
* Build the production prompt and either return it (--dry-run) or publish
* through executeImageGeneration (no Dodo debit).
* Build the production prompt and either return it (--dry-run) or insert
* through executeImageGeneration (no Dodo debit). Default listing is draft;
* pass --publish after review to insert a public row.
*/
export async function generateMashup(
args: MashupCliArgs,
Expand Down Expand Up @@ -195,14 +200,16 @@ export async function generateMashup(

assertAiGatewayConfigured();
const creatorId = await resolveOpsCreatorId(args.creatorId);
const inserted = await insertPublishedGeneration({
const listing = mashupListingFields(args.publish);
const inserted = await insertGenerationRow({
creatorId,
builderName: merged.builder,
targetName: merged.target,
extraDetails,
prompt,
screenType,
vibeTags: merged.builderDefaultVibeTags,
listing,
});

try {
Expand Down Expand Up @@ -235,7 +242,8 @@ export async function generateMashup(
});

return {
kind: "published",
kind: "inserted",
visibility: listing.visibility,
builder,
target,
screenType,
Expand Down
Loading