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: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
80 changes: 80 additions & 0 deletions scripts/generate-mashup.ts
Original file line number Diff line number Diff line change
@@ -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);
});
28 changes: 28 additions & 0 deletions src/data/company-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,34 @@ export async function listSelectableProfileIds(): Promise<string[]> {
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.
*/
Expand Down
56 changes: 56 additions & 0 deletions src/data/generator-profile-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading