diff --git a/.agents/skills/convex-create-component/SKILL.md b/.agents/skills/convex-create-component/SKILL.md
index 4e5785bd..3c8adb83 100644
--- a/.agents/skills/convex-create-component/SKILL.md
+++ b/.agents/skills/convex-create-component/SKILL.md
@@ -1,7 +1,6 @@
---
name: convex-create-component
-description:
- Builds reusable Convex components with isolated tables and app-facing APIs.
+description: Builds reusable Convex components with isolated tables and app-facing APIs.
Use for new components, reusable backend modules, integrations, or component
boundary work.
---
@@ -131,9 +130,7 @@ export const listUnread = query({
handler: async (ctx, args) => {
return await ctx.db
.query("notifications")
- .withIndex("by_user_read", (q) =>
- q.eq("userId", args.userId).eq("read", false),
- )
+ .withIndex("by_user_read", (q) => q.eq("userId", args.userId).eq("read", false))
.collect();
},
});
diff --git a/.agents/skills/convex-create-component/agents/openai.yaml b/.agents/skills/convex-create-component/agents/openai.yaml
index bf58a4c4..acdfd4a7 100644
--- a/.agents/skills/convex-create-component/agents/openai.yaml
+++ b/.agents/skills/convex-create-component/agents/openai.yaml
@@ -1,12 +1,10 @@
interface:
display_name: "Convex Create Component"
- short_description:
- "Design and build reusable Convex components with clear boundaries."
+ short_description: "Design and build reusable Convex components with clear boundaries."
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#14B8A6"
- default_prompt:
- "Help me create a Convex component for this feature. First check that a
+ default_prompt: "Help me create a Convex component for this feature. First check that a
component is actually justified, then design the tables, API surface, and
app-facing wrappers before implementing it."
diff --git a/.agents/skills/convex-migration-helper/SKILL.md b/.agents/skills/convex-migration-helper/SKILL.md
index 8ce9874b..2470ed01 100644
--- a/.agents/skills/convex-migration-helper/SKILL.md
+++ b/.agents/skills/convex-migration-helper/SKILL.md
@@ -1,7 +1,6 @@
---
name: convex-migration-helper
-description:
- Plans Convex schema and data migrations with widen-migrate-narrow and
+description: Plans Convex schema and data migrations with widen-migrate-narrow and
@convex-dev/migrations. Use for breaking schema changes, backfills, table
reshaping, or zero-downtime rollouts.
---
diff --git a/.agents/skills/convex-migration-helper/agents/openai.yaml b/.agents/skills/convex-migration-helper/agents/openai.yaml
index 5f027dd9..075edd5a 100644
--- a/.agents/skills/convex-migration-helper/agents/openai.yaml
+++ b/.agents/skills/convex-migration-helper/agents/openai.yaml
@@ -4,8 +4,7 @@ interface:
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#8B5CF6"
- default_prompt:
- "Help me plan and execute this Convex migration safely. Start by identifying
+ default_prompt: "Help me plan and execute this Convex migration safely. Start by identifying
the schema change, the existing data shape, and the widen-migrate-narrow
path before making edits."
diff --git a/.agents/skills/convex-performance-audit/SKILL.md b/.agents/skills/convex-performance-audit/SKILL.md
index 998af1d1..7b4fb528 100644
--- a/.agents/skills/convex-performance-audit/SKILL.md
+++ b/.agents/skills/convex-performance-audit/SKILL.md
@@ -1,7 +1,6 @@
---
name: convex-performance-audit
-description:
- Audits Convex performance for reads, subscriptions, write contention, and
+description: Audits Convex performance for reads, subscriptions, write contention, and
function limits. Use for slow features, insights findings, OCC conflicts, or
read amplification.
---
diff --git a/.agents/skills/convex-performance-audit/agents/openai.yaml b/.agents/skills/convex-performance-audit/agents/openai.yaml
index 0a1d7889..53e73ecc 100644
--- a/.agents/skills/convex-performance-audit/agents/openai.yaml
+++ b/.agents/skills/convex-performance-audit/agents/openai.yaml
@@ -1,12 +1,10 @@
interface:
display_name: "Convex Performance Audit"
- short_description:
- "Audit slow Convex reads, subscriptions, OCC conflicts, and limits."
+ short_description: "Audit slow Convex reads, subscriptions, OCC conflicts, and limits."
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#EF4444"
- default_prompt:
- "Audit this Convex app for performance issues. Start with the strongest
+ default_prompt: "Audit this Convex app for performance issues. Start with the strongest
signal available, identify the problem class, and suggest the smallest
high-impact fix before proposing bigger structural changes."
diff --git a/.agents/skills/convex-performance-audit/references/hot-path-rules.md b/.agents/skills/convex-performance-audit/references/hot-path-rules.md
index 142231df..ea0499e2 100644
--- a/.agents/skills/convex-performance-audit/references/hot-path-rules.md
+++ b/.agents/skills/convex-performance-audit/references/hot-path-rules.md
@@ -144,10 +144,10 @@ defineTable({ team: v.id("teams"), user: v.id("users") })
```ts
// Good: single compound index serves both query patterns
-defineTable({ team: v.id("teams"), user: v.id("users") }).index(
- "by_team_and_user",
- ["team", "user"],
-);
+defineTable({ team: v.id("teams"), user: v.id("users") }).index("by_team_and_user", [
+ "team",
+ "user",
+]);
```
Exception: `.index("by_foo", ["foo"])` is really an index on `foo` +
@@ -195,8 +195,7 @@ const ownerName = project.ownerName ?? "Unknown owner";
```ts
// Good: denormalized data is an optimization, not the only source of truth
-const ownerName =
- project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null;
+const ownerName = project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null;
```
Bad lookup map pattern:
diff --git a/.agents/skills/convex-performance-audit/references/subscription-cost.md b/.agents/skills/convex-performance-audit/references/subscription-cost.md
index 78f05b0e..9b0fc6d1 100644
--- a/.agents/skills/convex-performance-audit/references/subscription-cost.md
+++ b/.agents/skills/convex-performance-audit/references/subscription-cost.md
@@ -157,10 +157,7 @@ const profile = useQuery(api.users.getProfile, { userId: selectedId! });
```ts
// Good: skip when there is nothing to fetch
-const profile = useQuery(
- api.users.getProfile,
- selectedId ? { userId: selectedId } : "skip",
-);
+const profile = useQuery(api.users.getProfile, selectedId ? { userId: selectedId } : "skip");
```
### 4. Isolate frequently-updated fields into separate documents
diff --git a/.agents/skills/convex-quickstart/SKILL.md b/.agents/skills/convex-quickstart/SKILL.md
index 09400d39..00b54ff9 100644
--- a/.agents/skills/convex-quickstart/SKILL.md
+++ b/.agents/skills/convex-quickstart/SKILL.md
@@ -1,7 +1,6 @@
---
name: convex-quickstart
-description:
- Creates or adds Convex to an app. Use for new Convex projects, npm create
+description: Creates or adds Convex to an app. Use for new Convex projects, npm create
convex@latest, frontend setup, env vars, or the first npx convex dev run.
---
@@ -222,9 +221,7 @@ Create the `ConvexReactClient` at module scope, not inside a component:
```tsx
// Bad: re-creates the client on every render
function App() {
- const convex = new ConvexReactClient(
- import.meta.env.VITE_CONVEX_URL as string,
- );
+ const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
return ...;
}
@@ -275,11 +272,7 @@ export function ConvexClientProvider({ children }: { children: ReactNode }) {
// app/layout.tsx
import { ConvexClientProvider } from "./ConvexClientProvider";
-export default function RootLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
+export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
diff --git a/.agents/skills/convex-quickstart/agents/openai.yaml b/.agents/skills/convex-quickstart/agents/openai.yaml
index 0347a50e..accea5b1 100644
--- a/.agents/skills/convex-quickstart/agents/openai.yaml
+++ b/.agents/skills/convex-quickstart/agents/openai.yaml
@@ -1,12 +1,10 @@
interface:
display_name: "Convex Quickstart"
- short_description:
- "Start a new Convex app or add Convex to an existing frontend."
+ short_description: "Start a new Convex app or add Convex to an existing frontend."
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#F97316"
- default_prompt:
- "Set up Convex for this project as fast as possible. First decide whether
+ default_prompt: "Set up Convex for this project as fast as possible. First decide whether
this is a new app or an existing app, then scaffold or integrate Convex and
verify the setup works."
diff --git a/.agents/skills/convex-setup-auth/SKILL.md b/.agents/skills/convex-setup-auth/SKILL.md
index 410198f0..4653c6e4 100644
--- a/.agents/skills/convex-setup-auth/SKILL.md
+++ b/.agents/skills/convex-setup-auth/SKILL.md
@@ -1,7 +1,6 @@
---
name: convex-setup-auth
-description:
- Sets up Convex auth, identity mapping, and access control. Use for login, auth
+description: Sets up Convex auth, identity mapping, and access control. Use for login, auth
providers, users tables, protected functions, or roles in a Convex app.
---
@@ -131,9 +130,7 @@ export const getMyProfile = query({
return await ctx.db
.query("users")
- .withIndex("by_tokenIdentifier", (q) =>
- q.eq("tokenIdentifier", identity.tokenIdentifier),
- )
+ .withIndex("by_tokenIdentifier", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier))
.unique();
},
});
diff --git a/.agents/skills/convex-setup-auth/agents/openai.yaml b/.agents/skills/convex-setup-auth/agents/openai.yaml
index f7e207c9..054e2caa 100644
--- a/.agents/skills/convex-setup-auth/agents/openai.yaml
+++ b/.agents/skills/convex-setup-auth/agents/openai.yaml
@@ -1,12 +1,10 @@
interface:
display_name: "Convex Setup Auth"
- short_description:
- "Set up Convex auth, user identity mapping, and access control."
+ short_description: "Set up Convex auth, user identity mapping, and access control."
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
- default_prompt:
- "Set up authentication for this Convex app. Figure out the provider first,
+ default_prompt: "Set up authentication for this Convex app. Figure out the provider first,
then wire up the user model, identity mapping, and access control with the
smallest solid implementation."
diff --git a/.agents/skills/convex/SKILL.md b/.agents/skills/convex/SKILL.md
index 08c67f14..2bbdff76 100644
--- a/.agents/skills/convex/SKILL.md
+++ b/.agents/skills/convex/SKILL.md
@@ -1,7 +1,6 @@
---
name: convex
-description:
- Routes general Convex requests to the right project skill. Use when the user
+description: Routes general Convex requests to the right project skill. Use when the user
asks which Convex skill to use or gives an underspecified Convex app task.
---
diff --git a/AGENTS.md b/AGENTS.md
index afec2208..1ba4d8ad 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -27,6 +27,7 @@ Keep this section as the command map agents normally need, not a full `package.j
- `bun run setup:worktree` — validate copied `.env.local` / `.convex` state, or link missing fallback state from a usable source worktree. Use `-- --from ` or `CLAWHUB_WORKTREE_SOURCE=` when auto-discovery picks the wrong source.
- `bun run dev:worktree` — Worktrunk-managed detached worktree server that also seeds local fixtures plus the public corpus once before starting the app when `VITE_CONVEX_URL` and `CONVEX_DEPLOYMENT` are local. Requires `wt` on `PATH`; from that worktree use `wt --yes url` to print the branch URL and `wt --yes stop` to stop it.
- `bun run seed:dev` — manual reseed path; runs worktree setup, waits for local Convex, seeds local fixtures plus the public corpus, and refreshes stats.
+- `bun run seed` — shared non-production seed pipeline used by local setup and disposable PR previews after their target Convex deployment is ready.
- `bun run build` — production build (Vite + Nitro).
- `bun run ci:static` — required pre-handoff static gate: peer checks, audit, formatting, lint, and dead-code checks.
- `bun run ci:unit` — Vitest coverage gate; required for source/test PRs unless docs/config-only.
diff --git a/convex/crons.test.ts b/convex/crons.test.ts
index 48a45db1..a6741c5b 100644
--- a/convex/crons.test.ts
+++ b/convex/crons.test.ts
@@ -100,10 +100,12 @@ describe("crons", () => {
vi.resetModules();
mocks.interval.mockReset();
delete process.env.CLAWHUB_DISABLE_CRONS;
+ delete process.env.CLAWHUB_PREVIEW;
});
afterEach(() => {
delete process.env.CLAWHUB_DISABLE_CRONS;
+ delete process.env.CLAWHUB_PREVIEW;
});
it("does not register production cron work when explicitly disabled", async () => {
@@ -114,6 +116,14 @@ describe("crons", () => {
expect(mocks.interval).not.toHaveBeenCalled();
});
+ it("does not register side-effecting cron work in disposable previews", async () => {
+ process.env.CLAWHUB_PREVIEW = "1";
+
+ await import("./crons");
+
+ expect(mocks.interval).not.toHaveBeenCalled();
+ });
+
it("runs GitHub skill source sync every 15 minutes", async () => {
await import("./crons");
diff --git a/convex/crons.ts b/convex/crons.ts
index cbdc6818..a84ec82f 100644
--- a/convex/crons.ts
+++ b/convex/crons.ts
@@ -4,7 +4,7 @@ import { RETENTION_STANDARD_BATCH_SIZE } from "./lib/retentionPolicy";
const crons = cronJobs();
-if (process.env.CLAWHUB_DISABLE_CRONS !== "1") {
+if (process.env.CLAWHUB_DISABLE_CRONS !== "1" && process.env.CLAWHUB_PREVIEW !== "1") {
crons.interval(
"github-skill-source-sync",
{ minutes: 15 },
diff --git a/convex/devSeed.localFixtures.test.ts b/convex/devSeed.localFixtures.test.ts
index ae0e011f..35ccd475 100644
--- a/convex/devSeed.localFixtures.test.ts
+++ b/convex/devSeed.localFixtures.test.ts
@@ -56,6 +56,7 @@ function createDb() {
const tables: Record & { _id: string }>> = {};
const counters: Record = {};
const operations: Array<{ type: "delete"; table: string; id: string }> = [];
+ const queries: Array<{ table: string; constraints: Record }> = [];
const list = (table: string) => {
tables[table] ??= [];
@@ -114,6 +115,7 @@ function createDb() {
withIndex: (_name: string, build: (q: ReturnType) => unknown) => {
const constraints: Record = {};
build(chainEq(constraints));
+ queries.push({ table, constraints });
const matched = () =>
list(table).filter((doc) => matches(doc as Record, constraints));
return {
@@ -137,7 +139,7 @@ function createDb() {
}),
};
- return { db, tables, operations };
+ return { db, tables, operations, queries };
}
function createMutationCtx(db: ReturnType["db"]) {
@@ -277,6 +279,49 @@ describe("devSeed local fixtures", () => {
);
});
+ it("resolves a shared public corpus owner once per mutation batch", async () => {
+ const { db, queries } = createDb();
+ const dummyOwner = {
+ handle: "corpus-owner",
+ displayName: "Corpus Owner",
+ image: "https://example.invalid/avatar.png",
+ };
+
+ await seedPublicCorpusBatchHandler(
+ createMutationCtx(db) as never,
+ {
+ rows: [
+ {
+ kind: "skill",
+ slug: "corpus-one",
+ displayName: "Corpus One",
+ version: "0.1.0",
+ skillMd: "# Corpus one",
+ storageId: "storage:corpus-one",
+ embedding: [0, 1, 2],
+ dummyOwner,
+ },
+ {
+ kind: "skill",
+ slug: "corpus-two",
+ displayName: "Corpus Two",
+ version: "0.1.0",
+ skillMd: "# Corpus two",
+ storageId: "storage:corpus-two",
+ embedding: [0, 1, 2],
+ dummyOwner,
+ },
+ ],
+ } as never,
+ );
+
+ expect(
+ queries.filter(
+ (query) => query.table === "users" && query.constraints.handle === dummyOwner.handle,
+ ),
+ ).toHaveLength(1);
+ });
+
it("backfills daily activity for existing public corpus skills", async () => {
const { db, tables } = createDb();
const userId = (await db.insert("users", {
diff --git a/convex/devSeed.ts b/convex/devSeed.ts
index 41499098..1fb26d89 100644
--- a/convex/devSeed.ts
+++ b/convex/devSeed.ts
@@ -64,6 +64,12 @@ type PublicCorpusSeedBatchResult = {
skipped: string[];
};
+type PublicCorpusSeedBatchHandlerResult = {
+ ok: true;
+ seeded: string[];
+ skipped: string[];
+};
+
function seededPackageRecommendationScore(stats: {
downloads: number;
installs: number;
@@ -189,6 +195,33 @@ const publicCorpusSeedRowValidator = v.union(
publicCorpusPluginRowValidator,
);
+type PublicCorpusSeedRow =
+ | {
+ kind: "skill";
+ slug: string;
+ displayName: string;
+ version: string;
+ skillMd: string;
+ summary?: string;
+ createdAt?: number;
+ dummyOwner: PublicCorpusDummyOwner;
+ }
+ | {
+ kind: "plugin";
+ name: string;
+ displayName: string;
+ version: string;
+ readme: string;
+ summary?: string;
+ categories?: string[];
+ topics?: string[];
+ family?: "skill" | "code-plugin" | "bundle-plugin";
+ channel?: "official" | "community" | "private";
+ sourceRepoHost?: string | null;
+ createdAt?: number;
+ dummyOwner: PublicCorpusDummyOwner;
+ };
+
const publicCorpusPreparedSkillRowValidator = v.object({
kind: v.literal("skill"),
slug: v.string(),
@@ -924,63 +957,70 @@ export const backfillExistingPublicCorpusBatchRows = internalMutation({
},
});
+async function seedPublicCorpusBatchHandler(
+ ctx: ActionCtx,
+ args: {
+ reset?: boolean;
+ resetOwnerHandles?: string[];
+ rows: PublicCorpusSeedRow[];
+ },
+): Promise {
+ const existingResult: PublicCorpusExistingRowsResult | null = args.reset
+ ? null
+ : await ctx.runMutation(internal.devSeed.backfillExistingPublicCorpusBatchRows, {
+ rows: args.rows,
+ });
+ const missingKeys = new Set(existingResult?.missingKeys ?? []);
+ const rowsToPrepare = args.reset
+ ? args.rows
+ : args.rows.filter((row) => missingKeys.has(publicCorpusSeedRowKey(row)));
+ if (!args.reset && rowsToPrepare.length === 0) {
+ return { ok: true as const, seeded: [], skipped: existingResult?.skipped ?? [] };
+ }
+
+ const preparedRows = await Promise.all(
+ rowsToPrepare.map(async (row) => {
+ if (row.kind === "skill") {
+ const storageId = await ctx.storage.store(
+ new Blob([row.skillMd], { type: "text/markdown" }),
+ );
+ const frontmatter = parseFrontmatter(row.skillMd);
+ const embeddingText = buildEmbeddingText({
+ frontmatter,
+ readme: row.skillMd,
+ otherFiles: [],
+ });
+ const embedding = await generateEmbedding(embeddingText);
+ return { ...row, storageId, embedding };
+ }
+ const storageId = await ctx.storage.store(new Blob([row.readme], { type: "text/markdown" }));
+ return { ...row, storageId };
+ }),
+ );
+
+ const seedResult: PublicCorpusSeedBatchResult = await ctx.runMutation(
+ internal.devSeed.seedPublicCorpusBatchMutation,
+ {
+ reset: args.reset,
+ resetOwnerHandles: args.resetOwnerHandles,
+ rows: preparedRows,
+ },
+ );
+
+ return {
+ ok: true as const,
+ seeded: seedResult.seeded,
+ skipped: [...(existingResult?.skipped ?? []), ...seedResult.skipped],
+ };
+}
+
export const seedPublicCorpusBatch: ReturnType = internalAction({
args: {
reset: v.optional(v.boolean()),
resetOwnerHandles: v.optional(v.array(v.string())),
rows: v.array(publicCorpusSeedRowValidator),
},
- handler: async (ctx, args) => {
- const existingResult: PublicCorpusExistingRowsResult | null = args.reset
- ? null
- : await ctx.runMutation(internal.devSeed.backfillExistingPublicCorpusBatchRows, {
- rows: args.rows,
- });
- const missingKeys = new Set(existingResult?.missingKeys ?? []);
- const rowsToPrepare = args.reset
- ? args.rows
- : args.rows.filter((row) => missingKeys.has(publicCorpusSeedRowKey(row)));
- if (!args.reset && rowsToPrepare.length === 0) {
- return { ok: true, seeded: [], skipped: existingResult?.skipped ?? [] };
- }
-
- const preparedRows = await Promise.all(
- rowsToPrepare.map(async (row) => {
- if (row.kind === "skill") {
- const storageId = await ctx.storage.store(
- new Blob([row.skillMd], { type: "text/markdown" }),
- );
- const frontmatter = parseFrontmatter(row.skillMd);
- const embeddingText = buildEmbeddingText({
- frontmatter,
- readme: row.skillMd,
- otherFiles: [],
- });
- const embedding = await generateEmbedding(embeddingText);
- return { ...row, storageId, embedding };
- }
- const storageId = await ctx.storage.store(
- new Blob([row.readme], { type: "text/markdown" }),
- );
- return { ...row, storageId };
- }),
- );
-
- const seedResult: PublicCorpusSeedBatchResult = await ctx.runMutation(
- internal.devSeed.seedPublicCorpusBatchMutation,
- {
- reset: args.reset,
- resetOwnerHandles: args.resetOwnerHandles,
- rows: preparedRows,
- },
- );
-
- return {
- ok: true,
- seeded: seedResult.seeded,
- skipped: [...(existingResult?.skipped ?? []), ...seedResult.skipped],
- };
- },
+ handler: seedPublicCorpusBatchHandler,
});
function publicCorpusSeedRowKey(
@@ -1016,9 +1056,15 @@ export const seedPublicCorpusBatchMutation = internalMutation({
const seeded: string[] = [];
const skipped: string[] = [];
+ const owners = new Map; publisherId: Id<"publishers"> }>();
for (const row of args.rows) {
- const { userId, publisherId } = await ensurePublicCorpusOwner(ctx, row.dummyOwner);
+ let owner = owners.get(row.dummyOwner.handle);
+ if (!owner) {
+ owner = await ensurePublicCorpusOwner(ctx, row.dummyOwner);
+ owners.set(row.dummyOwner.handle, owner);
+ }
+ const { userId, publisherId } = owner;
if (row.kind === "skill") {
const existing = await ctx.db
.query("skills")
diff --git a/package.json b/package.json
index 9086b211..c820ef8b 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"scripts": {
"admin": "bun packages/clawhub-admin/src/cli.ts",
"build": "bun run llms:generate && vite build && bun scripts/copy-og-assets.ts",
+ "build:vercel": "bun scripts/vercel-build.ts",
"check": "bun run lint",
"check:peers": "bun scripts/check-peer-deps.ts",
"check:release-workflow-action-pins": "node scripts/check-release-workflow-action-pins.mjs",
@@ -56,6 +57,7 @@
"release:clawhub:cli:changelog": "node scripts/extract-changelog-release.mjs",
"release:clawhub:cli:npm:check": "node scripts/clawhub-cli-npm-release-check.mjs",
"security:codex-worker": "bun scripts/security/run-codex-scan-worker.ts",
+ "seed": "bun scripts/seed.ts",
"seed:dev": "bun run setup:worktree -- --quiet && bun scripts/dev-worktree.ts --seed-only",
"seed:public-corpus": "bun run setup:worktree -- --quiet && bunx convex dev --once --typecheck=disable && bun scripts/public-corpus/seed-public-corpus.ts",
"setup:worktree": "bun scripts/setup-worktree.ts",
diff --git a/scripts/dev-worktree.test.ts b/scripts/dev-worktree.test.ts
index 37e85eaf..2a46950f 100644
--- a/scripts/dev-worktree.test.ts
+++ b/scripts/dev-worktree.test.ts
@@ -8,7 +8,6 @@ import {
buildEnvFileCandidates,
buildViteArgs,
applyLocalConvexEnvForUrl,
- isConvexFunctionUnavailableOutput,
isLocalConvexUrl,
isRunningPid,
parseArgs,
@@ -16,6 +15,7 @@ import {
shouldSeedLocalData,
shouldStartDevWorkers,
} from "./dev-worktree";
+import { isConvexFunctionUnavailableOutput } from "./seed";
describe("dev-worktree helpers", () => {
it("seeds local fixtures and public corpus when Worktrunk starts dev services", () => {
diff --git a/scripts/dev-worktree.ts b/scripts/dev-worktree.ts
index b6f569fc..2081904e 100644
--- a/scripts/dev-worktree.ts
+++ b/scripts/dev-worktree.ts
@@ -16,7 +16,6 @@ type Options = {
const DEFAULT_ENV_SOURCES = [".env.local"];
const CONVEX_START_TIMEOUT_MS = 120_000;
-const CONVEX_FUNCTIONS_READY_TIMEOUT_MS = 120_000;
const REACHABILITY_POLL_MS = 500;
const RUNTIME_DIR = ".codex/runtime";
const DETACHED_PID_FILE = `${RUNTIME_DIR}/dev-worktree.pid`;
@@ -305,57 +304,6 @@ export function runSync(
);
}
-function runSyncBuffered(
- command: string,
- args: string[],
- extraEnv: Record,
-) {
- const result = spawnSync(command, args, {
- cwd: process.cwd(),
- encoding: "utf8",
- env: { ...process.env, ...extraEnv },
- });
-
- return {
- output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
- status: result.status ?? 1,
- };
-}
-
-function writeBufferedOutput(output: string) {
- if (output) process.stdout.write(output);
-}
-
-export function isConvexFunctionUnavailableOutput(output: string) {
- return (
- output.includes("Could not find function for") &&
- output.includes("Did you forget to run `npx convex dev`")
- );
-}
-
-async function runConvexFunctionWhenReady(args: string[]) {
- const startedAt = Date.now();
-
- while (true) {
- const result = runSyncBuffered("bunx", args, {});
- if (result.status === 0) {
- writeBufferedOutput(result.output);
- return 0;
- }
-
- if (
- !isConvexFunctionUnavailableOutput(result.output) ||
- Date.now() - startedAt >= CONVEX_FUNCTIONS_READY_TIMEOUT_MS
- ) {
- writeBufferedOutput(result.output);
- return result.status;
- }
-
- console.log("Convex functions are not queryable yet; retrying...");
- await sleep(REACHABILITY_POLL_MS);
- }
-}
-
function spawnManaged(command: string, args: string[]) {
const child = spawn(command, args, {
cwd: process.cwd(),
@@ -549,24 +497,8 @@ async function main() {
if (seed.seed) {
console.log("Seeding local fixtures and public corpus...");
- const seedStatus = await runConvexFunctionWhenReady([
- "convex",
- "run",
- "--no-push",
- "devSeed:seedLocalFixtures",
- ]);
+ const seedStatus = runSync("bun", ["run", "seed"], {});
if (seedStatus !== 0) exitAfterStoppingManagedChildren(seedStatus);
-
- const publicCorpusStatus = runSync("bun", ["scripts/public-corpus/seed-public-corpus.ts"], {});
- if (publicCorpusStatus !== 0) exitAfterStoppingManagedChildren(publicCorpusStatus);
-
- const statsStatus = await runConvexFunctionWhenReady([
- "convex",
- "run",
- "--no-push",
- "statsMaintenance:updateGlobalStatsAction",
- ]);
- if (statsStatus !== 0) exitAfterStoppingManagedChildren(statsStatus);
writeSeedSentinel(convexUrl, convexDeployment?.trim() ?? "");
}
diff --git a/scripts/public-corpus/seed-public-corpus.test.ts b/scripts/public-corpus/seed-public-corpus.test.ts
index fa5c8eb7..220dab01 100644
--- a/scripts/public-corpus/seed-public-corpus.test.ts
+++ b/scripts/public-corpus/seed-public-corpus.test.ts
@@ -1,12 +1,138 @@
import { describe, expect, it, vi } from "vitest";
import {
+ DEFAULT_BATCH_BYTES,
+ DEFAULT_SEED_CONCURRENCY,
+ MAX_CONVEX_RUN_ARG_BYTES,
buildSeedCorpusRow,
+ chunkRowsByOwner,
isRetryableConvexSeedBatchOutput,
+ runSeedCorpusBatches,
runConvexSeedBatchWithRetry,
type SeedBatchRunOnce,
+ type SeedCorpusRow,
+ serializeConvexSeedArgs,
} from "./seed-public-corpus";
describe("public corpus seed runner", () => {
+ it("uses portable owner-isolated batches", () => {
+ const owner = {
+ handle: "dummy-owner",
+ displayName: "Dummy Owner",
+ image: "https://example.invalid/avatar.png",
+ };
+ const rows = Array.from({ length: 4 }, (_, index) =>
+ buildSeedCorpusRow(
+ {
+ kind: "skill",
+ slug: `skill-${index}`,
+ displayName: `Skill ${index}`,
+ version: "1.0.0",
+ skillMd: "x".repeat(50_000),
+ },
+ owner,
+ ),
+ );
+
+ expect(DEFAULT_BATCH_BYTES).toBeGreaterThan(96_000);
+ expect(DEFAULT_SEED_CONCURRENCY).toBe(24);
+ expect(chunkRowsByOwner(rows, DEFAULT_BATCH_BYTES)).toEqual([rows.slice(0, 2), rows.slice(2)]);
+ });
+
+ it("rejects batch arguments above the portable process limit", () => {
+ expect(() =>
+ serializeConvexSeedArgs({
+ rows: [{ skillMd: "x".repeat(MAX_CONVEX_RUN_ARG_BYTES) }],
+ }),
+ ).toThrow("Public corpus batch argument is");
+ });
+
+ it("runs different owners concurrently while serializing each owner", async () => {
+ const buildRow = (ownerHandle: string, index: number) =>
+ buildSeedCorpusRow(
+ {
+ kind: "skill",
+ slug: `${ownerHandle}-${index}`,
+ displayName: `${ownerHandle} ${index}`,
+ version: "1.0.0",
+ skillMd: "# Fixture",
+ },
+ {
+ handle: ownerHandle,
+ displayName: ownerHandle,
+ image: "https://example.invalid/avatar.png",
+ },
+ );
+ const batches = [
+ [buildRow("owner-a", 1)],
+ [buildRow("owner-b", 1)],
+ [buildRow("owner-a", 2)],
+ [buildRow("owner-c", 1)],
+ ];
+ const activeOwners = new Set();
+ let active = 0;
+ let maxActive = 0;
+ const runOnce = vi.fn(async (args: unknown) => {
+ const rows = (args as { rows: (typeof batches)[number] }).rows;
+ const ownerHandle = rows[0]!.dummyOwner.handle;
+ expect(activeOwners.has(ownerHandle)).toBe(false);
+ activeOwners.add(ownerHandle);
+ active += 1;
+ maxActive = Math.max(maxActive, active);
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ active -= 1;
+ activeOwners.delete(ownerHandle);
+ return { status: 0, output: "" };
+ });
+
+ await runSeedCorpusBatches(batches, {
+ concurrency: 2,
+ runOnce,
+ log: vi.fn(),
+ });
+
+ expect(runOnce).toHaveBeenCalledTimes(4);
+ expect(maxActive).toBe(2);
+ });
+
+ it("waits for active owners and stops scheduling after a batch fails", async () => {
+ const buildRow = (ownerHandle: string) =>
+ buildSeedCorpusRow(
+ {
+ kind: "skill",
+ slug: ownerHandle,
+ displayName: ownerHandle,
+ version: "1.0.0",
+ skillMd: "# Fixture",
+ },
+ {
+ handle: ownerHandle,
+ displayName: ownerHandle,
+ image: "https://example.invalid/avatar.png",
+ },
+ );
+ const completedOwners: string[] = [];
+ const runOnce = vi.fn(async (args: unknown) => {
+ const rows = (args as { rows: SeedCorpusRow[] }).rows;
+ const ownerHandle = rows[0]!.dummyOwner.handle;
+ await new Promise((resolve) => setTimeout(resolve, ownerHandle === "owner-a" ? 5 : 20));
+ completedOwners.push(ownerHandle);
+ return ownerHandle === "owner-a"
+ ? { status: 1, output: "failed" }
+ : { status: 0, output: "" };
+ });
+
+ await expect(
+ runSeedCorpusBatches([[buildRow("owner-a")], [buildRow("owner-b")], [buildRow("owner-c")]], {
+ concurrency: 2,
+ runOnce,
+ log: vi.fn(),
+ }),
+ ).rejects.toThrow("Public corpus batch 1/3 failed");
+
+ expect(completedOwners).toEqual(["owner-a", "owner-b"]);
+ expect(runOnce).toHaveBeenCalledTimes(2);
+ });
+
it("strips retired capability metadata before sending rows to Convex", () => {
const owner = {
handle: "dummy-owner",
diff --git a/scripts/public-corpus/seed-public-corpus.ts b/scripts/public-corpus/seed-public-corpus.ts
index 915bb075..5b21e808 100644
--- a/scripts/public-corpus/seed-public-corpus.ts
+++ b/scripts/public-corpus/seed-public-corpus.ts
@@ -1,6 +1,7 @@
#!/usr/bin/env bun
-import { spawnSync } from "node:child_process";
+import { spawn } from "node:child_process";
import { readFile } from "node:fs/promises";
+import { assertPreviewSeedTargetAllowed } from "../seed";
import { buildDummyOwnerPool, ownerForCorpusKey, type DummyCorpusOwner } from "./dummyOwners";
import {
DEFAULT_PUBLIC_CORPUS_FIXTURE,
@@ -14,13 +15,18 @@ type Options = {
reset: boolean;
limit: number | null;
batchBytes: number;
+ concurrency: number;
+ previewName: string | null;
};
-type SeedCorpusRow = PublicCorpusRow & {
+export type SeedCorpusRow = PublicCorpusRow & {
dummyOwner: DummyCorpusOwner;
};
-const DEFAULT_BATCH_BYTES = 96_000;
+// Keep the encoded Convex CLI argument below Linux's per-argument exec limit.
+export const DEFAULT_BATCH_BYTES = 120_000;
+export const DEFAULT_SEED_CONCURRENCY = 24;
+export const MAX_CONVEX_RUN_ARG_BYTES = 130_000;
const MAX_SEED_BATCH_ATTEMPTS = 4;
const BASE_SEED_BATCH_RETRY_DELAY_MS = 500;
@@ -29,10 +35,13 @@ export type SeedBatchRunResult = {
output: string;
};
-export type SeedBatchRunOnce = (args: unknown) => SeedBatchRunResult;
+export type SeedBatchRunOnce = (args: unknown) => SeedBatchRunResult | Promise;
async function main() {
const options = parseArgs(process.argv.slice(2));
+ if (options.previewName) {
+ assertPreviewSeedTargetAllowed();
+ }
const text = await readFile(options.fixture, "utf8");
const rows = parseCorpusJsonl(text).slice(0, options.limit ?? undefined);
const validation = validateCorpusRows(rows);
@@ -48,20 +57,12 @@ async function main() {
buildSeedCorpusRow(row, ownerForCorpusKey(corpusKey(row), owners)),
);
const batches = chunkRowsByOwner(seedRows, options.batchBytes);
-
- for (let index = 0; index < batches.length; index += 1) {
- const batch = batches[index]!;
- const args = {
- reset: options.reset && index === 0,
- resetOwnerHandles: options.reset ? owners.map((owner) => owner.handle) : [],
- rows: batch,
- };
- const status = await runConvexSeedBatchWithRetry(args);
- if (status !== 0) process.exit(status);
- console.log(
- `Seeded public corpus batch ${index + 1}/${batches.length} (${batch.length} rows).`,
- );
- }
+ await runSeedCorpusBatches(batches, {
+ concurrency: options.concurrency,
+ reset: options.reset,
+ resetOwnerHandles: owners.map((owner) => owner.handle),
+ runOnce: (batchArgs) => runConvexSeedBatchOnce(batchArgs, options.previewName),
+ });
console.log(
JSON.stringify(
@@ -84,6 +85,8 @@ function parseArgs(args: string[]): Options {
reset: false,
limit: null,
batchBytes: DEFAULT_BATCH_BYTES,
+ concurrency: DEFAULT_SEED_CONCURRENCY,
+ previewName: null,
};
for (let index = 0; index < args.length; index += 1) {
@@ -96,6 +99,12 @@ function parseArgs(args: string[]): Options {
options.limit = readPositiveInt(readValue(args, ++index, arg), arg);
} else if (arg === "--batch-bytes") {
options.batchBytes = readPositiveInt(readValue(args, ++index, arg), arg);
+ } else if (arg === "--concurrency") {
+ options.concurrency = readPositiveInt(readValue(args, ++index, arg), arg);
+ } else if (arg === "--preview-name") {
+ options.previewName = readValue(args, ++index, arg);
+ } else if (arg.startsWith("--preview-name=")) {
+ options.previewName = arg.slice("--preview-name=".length);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
@@ -104,7 +113,7 @@ function parseArgs(args: string[]): Options {
return options;
}
-function chunkRowsByOwner(rows: SeedCorpusRow[], maxBytes: number) {
+export function chunkRowsByOwner(rows: SeedCorpusRow[], maxBytes: number) {
const byOwner = new Map();
for (const row of rows) {
const ownerRows = byOwner.get(row.dummyOwner.handle) ?? [];
@@ -123,7 +132,7 @@ function chunkRows(rows: SeedCorpusRow[], maxBytes: number) {
let currentBytes = 0;
for (const row of rows) {
- const rowBytes = JSON.stringify(row).length + 2;
+ const rowBytes = Buffer.byteLength(JSON.stringify(row)) + 2;
if (current.length > 0 && currentBytes + rowBytes > maxBytes) {
batches.push(current);
current = [];
@@ -177,20 +186,53 @@ export function isRetryableConvexSeedBatchOutput(output: string) {
return output.includes("Data read or written in this mutation changed while it was being run");
}
-export function runConvexSeedBatchOnce(args: unknown): SeedBatchRunResult {
- const result = spawnSync(
+export async function runConvexSeedBatchOnce(
+ args: unknown,
+ previewName: string | null = null,
+): Promise {
+ const targetArgs = previewName ? ["--preview-name", previewName] : ["--no-push"];
+ const serializedArgs = serializeConvexSeedArgs(args);
+ const child = spawn(
"bunx",
- ["convex", "run", "--no-push", "devSeed:seedPublicCorpusBatch", JSON.stringify(args)],
+ ["convex", "run", ...targetArgs, "devSeed:seedPublicCorpusBatch", serializedArgs],
{
cwd: process.cwd(),
- encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
},
);
- const output = `${result.stdout ?? ""}${result.stderr ?? ""}${
- result.error ? `${result.error.message}\n` : ""
- }`;
- writeCommandOutput(output);
- return { status: result.status ?? 1, output };
+ let output = "";
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+ child.stdout.on("data", (chunk: string) => {
+ output += chunk;
+ });
+ child.stderr.on("data", (chunk: string) => {
+ output += chunk;
+ });
+
+ return await new Promise((resolve) => {
+ let settled = false;
+ const finish = (status: number, errorOutput = "") => {
+ if (settled) return;
+ settled = true;
+ output += errorOutput;
+ writeCommandOutput(output);
+ resolve({ status, output });
+ };
+ child.on("error", (error) => finish(1, `${error.message}\n`));
+ child.on("close", (status) => finish(status ?? 1));
+ });
+}
+
+export function serializeConvexSeedArgs(args: unknown) {
+ const serialized = JSON.stringify(args);
+ const bytes = Buffer.byteLength(serialized);
+ if (bytes > MAX_CONVEX_RUN_ARG_BYTES) {
+ throw new Error(
+ `Public corpus batch argument is ${bytes} bytes; maximum is ${MAX_CONVEX_RUN_ARG_BYTES}`,
+ );
+ }
+ return serialized;
}
export async function runConvexSeedBatchWithRetry(
@@ -210,7 +252,7 @@ export async function runConvexSeedBatchWithRetry(
const log = options.log ?? console.warn;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
- const result = runOnce(args);
+ const result = await runOnce(args);
if (result.status === 0) return 0;
if (!isRetryableConvexSeedBatchOutput(result.output) || attempt >= maxAttempts) {
return result.status;
@@ -225,6 +267,74 @@ export async function runConvexSeedBatchWithRetry(
return 1;
}
+export async function runSeedCorpusBatches(
+ batches: SeedCorpusRow[][],
+ options: {
+ concurrency: number;
+ reset?: boolean;
+ resetOwnerHandles?: string[];
+ runOnce: SeedBatchRunOnce;
+ log?: (message: string) => void;
+ },
+) {
+ const entries = batches.map((rows, index) => ({
+ index,
+ ownerHandle: rows[0]?.dummyOwner.handle ?? "",
+ rows,
+ }));
+ const totalBatches = entries.length;
+ const log = options.log ?? console.log;
+
+ const runEntry = async (entry: (typeof entries)[number], reset = false) => {
+ const status = await runConvexSeedBatchWithRetry(
+ {
+ reset,
+ resetOwnerHandles: reset ? (options.resetOwnerHandles ?? []) : [],
+ rows: entry.rows,
+ },
+ { runOnce: options.runOnce },
+ );
+ if (status !== 0) {
+ throw new Error(`Public corpus batch ${entry.index + 1}/${totalBatches} failed`);
+ }
+ log(
+ `Seeded public corpus batch ${entry.index + 1}/${totalBatches} (${entry.rows.length} rows).`,
+ );
+ };
+
+ const firstEntry = options.reset ? entries.shift() : undefined;
+ if (firstEntry) await runEntry(firstEntry, true);
+
+ const ownerQueues = new Map();
+ for (const entry of entries) {
+ const queue = ownerQueues.get(entry.ownerHandle) ?? [];
+ queue.push(entry);
+ ownerQueues.set(entry.ownerHandle, queue);
+ }
+
+ const queues = Array.from(ownerQueues.values());
+ let nextQueue = 0;
+ let firstError: unknown;
+ const workerCount = Math.min(options.concurrency, queues.length);
+ const workers = Array.from({ length: workerCount }, async () => {
+ while (!firstError) {
+ const queue = queues[nextQueue++];
+ if (!queue) return;
+ try {
+ for (const entry of queue) {
+ if (firstError) return;
+ await runEntry(entry);
+ }
+ } catch (error) {
+ firstError ??= error;
+ return;
+ }
+ }
+ });
+ await Promise.all(workers);
+ if (firstError) throw firstError;
+}
+
function readValue(args: string[], index: number, flag: string) {
const value = args[index];
if (!value) throw new Error(`Missing value for ${flag}`);
diff --git a/scripts/seed.test.ts b/scripts/seed.test.ts
new file mode 100644
index 00000000..b36fa6bd
--- /dev/null
+++ b/scripts/seed.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+import { assertSeedTargetAllowed, buildSeedSteps, parseSeedArgs } from "./seed";
+
+describe("shared seed runner", () => {
+ it("uses the local deployment selected by the environment by default", () => {
+ expect(buildSeedSteps(parseSeedArgs([]))).toEqual([
+ {
+ command: "bunx",
+ args: ["convex", "run", "--no-push", "devSeed:seedLocalFixtures"],
+ },
+ {
+ command: "bun",
+ args: ["scripts/public-corpus/seed-public-corpus.ts"],
+ },
+ {
+ command: "bunx",
+ args: ["convex", "run", "--no-push", "statsMaintenance:updateGlobalStatsAction"],
+ },
+ ]);
+ });
+
+ it("targets every seed step at the same named preview deployment", () => {
+ expect(buildSeedSteps(parseSeedArgs(["--preview-name", "feature/demo"]))).toEqual([
+ {
+ command: "bunx",
+ args: ["convex", "run", "--preview-name", "feature/demo", "devSeed:seedLocalFixtures"],
+ },
+ {
+ command: "bun",
+ args: ["scripts/public-corpus/seed-public-corpus.ts", "--preview-name", "feature/demo"],
+ },
+ {
+ command: "bunx",
+ args: [
+ "convex",
+ "run",
+ "--preview-name",
+ "feature/demo",
+ "statsMaintenance:updateGlobalStatsAction",
+ ],
+ },
+ ]);
+ });
+
+ it("allows only local or explicitly keyed preview targets", () => {
+ expect(() =>
+ assertSeedTargetAllowed(parseSeedArgs([]), {
+ CONVEX_DEPLOYMENT: "local:local-amantus-clawdhub",
+ }),
+ ).not.toThrow();
+ expect(() =>
+ assertSeedTargetAllowed(parseSeedArgs(["--preview-name", "feature/demo"]), {
+ CONVEX_DEPLOY_KEY: "preview:openclaw:clawhub|secret",
+ }),
+ ).not.toThrow();
+
+ expect(() =>
+ assertSeedTargetAllowed(parseSeedArgs(["--preview-name", "feature/demo"]), {
+ CONVEX_DEPLOY_KEY: "prod:wry-manatee-359|secret",
+ }),
+ ).toThrow("requires a Convex Preview deploy key");
+ });
+});
diff --git a/scripts/seed.ts b/scripts/seed.ts
new file mode 100644
index 00000000..0cdb82f1
--- /dev/null
+++ b/scripts/seed.ts
@@ -0,0 +1,150 @@
+#!/usr/bin/env bun
+
+import { spawnSync } from "node:child_process";
+
+type SeedOptions = {
+ previewName: string | null;
+};
+
+type SeedStep = {
+ command: string;
+ args: string[];
+};
+
+const CONVEX_FUNCTIONS_READY_TIMEOUT_MS = 120_000;
+const REACHABILITY_POLL_MS = 500;
+
+export function parseSeedArgs(args: string[]): SeedOptions {
+ const options: SeedOptions = { previewName: null };
+
+ for (let index = 0; index < args.length; index += 1) {
+ const arg = args[index];
+ if (arg === "--preview-name") {
+ options.previewName = readValue(args, ++index, arg);
+ } else if (arg.startsWith("--preview-name=")) {
+ options.previewName = arg.slice("--preview-name=".length);
+ } else {
+ throw new Error(`Unknown argument: ${arg}`);
+ }
+ }
+
+ return options;
+}
+
+export function buildSeedSteps(options: SeedOptions): SeedStep[] {
+ const convexTargetArgs = options.previewName
+ ? ["--preview-name", options.previewName]
+ : ["--no-push"];
+ const corpusTargetArgs = options.previewName ? ["--preview-name", options.previewName] : [];
+
+ return [
+ {
+ command: "bunx",
+ args: ["convex", "run", ...convexTargetArgs, "devSeed:seedLocalFixtures"],
+ },
+ {
+ command: "bun",
+ args: ["scripts/public-corpus/seed-public-corpus.ts", ...corpusTargetArgs],
+ },
+ {
+ command: "bunx",
+ args: ["convex", "run", ...convexTargetArgs, "statsMaintenance:updateGlobalStatsAction"],
+ },
+ ];
+}
+
+export function isConvexFunctionUnavailableOutput(output: string) {
+ return (
+ output.includes("Could not find function for") &&
+ output.includes("Did you forget to run `npx convex dev`")
+ );
+}
+
+export function assertSeedTargetAllowed(
+ options: SeedOptions,
+ env: NodeJS.ProcessEnv = process.env,
+) {
+ if (options.previewName) {
+ assertPreviewSeedTargetAllowed(env);
+ return;
+ }
+
+ const deployment = env.CONVEX_DEPLOYMENT?.trim();
+ if (
+ deployment === "anonymous-agent" ||
+ deployment?.startsWith("anonymous:") ||
+ deployment?.startsWith("local:")
+ ) {
+ return;
+ }
+ throw new Error("Shared seed without --deployment requires a local Convex deployment");
+}
+
+export function assertPreviewSeedTargetAllowed(env: NodeJS.ProcessEnv = process.env) {
+ if (!env.CONVEX_DEPLOY_KEY?.trim().startsWith("preview:")) {
+ throw new Error("Shared preview seed requires a Convex Preview deploy key");
+ }
+}
+
+async function runStep(step: SeedStep) {
+ if (step.command !== "bunx" || step.args[0] !== "convex" || step.args[1] !== "run") {
+ return (
+ spawnSync(step.command, step.args, {
+ cwd: process.cwd(),
+ env: process.env,
+ stdio: "inherit",
+ }).status ?? 1
+ );
+ }
+
+ const startedAt = Date.now();
+ while (true) {
+ const result = spawnSync(step.command, step.args, {
+ cwd: process.cwd(),
+ encoding: "utf8",
+ env: process.env,
+ });
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
+ if (result.status === 0) {
+ if (output) process.stdout.write(output);
+ return 0;
+ }
+ if (
+ !isConvexFunctionUnavailableOutput(output) ||
+ Date.now() - startedAt >= CONVEX_FUNCTIONS_READY_TIMEOUT_MS
+ ) {
+ if (output) process.stdout.write(output);
+ return result.status ?? 1;
+ }
+ console.log("Convex functions are not queryable yet; retrying...");
+ await sleep(REACHABILITY_POLL_MS);
+ }
+}
+
+function sleep(ms: number) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+}
+
+function readValue(args: string[], index: number, flag: string) {
+ const value = args[index]?.trim();
+ if (!value) throw new Error(`Missing value for ${flag}`);
+ return value;
+}
+
+async function main() {
+ const options = parseSeedArgs(process.argv.slice(2));
+ assertSeedTargetAllowed(options);
+ for (const step of buildSeedSteps(options)) {
+ const status = await runStep(step);
+ if (status !== 0) process.exit(status);
+ }
+}
+
+if (import.meta.main) {
+ main().catch((error: unknown) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+ });
+}
diff --git a/scripts/vercel-build-frontend.test.ts b/scripts/vercel-build-frontend.test.ts
new file mode 100644
index 00000000..06089242
--- /dev/null
+++ b/scripts/vercel-build-frontend.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import { resolveFrontendBuildEnv } from "./vercel-build-frontend";
+
+describe("Vercel frontend build environment", () => {
+ it("derives the preview site URL from the Convex CLI injected cloud URL", () => {
+ const env = resolveFrontendBuildEnv({
+ VERCEL_ENV: "preview",
+ VITE_CONVEX_URL: "https://paired-preview-123.convex.cloud",
+ VITE_CONVEX_SITE_URL: "https://wry-manatee-359.convex.site",
+ });
+
+ expect(env.VITE_CONVEX_SITE_URL).toBe("https://paired-preview-123.convex.site");
+ expect(env.VITE_CLAWHUB_DEPLOY_ENV).toBe("preview");
+ });
+
+ it("preserves an explicit production site URL", () => {
+ const env = resolveFrontendBuildEnv({
+ VERCEL_ENV: "production",
+ VITE_CONVEX_URL: "https://wry-manatee-359.convex.cloud",
+ VITE_CONVEX_SITE_URL: "https://api.clawhub.example",
+ });
+
+ expect(env.VITE_CONVEX_SITE_URL).toBe("https://api.clawhub.example");
+ expect(env.VITE_CLAWHUB_DEPLOY_ENV).toBe("production");
+ });
+});
diff --git a/scripts/vercel-build-frontend.ts b/scripts/vercel-build-frontend.ts
new file mode 100644
index 00000000..a401031b
--- /dev/null
+++ b/scripts/vercel-build-frontend.ts
@@ -0,0 +1,36 @@
+#!/usr/bin/env bun
+
+import { spawnSync } from "node:child_process";
+import { resolveConvexSiteUrl } from "../src/lib/convexDeploymentUrl";
+
+export function resolveFrontendBuildEnv(env: NodeJS.ProcessEnv) {
+ const convexSiteUrl = resolveConvexSiteUrl({
+ CONVEX_URL: env.CONVEX_URL,
+ VITE_CONVEX_SITE_URL: env.VERCEL_ENV === "preview" ? undefined : env.VITE_CONVEX_SITE_URL,
+ VITE_CONVEX_URL: env.VITE_CONVEX_URL,
+ });
+ return {
+ ...env,
+ VITE_CONVEX_SITE_URL: convexSiteUrl,
+ VITE_CLAWHUB_DEPLOY_ENV: env.VERCEL_ENV ?? "development",
+ };
+}
+
+function main() {
+ const result = spawnSync("bun", ["run", "build"], {
+ cwd: process.cwd(),
+ env: resolveFrontendBuildEnv(process.env),
+ stdio: "inherit",
+ });
+ if (result.error) throw result.error;
+ process.exit(result.status ?? 1);
+}
+
+if (import.meta.main) {
+ try {
+ main();
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+ }
+}
diff --git a/scripts/vercel-build.test.ts b/scripts/vercel-build.test.ts
new file mode 100644
index 00000000..b7665cda
--- /dev/null
+++ b/scripts/vercel-build.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+import { resolveVercelBuildPlan } from "./vercel-build";
+
+describe("Vercel build plan", () => {
+ it("recreates and seeds the branch Convex deployment for previews", () => {
+ expect(
+ resolveVercelBuildPlan({
+ VERCEL_ENV: "preview",
+ VERCEL_GIT_COMMIT_REF: "pe/claw-413-pr-previews",
+ CONVEX_DEPLOY_KEY: "preview:openclaw:clawhub|secret",
+ }),
+ ).toEqual([
+ {
+ command: "bunx",
+ args: [
+ "convex",
+ "deploy",
+ "--preview-create",
+ "pe/claw-413-pr-previews",
+ "--cmd",
+ "bun scripts/vercel-build-frontend.ts",
+ "--cmd-url-env-var-name",
+ "VITE_CONVEX_URL",
+ ],
+ },
+ {
+ command: "bun",
+ args: ["run", "seed", "--", "--preview-name", "pe/claw-413-pr-previews"],
+ },
+ ]);
+ });
+
+ it("fails closed when a preview deploy key is missing or has the wrong type", () => {
+ expect(() =>
+ resolveVercelBuildPlan({
+ VERCEL_ENV: "preview",
+ VERCEL_GIT_COMMIT_REF: "feature/demo",
+ }),
+ ).toThrow("Preview builds require a Convex Preview deploy key");
+
+ expect(() =>
+ resolveVercelBuildPlan({
+ VERCEL_ENV: "preview",
+ VERCEL_GIT_COMMIT_REF: "feature/demo",
+ CONVEX_DEPLOY_KEY: "prod:wry-manatee-359|secret",
+ }),
+ ).toThrow("Preview builds require a Convex Preview deploy key");
+ });
+
+ it("runs the ordinary frontend build for production and rejects deploy credentials", () => {
+ expect(resolveVercelBuildPlan({ VERCEL_ENV: "production" })).toEqual([
+ {
+ command: "bun",
+ args: ["scripts/vercel-build-frontend.ts"],
+ },
+ ]);
+
+ expect(() =>
+ resolveVercelBuildPlan({
+ VERCEL_ENV: "production",
+ CONVEX_DEPLOY_KEY: "prod:wry-manatee-359|secret",
+ }),
+ ).toThrow("Production Vercel builds must not receive CONVEX_DEPLOY_KEY");
+ });
+});
diff --git a/scripts/vercel-build.ts b/scripts/vercel-build.ts
new file mode 100644
index 00000000..033de3a2
--- /dev/null
+++ b/scripts/vercel-build.ts
@@ -0,0 +1,76 @@
+#!/usr/bin/env bun
+
+import { spawnSync } from "node:child_process";
+
+type BuildEnv = {
+ CONVEX_DEPLOY_KEY?: string;
+ VERCEL_ENV?: string;
+ VERCEL_GIT_COMMIT_REF?: string;
+};
+
+type BuildStep = {
+ command: string;
+ args: string[];
+};
+
+export function resolveVercelBuildPlan(env: BuildEnv): BuildStep[] {
+ if (env.VERCEL_ENV !== "preview") {
+ if (env.VERCEL_ENV === "production" && env.CONVEX_DEPLOY_KEY?.trim()) {
+ throw new Error("Production Vercel builds must not receive CONVEX_DEPLOY_KEY");
+ }
+ return [{ command: "bun", args: ["scripts/vercel-build-frontend.ts"] }];
+ }
+
+ const deployKey = env.CONVEX_DEPLOY_KEY?.trim();
+ if (!deployKey?.startsWith("preview:")) {
+ throw new Error("Preview builds require a Convex Preview deploy key");
+ }
+
+ const previewName = env.VERCEL_GIT_COMMIT_REF?.trim();
+ if (!previewName) {
+ throw new Error("Preview builds require VERCEL_GIT_COMMIT_REF");
+ }
+
+ return [
+ {
+ command: "bunx",
+ args: [
+ "convex",
+ "deploy",
+ "--preview-create",
+ previewName,
+ "--cmd",
+ "bun scripts/vercel-build-frontend.ts",
+ "--cmd-url-env-var-name",
+ "VITE_CONVEX_URL",
+ ],
+ },
+ // --preview-create guarantees an empty backend, so the shared seed stays
+ // idempotent and avoids a destructive full-corpus reset transaction.
+ {
+ command: "bun",
+ args: ["run", "seed", "--", "--preview-name", previewName],
+ },
+ ];
+}
+
+function main() {
+ for (const step of resolveVercelBuildPlan(process.env)) {
+ const result = spawnSync(step.command, step.args, {
+ cwd: process.cwd(),
+ env: process.env,
+ stdio: "inherit",
+ });
+ if (result.error) throw result.error;
+ if (result.status !== 0) process.exit(result.status ?? 1);
+ }
+}
+
+if (import.meta.main) {
+ try {
+ main();
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+ }
+}
diff --git a/server/convexProxy.test.ts b/server/convexProxy.test.ts
new file mode 100644
index 00000000..17e02558
--- /dev/null
+++ b/server/convexProxy.test.ts
@@ -0,0 +1,97 @@
+/* @vitest-environment node */
+
+import { mockEvent } from "h3";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ buildConvexProxyTarget,
+ isConvexProxyMethodAllowed,
+ proxyConvexRequest,
+ resolveConvexProxyEnv,
+} from "./convexProxy";
+
+describe("Convex HTTP proxy", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("maps API and hosted feed paths to the paired Convex site", () => {
+ const env = {
+ VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
+ };
+
+ expect(buildConvexProxyTarget("/api/v1/skills/demo?include=latest", env)).toBe(
+ "https://preview-branch-123.convex.site/api/v1/skills/demo?include=latest",
+ );
+ expect(buildConvexProxyTarget("/v1/feeds/plugins", env)).toBe(
+ "https://preview-branch-123.convex.site/api/v1/feeds/plugins",
+ );
+ });
+
+ it("allows only read methods when the frontend is a preview", () => {
+ const previewEnv = { VERCEL_ENV: "preview" };
+ expect(isConvexProxyMethodAllowed("GET", previewEnv)).toBe(true);
+ expect(isConvexProxyMethodAllowed("HEAD", previewEnv)).toBe(true);
+ expect(isConvexProxyMethodAllowed("POST", previewEnv)).toBe(false);
+ expect(isConvexProxyMethodAllowed("DELETE", previewEnv)).toBe(false);
+ expect(isConvexProxyMethodAllowed("POST", { VERCEL_ENV: "production" })).toBe(true);
+ });
+
+ it("prefers the build-paired Convex URL over stale Vercel runtime values", () => {
+ expect(
+ resolveConvexProxyEnv(
+ {
+ VERCEL_ENV: "preview",
+ VITE_CONVEX_SITE_URL: "https://wry-manatee-359.convex.site",
+ VITE_CONVEX_URL: "https://wry-manatee-359.convex.cloud",
+ },
+ {
+ VITE_CLAWHUB_DEPLOY_ENV: "preview",
+ VITE_CONVEX_SITE_URL: "https://paired-preview-123.convex.site",
+ VITE_CONVEX_URL: "https://paired-preview-123.convex.cloud",
+ },
+ ),
+ ).toEqual({
+ VERCEL_ENV: "preview",
+ VITE_CLAWHUB_DEPLOY_ENV: "preview",
+ VITE_CONVEX_SITE_URL: "https://paired-preview-123.convex.site",
+ VITE_CONVEX_URL: "https://paired-preview-123.convex.cloud",
+ });
+ });
+
+ it("proxies reads and exposes the non-secret preview deployment name for proof", async () => {
+ const fetchMock = vi.fn(async () => {
+ return new Response(JSON.stringify({ ok: true }), {
+ headers: { "content-type": "application/json" },
+ });
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ const event = mockEvent("https://preview.example/api/v1/skills/demo?include=latest");
+
+ const response = await proxyConvexRequest(event, {
+ VERCEL_ENV: "preview",
+ VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
+ });
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ "https://preview-branch-123.convex.site/api/v1/skills/demo?include=latest",
+ expect.objectContaining({ method: "GET" }),
+ );
+ expect(response.headers.get("X-ClawHub-Preview-Backend")).toBe("preview-branch-123");
+ });
+
+ it("rejects preview writes without contacting Convex", async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+ const event = mockEvent("https://preview.example/api/v1/skills/demo", {
+ method: "POST",
+ });
+
+ const response = await proxyConvexRequest(event, {
+ VERCEL_ENV: "preview",
+ VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
+ });
+
+ expect(response.status).toBe(405);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/server/convexProxy.ts b/server/convexProxy.ts
new file mode 100644
index 00000000..75ae9c4b
--- /dev/null
+++ b/server/convexProxy.ts
@@ -0,0 +1,84 @@
+import {
+ defineEventHandler,
+ getRequestURL,
+ proxyRequest,
+ type H3Event,
+ type HTTPResponse,
+} from "h3";
+import { convexDeploymentName, resolveConvexSiteUrl } from "../src/lib/convexDeploymentUrl";
+
+type ProxyEnv = {
+ CONVEX_URL?: string;
+ VERCEL_ENV?: string;
+ VITE_CLAWHUB_DEPLOY_ENV?: string;
+ VITE_CONVEX_SITE_URL?: string;
+ VITE_CONVEX_URL?: string;
+};
+
+const BUNDLED_PROXY_ENV: ProxyEnv = {
+ VITE_CLAWHUB_DEPLOY_ENV: import.meta.env.VITE_CLAWHUB_DEPLOY_ENV,
+ VITE_CONVEX_SITE_URL: import.meta.env.VITE_CONVEX_SITE_URL,
+ VITE_CONVEX_URL: import.meta.env.VITE_CONVEX_URL,
+};
+
+export function resolveConvexProxyEnv(
+ runtimeEnv: ProxyEnv,
+ bundledEnv: ProxyEnv = BUNDLED_PROXY_ENV,
+): ProxyEnv {
+ return {
+ ...runtimeEnv,
+ ...(bundledEnv.VITE_CLAWHUB_DEPLOY_ENV
+ ? { VITE_CLAWHUB_DEPLOY_ENV: bundledEnv.VITE_CLAWHUB_DEPLOY_ENV }
+ : {}),
+ ...(bundledEnv.VITE_CONVEX_SITE_URL
+ ? { VITE_CONVEX_SITE_URL: bundledEnv.VITE_CONVEX_SITE_URL }
+ : {}),
+ ...(bundledEnv.VITE_CONVEX_URL ? { VITE_CONVEX_URL: bundledEnv.VITE_CONVEX_URL } : {}),
+ };
+}
+
+function isPreviewFrontend(env: ProxyEnv) {
+ return env.VERCEL_ENV === "preview" || env.VITE_CLAWHUB_DEPLOY_ENV === "preview";
+}
+
+export function isConvexProxyMethodAllowed(method: string, env: ProxyEnv) {
+ if (!isPreviewFrontend(env)) return true;
+ return method === "GET" || method === "HEAD";
+}
+
+export function buildConvexProxyTarget(pathAndQuery: string, env: ProxyEnv) {
+ const requestUrl = new URL(pathAndQuery, "https://clawhub.invalid");
+ const targetPath = requestUrl.pathname.startsWith("/v1/feeds/")
+ ? `/api${requestUrl.pathname}`
+ : requestUrl.pathname;
+ const targetUrl = new URL(targetPath, resolveConvexSiteUrl(env));
+ targetUrl.search = requestUrl.search;
+ return targetUrl.toString();
+}
+
+export async function proxyConvexRequest(
+ event: H3Event,
+ env: ProxyEnv = resolveConvexProxyEnv(process.env),
+): Promise {
+ if (!isConvexProxyMethodAllowed(event.req.method, env)) {
+ return new Response("Disposable previews are read-only.", {
+ status: 405,
+ headers: {
+ Allow: "GET, HEAD",
+ "Cache-Control": "no-store",
+ "Content-Type": "text/plain; charset=utf-8",
+ },
+ });
+ }
+
+ const requestUrl = getRequestURL(event);
+ const target = buildConvexProxyTarget(`${requestUrl.pathname}${requestUrl.search}`, env);
+ const response = await proxyRequest(event, target);
+ if (isPreviewFrontend(env)) {
+ const deployment = convexDeploymentName(target);
+ if (deployment) response.headers.set("X-ClawHub-Preview-Backend", deployment);
+ }
+ return response;
+}
+
+export default defineEventHandler((event) => proxyConvexRequest(event));
diff --git a/server/handlers/convexProxy.ts b/server/handlers/convexProxy.ts
new file mode 100644
index 00000000..748581fc
--- /dev/null
+++ b/server/handlers/convexProxy.ts
@@ -0,0 +1 @@
+export { default } from "../convexProxy";
diff --git a/specs/deploy.md b/specs/deploy.md
index abfa156d..ca17b234 100644
--- a/specs/deploy.md
+++ b/specs/deploy.md
@@ -1,5 +1,5 @@
---
-summary: "Maintainer deploy checklist: Convex backend, Vercel web app, CLI npm release, and /api rewrites."
+summary: "Maintainer deploy checklist: Convex backend, Vercel web app and PR previews, CLI npm release, and API routing."
---
# Deploy
@@ -153,16 +153,53 @@ Deploy order:
3. wait for Vercel production deploy for the same Git SHA
4. smoke
+### Disposable PR previews
+
+Vercel Preview builds use `bun run build:vercel`. The build entrypoint requires a
+Convex Preview deploy key, recreates the branch's Convex preview with
+`--preview-create`, builds the frontend with that deployment's URL, and runs the
+same `bun run seed` pipeline used by local development against that preview name.
+
+One-time setup:
+
+1. In the Convex project settings, generate a Preview deploy key.
+2. In the Vercel project, set `CONVEX_DEPLOY_KEY` to that key for the **Preview**
+ environment only.
+3. In the Convex project default environment variables for Preview deployments,
+ set:
+ - `CLAWHUB_PREVIEW=1`
+ - `CLAWHUB_DISABLE_CRONS=1`
+4. Do not copy production auth, email, webhook, scanner, worker, backup, or
+ user-channel secrets into Preview defaults.
+5. Remove `CONVEX_DEPLOY_KEY` from the Vercel **Production** environment if it
+ exists. Production Convex deploys remain manual-only through
+ `.github/workflows/deploy.yml`.
+
+The shared seed command fails closed unless its target is local or an explicit
+preview name selected with a Convex Preview deploy key. It installs
+the committed public corpus plus the same synthetic clean, suspicious, and
+malicious presentation states used locally. Staging remains snapshot-backed and
+production is never seeded.
+
+Preview browser traffic is public and read-only. Nitro rejects non-GET/HEAD
+requests before proxying and adds `X-ClawHub-Preview-Backend` to proxied preview
+responses so smoke proof can record the paired non-secret deployment name.
+Authenticated write flows belong in the permanent test environment.
+
## 3) Route `/api/*` to Convex
-This repo currently uses `vercel.json` rewrites:
+Nitro handles `/api/**` and `/v1/feeds/**` through the environment-aware Convex
+proxy in `server/convexProxy.ts`. The target comes from the build's
+`VITE_CONVEX_SITE_URL`, or is derived from the paired `VITE_CONVEX_URL`. Those
+build-time values are compiled into the Nitro server output so stale Vercel
+runtime variables cannot redirect a Preview deployment to production.
-- `source: /api/:path*`
-- `destination: https://.convex.site/api/:path*`
+Do not add a production deployment hostname back to `vercel.json`. Static
+rewrites would make Vercel previews query production even when their Convex
+client points at a disposable backend.
-For self-host:
-
-- update `vercel.json` to your deployment's Convex site URL.
+For self-hosting, set `VITE_CONVEX_URL` and optionally
+`VITE_CONVEX_SITE_URL` to the intended deployment before building.
## 4) Registry discovery
diff --git a/specs/dev-seeding.md b/specs/dev-seeding.md
index 6d537383..adcb3f7e 100644
--- a/specs/dev-seeding.md
+++ b/specs/dev-seeding.md
@@ -18,8 +18,15 @@ Local fixture seeding is command-driven by default:
the documented first-run local setup path.
- CLI seeding (`bun run seed:dev`) runs the same seed path manually without starting the preview and
bypasses the first-run sentinel.
+- `bun run seed` is the shared seed pipeline used after local setup and by disposable PR previews.
+ It installs the same moderation fixtures and committed public corpus, then refreshes global stats.
+ Without `--preview-name` it accepts only a local Convex deployment; remote use requires an
+ explicit preview name plus a Convex Preview deploy key. Vercel recreates that preview deployment
+ before invoking the shared seed, so the corpus import does not perform a destructive reset.
- `bun run seed:public-corpus` is the lower-level corpus-only import command. Use it for corpus
- fixture work, not as the default local setup command.
+ fixture work, not as the default local setup command. The importer keeps each dummy owner's
+ batches serialized while running different owners concurrently, so owner creation remains
+ deterministic without paying one network round trip per corpus row.
- `bun run validate:public-corpus` validates the committed public corpus fixture without seeding.
- `internal.devSeed.seedCurrentUserFixtures` remains a dev-only internal action for explicit local
development tools/tests that need fixtures cloned to a local user.
@@ -34,5 +41,5 @@ should not be exposed as a first-run dashboard button unless the UX and ownershi
intentionally revisited.
Without `OPENAI_API_KEY`, public corpus import may use zero vectors. That is
-acceptable for local setup and layout QA, but semantic search quality will be weaker than an
-embedding-backed local database.
+acceptable for local setup, disposable PR previews, and layout QA, but semantic search quality will
+be weaker than an embedding-backed database.
diff --git a/specs/hosted-catalog-feed.md b/specs/hosted-catalog-feed.md
index 549073fe..d11a66af 100644
--- a/specs/hosted-catalog-feed.md
+++ b/specs/hosted-catalog-feed.md
@@ -124,11 +124,11 @@ provides:
- `Surrogate-Control: max-age=300, stale-while-revalidate=86400`
- `304 Not Modified` for matching `If-None-Match` or `If-Modified-Since`
-`vercel.json` exposes `/v1/feeds/plugins`, `/v1/feeds/skills`, and
-`/v1/feeds/promotions` as edge-friendly rewrites to the Convex endpoints. The
-unversioned `/feeds/*` paths permanently redirect to their versioned paths. The
-`registry.openclaw.ai` custom domain must point at the same Vercel project
-before the public RFC URLs are enabled.
+Nitro exposes `/v1/feeds/plugins`, `/v1/feeds/skills`, and
+`/v1/feeds/promotions` through the same environment-aware Convex proxy used for
+`/api/*`. The unversioned `/feeds/*` paths permanently redirect to their
+versioned paths. The `registry.openclaw.ai` custom domain must point at the same
+Vercel project before the public RFC URLs are enabled.
The serialized payload uses stable object-key ordering and deterministic entry
and install-candidate ordering. Additive fields may be introduced within a
diff --git a/src/__tests__/vercel-preview-config.test.ts b/src/__tests__/vercel-preview-config.test.ts
new file mode 100644
index 00000000..0b0bd456
--- /dev/null
+++ b/src/__tests__/vercel-preview-config.test.ts
@@ -0,0 +1,20 @@
+import { readFile } from "node:fs/promises";
+import { describe, expect, it } from "vitest";
+
+describe("Vercel preview configuration", () => {
+ it("uses the environment-aware build entrypoint without production-pinned rewrites", async () => {
+ const configText = await readFile("vercel.json", "utf8");
+ const config = JSON.parse(configText) as {
+ buildCommand?: string;
+ rewrites?: unknown[];
+ };
+ const packageJson = JSON.parse(await readFile("package.json", "utf8")) as {
+ scripts?: Record;
+ };
+
+ expect(config.buildCommand).toBe("bun run build:vercel");
+ expect(config.rewrites).toBeUndefined();
+ expect(configText).not.toContain("wry-manatee-359");
+ expect(packageJson.scripts?.["build:vercel"]).toBe("bun scripts/vercel-build.ts");
+ });
+});
diff --git a/src/lib/convexDeploymentUrl.test.ts b/src/lib/convexDeploymentUrl.test.ts
new file mode 100644
index 00000000..16284ec7
--- /dev/null
+++ b/src/lib/convexDeploymentUrl.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import { resolveConvexSiteUrl } from "./convexDeploymentUrl";
+
+describe("resolveConvexSiteUrl", () => {
+ it("derives the paired HTTP Actions origin from a Convex cloud URL", () => {
+ expect(
+ resolveConvexSiteUrl({
+ VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
+ }),
+ ).toBe("https://preview-branch-123.convex.site");
+ });
+
+ it("prefers an explicit site URL for custom domains", () => {
+ expect(
+ resolveConvexSiteUrl({
+ VITE_CONVEX_SITE_URL: "https://api.preview.example/",
+ VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
+ }),
+ ).toBe("https://api.preview.example");
+ });
+
+ it("rejects malformed or non-Convex cloud URLs instead of guessing", () => {
+ expect(() => resolveConvexSiteUrl({ VITE_CONVEX_URL: "https://example.com" })).toThrow(
+ "Cannot derive a Convex site URL",
+ );
+ expect(() => resolveConvexSiteUrl({ VITE_CONVEX_URL: "not-a-url" })).toThrow(
+ "Cannot derive a Convex site URL",
+ );
+ });
+});
diff --git a/src/lib/convexDeploymentUrl.ts b/src/lib/convexDeploymentUrl.ts
new file mode 100644
index 00000000..87d19fcf
--- /dev/null
+++ b/src/lib/convexDeploymentUrl.ts
@@ -0,0 +1,41 @@
+type ConvexUrlEnv = {
+ CONVEX_URL?: string;
+ VITE_CONVEX_SITE_URL?: string;
+ VITE_CONVEX_URL?: string;
+};
+
+function parseAbsoluteUrl(value: string | undefined) {
+ const trimmed = value?.trim();
+ if (!trimmed) return null;
+ try {
+ return new URL(trimmed);
+ } catch {
+ return null;
+ }
+}
+
+export function resolveConvexSiteUrl(env: ConvexUrlEnv) {
+ const explicitSiteUrl = parseAbsoluteUrl(env.VITE_CONVEX_SITE_URL);
+ if (explicitSiteUrl) return explicitSiteUrl.origin;
+
+ const cloudUrl = parseAbsoluteUrl(env.VITE_CONVEX_URL ?? env.CONVEX_URL);
+ if (cloudUrl?.protocol === "https:" && cloudUrl.hostname.endsWith(".convex.cloud")) {
+ cloudUrl.hostname = `${cloudUrl.hostname.slice(0, -".convex.cloud".length)}.convex.site`;
+ return cloudUrl.origin;
+ }
+
+ if (
+ cloudUrl?.protocol === "http:" &&
+ ["localhost", "127.0.0.1", "[::1]"].includes(cloudUrl.hostname)
+ ) {
+ return cloudUrl.origin;
+ }
+
+ throw new Error("Cannot derive a Convex site URL from VITE_CONVEX_URL");
+}
+
+export function convexDeploymentName(url: string) {
+ const parsed = new URL(url);
+ if (!parsed.hostname.endsWith(".convex.site")) return null;
+ return parsed.hostname.slice(0, -".convex.site".length);
+}
diff --git a/src/lib/publicApiUrl.ts b/src/lib/publicApiUrl.ts
index 37327a29..172358a6 100644
--- a/src/lib/publicApiUrl.ts
+++ b/src/lib/publicApiUrl.ts
@@ -20,8 +20,8 @@ function resolveAbsoluteBaseUrl(...candidates: Array) {
export function publicApiUrl(path: string) {
const normalizedPath = normalizeApiPath(path);
if (typeof window !== "undefined") {
- // In production, Vercel rewrites /api/* to the Convex site. Local Nitro
- // intercepts those paths, so local browsers use the Convex site directly.
+ // Hosted browsers use the same-origin Nitro proxy. Local browsers use the
+ // Convex site directly so anonymous local backends do not need edge routing.
const convexClientBaseUrl = resolveAbsoluteBaseUrl(
getRuntimeEnv("VITE_CONVEX_SITE_URL"),
getRuntimeEnv("VITE_CONVEX_URL"),
diff --git a/src/lib/runtimeEnv.test.ts b/src/lib/runtimeEnv.test.ts
index a621d203..c773726d 100644
--- a/src/lib/runtimeEnv.test.ts
+++ b/src/lib/runtimeEnv.test.ts
@@ -25,6 +25,20 @@ describe("runtimeEnv", () => {
import.meta.env.VITE_SITE_URL = originalClientValue;
});
+ it("prefers the bundled Convex URL for preview SSR", () => {
+ const originalDeployEnv = import.meta.env.VITE_CLAWHUB_DEPLOY_ENV;
+ const originalConvexUrl = import.meta.env.VITE_CONVEX_URL;
+ vi.stubEnv("VITE_CLAWHUB_DEPLOY_ENV", "production");
+ vi.stubEnv("VITE_CONVEX_URL", "https://wry-manatee-359.convex.cloud");
+ import.meta.env.VITE_CLAWHUB_DEPLOY_ENV = "preview";
+ import.meta.env.VITE_CONVEX_URL = "https://paired-preview-123.convex.cloud";
+
+ expect(getRuntimeEnv("VITE_CONVEX_URL")).toBe("https://paired-preview-123.convex.cloud");
+
+ import.meta.env.VITE_CLAWHUB_DEPLOY_ENV = originalDeployEnv;
+ import.meta.env.VITE_CONVEX_URL = originalConvexUrl;
+ });
+
it("throws for missing required env", () => {
expect(() => getRequiredRuntimeEnv("VITE_MISSING_VALUE")).toThrow(
"Missing required environment variable: VITE_MISSING_VALUE",
diff --git a/src/lib/runtimeEnv.ts b/src/lib/runtimeEnv.ts
index dad1f7b4..70d8c78d 100644
--- a/src/lib/runtimeEnv.ts
+++ b/src/lib/runtimeEnv.ts
@@ -9,16 +9,19 @@ function readProcessEnv(name: string) {
return readString(process.env?.[name]);
}
-function readClientMetaEnv(name: string) {
- if (typeof window === "undefined") return undefined;
+function readMetaEnv(name: string) {
return readString((import.meta.env as Record)[name]);
}
export function getRuntimeEnv(name: string) {
- if (typeof window !== "undefined") {
- return readClientMetaEnv(name) ?? readProcessEnv(name);
+ const bundledValue = readMetaEnv(name);
+ const preferBundledValue =
+ typeof window !== "undefined" ||
+ (name.startsWith("VITE_") && readMetaEnv("VITE_CLAWHUB_DEPLOY_ENV") === "preview");
+ if (preferBundledValue) {
+ return bundledValue ?? readProcessEnv(name);
}
- return readProcessEnv(name) ?? readClientMetaEnv(name);
+ return readProcessEnv(name) ?? bundledValue;
}
export function getRequiredRuntimeEnv(name: string) {
diff --git a/vercel.json b/vercel.json
index 185a7c62..7dac9fea 100644
--- a/vercel.json
+++ b/vercel.json
@@ -1,4 +1,5 @@
{
+ "buildCommand": "bun run build:vercel",
"headers": [
{
"source": "/(.*)",
@@ -57,24 +58,6 @@
"statusCode": 302
}
],
- "rewrites": [
- {
- "source": "/api/:path*",
- "destination": "https://wry-manatee-359.convex.site/api/:path*"
- },
- {
- "source": "/v1/feeds/plugins",
- "destination": "https://wry-manatee-359.convex.site/api/v1/feeds/plugins"
- },
- {
- "source": "/v1/feeds/skills",
- "destination": "https://wry-manatee-359.convex.site/api/v1/feeds/skills"
- },
- {
- "source": "/v1/feeds/promotions",
- "destination": "https://wry-manatee-359.convex.site/api/v1/feeds/promotions"
- }
- ],
"images": {
"sizes": [256, 640, 1024, 1920],
"formats": ["image/webp"],
diff --git a/vite.config.ts b/vite.config.ts
index e92eb23e..dd398df1 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -201,6 +201,10 @@ const config = defineConfig({
devtools(),
nitro({
serverDir: "server",
+ handlers: [
+ { route: "/api/**", handler: "./server/handlers/convexProxy.ts" },
+ { route: "/v1/feeds/**", handler: "./server/handlers/convexProxy.ts" },
+ ],
rollupConfig: {
onwarn: handleRollupWarning,
},