#!/usr/bin/env bash # Convex quickstart bootstrap — sub-60s wow shell. # # Scaffolds a Next.js + Convex app with a featureRequests table + UI widget, # starts both dev servers, opens the browser, and starts a long-running # `npx convex run --watch` subscription that streams new feature requests # into a per-run log file so the calling agent can react to them. # # All logs land in a unique per-invocation directory (mktemp -d) so multiple # parallel bootstraps don't clobber each other's logs. The exact paths are # printed in the final report (look for "Log dir:" / "Convex log:" / etc.). # # Usage: # bash quickstart-bootstrap.sh APP_NAME TITLE [PLACEHOLDER1 PLACEHOLDER2 PLACEHOLDER3] # # Example: # bash quickstart-bootstrap.sh magician-crm "Magician CRM" \ # "Booking calendar" "Venue contacts" "Set lists" # # When the script returns, the agent's job is to: # 1. Watch the feature-requests log path printed in the final report. # 2. For each new "requested" row, mark it inProgress, build it, mark it completed. # # The full rules (withIndex, validators, etc.) live at: # https://docs.convex.dev/quickstart.md set -euo pipefail # Two ways to invoke: # 1. Slug mode (preferred): bash quickstart-bootstrap.sh # Fetches APP_NAME / TITLE / PLACEHOLDERS from # https://basic-anteater-667.convex.site/q//args.json # so the user (or their pasted prompt) only carries one short token. # 2. Positional mode (legacy / no bespoke ID): # bash quickstart-bootstrap.sh APP_NAME TITLE [PH1 PH2 PH3] QB_ARGS_BASE="${QB_ARGS_BASE:-https://basic-anteater-667.convex.site}" # ── Agent-side telemetry (POSTHOG.md taxonomy) ─────────────────────────────── # Fire-and-forget POST of a lifecycle/domain event to the anteater's /mp proxy # (the anteater forwards to PostHog — the device never holds a key). The scaffold # is the one place the `scaffold`/`chef` micro power runs as real code, so this is # its lifecycle emitter. Opt-out: DO_NOT_TRACK set, CONVEX_TELEMETRY=0, or # QB_TELEMETRY=0. Never sends code/prompts/PII — only the event + standard props. QB_TELEMETRY_BASE="${QB_TELEMETRY_BASE:-$QB_ARGS_BASE}" QB_HARNESS="${QB_HARNESS:-other}" mp_emit() { [[ -n "${DO_NOT_TRACK:-}" || "${CONVEX_TELEMETRY:-}" == "0" || "${QB_TELEMETRY:-1}" == "0" ]] && return 0 local event="$1" outcome="${2:-}" curl -fsS --max-time 5 -X POST "$QB_TELEMETRY_BASE/mp" \ -H 'content-type: application/json' \ -d "{\"event\":\"$event\",\"micro_power\":\"scaffold\",\"harness\":\"$QB_HARNESS\",\"outcome\":\"$outcome\",\"deployment_kind\":\"anonymous\"}" \ >/dev/null 2>&1 || true } usage() { echo "Usage:" >&2 echo " bash quickstart-bootstrap.sh (preferred)" >&2 echo " bash quickstart-bootstrap.sh APP_NAME TITLE [PH1 PH2 PH3]" >&2 exit 2 } [[ $# -lt 1 ]] && usage # Heuristic: if there's exactly one arg and it matches the bespoke-ID shape # (6-32 URL-safe chars), assume slug mode. Anything else falls through to # positional mode. if [[ $# -eq 1 && "$1" =~ ^[A-Za-z0-9_-]{6,32}$ ]]; then BESPOKE_ID="$1" echo "▸ Slug mode: fetching args from $QB_ARGS_BASE/q/$BESPOKE_ID/args.json" ARGS_JSON=$(curl -fsSL "$QB_ARGS_BASE/q/$BESPOKE_ID/args.json" 2>/dev/null \ || true) if [[ -z "$ARGS_JSON" ]]; then echo "✖ Couldn't fetch args for ID '$BESPOKE_ID'. Falling back to defaults." >&2 APP_NAME="convex-app" TITLE="My Convex App" PH1="Feature 1"; PH2="Feature 2"; PH3="Feature 3" else # Wait briefly for Haiku to populate args (status pending right after # /generate). Up to ~10s of polling, 1s apart. for _ in $(seq 1 10); do STATUS=$(echo "$ARGS_JSON" | node -pe \ 'JSON.parse(require("fs").readFileSync(0,"utf8")).status||""' \ 2>/dev/null || echo "") [[ "$STATUS" == "ready" ]] && break sleep 1 ARGS_JSON=$(curl -fsSL "$QB_ARGS_BASE/q/$BESPOKE_ID/args.json" \ 2>/dev/null || true) done # Write the assignments to a temp file and `source` it, instead of # `eval "$(...)"`. The node helper emits well-formed `NAME='value'` # bash assignments (single-quote-escaped like shlex.quote), so bash # just sources them — no in-place command-substitution parsing dance. # ARGS_JSON is passed via env (not stdin) so the heredoc can carry the # program on stdin. TMP_ENV=$(mktemp) QB_ARGS_JSON="$ARGS_JSON" node - > "$TMP_ENV" 2>/dev/null <<'JS' const d = (() => { try { return JSON.parse(process.env.QB_ARGS_JSON || "{}"); } catch { return {}; } })(); // POSIX single-quote escaping, matching python's shlex.quote. const q = (s) => "'" + String(s == null ? "" : s).replace(/'/g, "'\"'\"'") + "'"; const out = []; if (d.status !== "ready") { out.push("APP_NAME='convex-app'"); out.push("TITLE='My Convex App'"); out.push("PH1='Feature 1'"); out.push("PH2='Feature 2'"); out.push("PH3='Feature 3'"); out.push("IDEA=''"); out.push("AUDIENCE=''"); out.push("PRIORITY=''"); } else { // Sanitize degraded personalization. A vague/meta idea makes Haiku return // 'unknown' / ''; without this the app scaffolds as unknown/ with an // browser title. Fall back to neutral defaults instead. const ok = (s) => !!s && !String(s).toLowerCase().includes("unknown"); const slug = ok(d.appSlug) ? d.appSlug : "convex-app"; const title = ok(d.appTitle) ? d.appTitle : "My Convex App"; out.push("APP_NAME=" + q(slug)); out.push("TITLE=" + q(title)); const phs = (d.placeholders || []).filter(ok).concat(["Feature 1", "Feature 2", "Feature 3"]); out.push("PH1=" + q(phs[0])); out.push("PH2=" + q(phs[1])); out.push("PH3=" + q(phs[2])); out.push("IDEA=" + q(d.idea || "")); out.push("AUDIENCE=" + q(d.audience || "")); out.push("PRIORITY=" + q(d.priority || "")); } process.stdout.write(out.join("\n") + "\n"); JS # shellcheck disable=SC1090 source "$TMP_ENV" rm -f "$TMP_ENV" fi else APP_NAME="${1:-}" TITLE="${2:-}" PH1="${3:-Main feature}" PH2="${4:-Secondary feature}" PH3="${5:-Polish}" IDEA="" AUDIENCE="" PRIORITY="" fi if [[ -z "$APP_NAME" || -z "$TITLE" ]]; then usage fi # Agent-side taxonomy telemetry: the scaffold micro power started (opt-out aware). mp_emit micro_power.invoked # daemonize [args...] # Run a command detached from this script's process group via node's # child_process.spawn({detached:true}) (which calls setsid under the # hood), so it survives the parent shell dying AND survives # process-group-level cleanup that some agent harnesses (Codex CLI) do # when their session ends. Claude Code's nohup+disown path also works, # but setsid is a strict superset — covers both harnesses. # # The node helper: # 1. opens the logfile and points the child's stdout+stderr at it # 2. spawns the command detached (new session, new process group) # 3. unref()s it so this node process can exit immediately daemonize() { local LOG="$1"; shift QB_DAEMON_LOG="$LOG" node -e 'const cp=require("child_process");const fs=require("fs");const log=fs.openSync(process.env.QB_DAEMON_LOG,"a");const c=cp.spawn(process.argv[1],process.argv.slice(2),{detached:true,stdio:["ignore",log,log]});c.unref();' "$@" & disown } SCRIPT_START=$(date +%s) SCRIPT_STARTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) QB_SCRIPT_VERSION="2026-05-10" # bump on material changes QB_RUN_ID="$(date +%s)-$$" LAST_STEP="initialization" REACHED_END=0 elapsed() { printf "[+%3ds]" "$(( $(date +%s) - SCRIPT_START ))"; } step() { LAST_STEP="$1" printf "\n\033[1;36m▸ %s %s\033[0m\n" "$(elapsed)" "$1" } warn() { printf "\033[1;33m! %s %s\033[0m\n" "$(elapsed)" "$1"; } # Per-run log directory so multiple parallel bootstrap invocations don't # collide on /tmp/convex-dev.log etc. Random suffix via mktemp; the final # report prints the actual paths so the agent can find them. # Full template (not -t): macOS `mktemp -t qb.XXXXXXXX` treats the X's as a # LITERAL prefix and appends its own random suffix, leaving an ugly # `qb.XXXXXXXX.` dir that then shows up verbatim in every log path. A full # path template substitutes the X's on both BSD and GNU. LOG_DIR=$(mktemp -d "${TMPDIR:-/tmp}/qb.XXXXXXXX") CONVEX_LOG="$LOG_DIR/convex-dev.log" NEXT_LOG="$LOG_DIR/next-dev.log" FR_LOG="$LOG_DIR/feature-requests.log" RQ_LOG="$LOG_DIR/refinement-questions.log" # DEPLOY_LOG removed alongside the deploy-on-bootstrap step. STEP C now # tells the agent to run the publish flow on demand if the user asks. # ---------------------------------------------------------------- telemetry # The script POSTs structured events to the quickstart-feedback endpoint # so we see bootstrap-script failures without the user having to paste # logs. Set QB_TELEMETRY=0 to disable. QB_FEEDBACK_URL="${QB_FEEDBACK_URL:-https://basic-anteater-667.convex.site/feedback}" QB_PANEL_JS_URL="${QB_PANEL_JS_URL:-${QB_FEEDBACK_URL%/feedback}/chef-panel}" QB_TELEMETRY="${QB_TELEMETRY:-1}" # ─── Feature flags (the "profile") ───────────────────────────────────────── # Single source of truth for what a run includes. A launcher passes a coherent # set, or a named profile via QB_PROFILE; individual QB_* flags still override. # QB_PROFILE=full → everything on (dev / beta). # QB_PROFILE=minimal → just the scaffold + publishing (the public default). # Re-enabling a feature later is flipping its flag to 1 — nothing is deleted. case "${QB_PROFILE:-minimal}" in full) _DEF_PASSKEYS=1; _DEF_PANEL=1; _DEF_FEEDBACK=1; _DEF_DOMAIN=1; _DEF_PUBLISH=1 ;; *) _DEF_PASSKEYS=0; _DEF_PANEL=0; _DEF_FEEDBACK=0; _DEF_DOMAIN=0; _DEF_PUBLISH=0 ;; esac QB_PASSKEYS="${QB_PASSKEYS:-$_DEF_PASSKEYS}" # passkey (WebAuthn) auth pre-bake QB_PANEL="${QB_PANEL:-$_DEF_PANEL}" # in-app Chef feedback panel (frontend) QB_FEEDBACK="${QB_FEEDBACK:-$_DEF_FEEDBACK}" # @convex-dev/feedback component backend QB_DOMAIN="${QB_DOMAIN:-$_DEF_DOMAIN}" # custom-domain brainstorm + offer QB_PUBLISH="${QB_PUBLISH:-$_DEF_PUBLISH}" # publish to *.convex.app (OFF this release — gateway down) QB_VERSION="${QB_VERSION:-$QB_PUBLISH}" # "new version available" banner (only meaningful with publish) # Narration target is DERIVED from the panel, never hardcoded: panel on → post to # the Chef panel; panel off → narrate in chat (and never post to chef). QB_NARRATE="${QB_NARRATE:-$([[ "$QB_PANEL" == "1" ]] && echo chef || echo chat)}" # feature_on NAME → true when QB_=1. Use this everywhere instead of inline # "${QB_X:-}" == "1" checks, so every gate reads the same and the flags compose. feature_on() { [[ "$(eval "echo \${QB_$1:-0}")" == "1" ]]; } # Args: # $1 = outcome ("in_progress" | "success" | "failure") # $2 = summary (one-line, will be truncated to 500 chars) # $3 = transcript (multi-line context, truncated to ~8KB) # Best-effort: any failure here is silently swallowed so telemetry can't # tank the script. report_event() { [[ "$QB_TELEMETRY" != "1" ]] && return 0 local outcome="$1" local summary="$2" local transcript="${3:-}" local finished_at="" if [[ "$outcome" != "in_progress" ]]; then finished_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) fi QB_T_SUMMARY="$summary" QB_T_TRANSCRIPT="$transcript" QB_T_OUTCOME="$outcome" \ QB_T_STARTED="$SCRIPT_STARTED_AT" QB_T_FINISHED="$finished_at" QB_T_STEP="$LAST_STEP" \ QB_T_RUNID="$QB_RUN_ID" QB_T_VERSION="$QB_SCRIPT_VERSION" QB_T_URL="$QB_FEEDBACK_URL" \ node - >/dev/null 2>&1 <<'JS' || true const e = process.env; const osmap = { darwin: "darwin", linux: "linux", win32: "windows" }; const payload = { summary: (e.QB_T_SUMMARY || "").slice(0, 500), transcript: (e.QB_T_TRANSCRIPT || "").slice(0, 8000), harness: "bootstrap.sh", template: "nextjs-shadcn", tags: ["bootstrap-script", "step:" + e.QB_T_STEP, "run:" + e.QB_T_RUNID], startedAt: e.QB_T_STARTED, outcome: e.QB_T_OUTCOME, versions: { agent: "quickstart-bootstrap/" + e.QB_T_VERSION, os: osmap[process.platform] || process.platform, }, }; if (e.QB_T_FINISHED) payload.finishedAt = e.QB_T_FINISHED; fetch(e.QB_T_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(5000), }).catch(() => {}); JS } # Capture failure on any errexit'd command. $LINENO gives the failing # line; tail the relevant logs for context. on_err() { local rc=$? local line="$1" local context="" for f in "$CONVEX_LOG" "$NEXT_LOG"; do if [[ -f "$f" ]]; then context+="--- $f (last 80 lines) ---"$'\n' context+="$(tail -n 80 "$f" 2>/dev/null)"$'\n\n' fi done report_event "failure" \ "Bootstrap script aborted at line $line during step '$LAST_STEP' (rc=$rc)" \ "$context" } trap 'on_err $LINENO' ERR # Successful completion path: only fire on a clean exit AND only after # we've reached the BOOTSTRAP_COMPLETE marker (REACHED_END=1). This # avoids false positives if someone Ctrl-Cs midway. on_exit() { local rc=$? if [[ $rc -eq 0 && "$REACHED_END" == "1" ]]; then report_event "success" \ "Bootstrap completed in $(($(date +%s) - SCRIPT_START))s" fi } trap on_exit EXIT # Open the run with an in_progress event so we see partial starts even # if the script never reaches a known failure point (e.g., the user kills # it). Done early so any subsequent failure has at least the start row. report_event "in_progress" "Bootstrap started: app=$APP_NAME version=$QB_SCRIPT_VERSION" # ---------------------------------------------------------------- 1. scaffold # Prefer pnpm if installed — its content-addressable store at # ~/.local/share/pnpm/store (Linux/WSL) or ~/Library/pnpm/store (macOS) # hard-links into the project's node_modules instead of re-copying ~300MB # of files. Cuts subsequent template installs from ~60s to ~5-10s without # any new infrastructure or staleness concerns. First-run is roughly # npm-speed since the store is empty. # # Falls back to npm cleanly when pnpm isn't installed (which is the # default for most users) so behavior matches the previous implementation. if command -v pnpm >/dev/null 2>&1; then PKG_MGR="pnpm" else PKG_MGR="npm" fi # If the CLI prefetched a scaffold in a temp dir while the user was # typing their idea, rename it into place instead of re-running # create-convex. Saves ~20-40s of wall-clock on every quickstart. # QB_PREFETCHED_DIR points at /tmp/convex-quickstart-prefetch-XXX//. if [[ -n "${QB_PREFETCHED_DIR:-}" && -d "$QB_PREFETCHED_DIR" && -f "$QB_PREFETCHED_DIR/package.json" ]]; then step "Using prefetched scaffold from $QB_PREFETCHED_DIR (saved a long npm install)" mv "$QB_PREFETCHED_DIR" "$APP_NAME" else step "Scaffolding $APP_NAME (nextjs-shadcn template, $PKG_MGR)" if [[ "$PKG_MGR" == "pnpm" ]]; then # `npm_config_user_agent=pnpm` cues create-convex to install via pnpm. # Drop the `--` separator: pnpm create's positional-arg conventions # differ slightly from npm create. npm_config_user_agent="pnpm" \ pnpm create convex@latest "$APP_NAME" -t nextjs-shadcn -y else npm create convex@latest -- "$APP_NAME" -t nextjs-shadcn -y fi fi cd "$APP_NAME" # --------------------------- 1.4. stable, cwd-relative handle for the logs # LOG_DIR is a random mktemp dir. The skill tells the agent to watch the error # logs, but if it only knows them by name it runs `find … -name convex-errors.log` # to locate them — and on Claude Code `find` is shimmed to an all-cores parallel # finder, so that becomes a `find / -maxdepth 8` scan of the WHOLE filesystem that # pins every core and starves the run. Symlinking the log dir to a FIXED relative # path in the agent's cwd removes the need to search at all: the agent tails # `.quickstart-logs/convex-errors.log` directly. Keep it out of the tree/build. ln -sfn "$LOG_DIR" .quickstart-logs grep -qxF '.quickstart-logs' .gitignore 2>/dev/null || echo '.quickstart-logs' >> .gitignore # --------------------------- 1.5. clean out template's example Convex modules # nextjs-shadcn ships sample functions like convex/messages.ts that reference # tables (e.g. `messages`) we're about to drop from convex/schema.ts. Leaving # them around makes `convex dev` fail on TypeScript / schema-mismatch errors, # and the bootstrap stalls forever waiting for "Convex functions ready". So # nuke any *.ts in convex/ before we write our own (preserves tsconfig.json # and the still-non-existent _generated/). step "Removing template's example convex/*.ts modules (preserving auth wiring)" # Whitelist: never delete files that wire up auth on the convexauth # template — losing them silently breaks the entire auth flow (agent 11 # in the May 11 batch lost auth.ts/auth.config.ts/http.ts to this step # and fell back to a localStorage handle picker). Same for http.ts, which # templates may use for webhooks. We'll overwrite http.ts ourselves later # for webhooks. Safe to keep here; we no longer overwrite it ourselves. KEEP_RE='^(auth|auth\.config|http)\.ts$' for f in convex/*.ts; do [[ -e "$f" ]] || continue base="${f##*/}" if [[ "$base" =~ $KEEP_RE ]]; then echo " keep convex/$base (auth/http wiring)" continue fi rm -f "$f" done # Sample frontend components the template ships (e.g. # app/product/Chat/Chat.tsx) import from `api.messages` etc. — modules we # just deleted. tsc passes locally because the files compile in isolation, # but the dev server errors on first hit and the build fails. We own # app/page.tsx and app/layout.tsx after this script, so any other top- # level subdirectory under app/ is template scaffolding we don't use. # Wipe them — keep only the bare files (page.tsx, layout.tsx, globals.css, # ConvexClientProvider.tsx) which we'll write below. step "Removing template's sample app/ subdirectories (orphaned imports)" find app -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + # Nuke template-shipped middleware.ts if it exists. nextjs-shadcn ships # a middleware.ts that imports auth-related modules the no-auth wow # shell doesn't need, and Next's "Cannot find the middleware module" # overlay isn't catchable by app/error.tsx (it's a compile-level error, # not a render error). Safer to start without one and let the agent add # middleware only when an explicit feature request needs it. rm -f middleware.ts src/middleware.ts # ----------------------------------------------------- 2. Chef-panel tables # featureRequests/refinementQuestions/progressUpdates/todoItems ONLY serve the # Chef panel (QB_PANEL). In minimal profile (the public default: no panel) they # are dead weight — they pollute the user's schema and fail our own eval checks # (e.g. the unindexed todos.collect()). Gate them on PANEL; minimal gets a clean # starter schema the agent fills in as it builds. if feature_on PANEL; then step "Writing convex/schema.ts (Chef panel tables)" cat > convex/schema.ts <<'EOF' import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ featureRequests: defineTable({ title: v.string(), description: v.string(), state: v.union( v.literal("requested"), v.literal("inProgress"), v.literal("completed"), v.literal("rejected"), ), voteCount: v.number(), createdBy: v.optional(v.string()), }).index("by_state", ["state"]), // Agent-asked clarifying questions. The user's reply text is stored // directly as `answer` — no need to thread an additional row. State // transitions: open → answered (user typed something) | skipped (user // dismissed). Once answered the agent reads the answer and acts on it. refinementQuestions: defineTable({ text: v.string(), answer: v.optional(v.string()), state: v.union( v.literal("open"), v.literal("answered"), v.literal("skipped"), ), askedAtMs: v.number(), answeredAtMs: v.optional(v.number()), }).index("by_state", ["state"]), // Agent-emitted progress updates the user sees at the top of the // Chef panel. The agent posts one row per "I'm starting X" / "I just // finished Y" — keeps the user feeling motion is happening during // long builds. Reactivity handles the live update. progressUpdates: defineTable({ message: v.string(), kind: v.union( v.literal("step"), // "Writing convex/game.ts" v.literal("shipped"), // "Live game board ready" v.literal("note"), // anything else ), }), // Agent-planned todo list, shown as a visible checklist at the top // of the Chef panel. The agent calls todos:plan([...]) at the start // of the build to seed 4-6 items, then todos:setStatus(id, status) // as it works. The user watches items go pending → active → done, // which is far more reassuring than a single "Building…" spinner. todoItems: defineTable({ text: v.string(), order: v.number(), status: v.union( v.literal("pending"), v.literal("active"), v.literal("done"), ), }).index("by_order", ["order"]), }); EOF else step "Writing convex/schema.ts (empty starter — the agent adds the app's tables)" cat > convex/schema.ts <<'EOF' import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; // Your app's tables go here — add them as you build the schema. export default defineSchema({}); EOF fi # --------------------- 2.05. (passkeys mode) pre-bake Convex Auth + WebAuthn # When launched via /quickstart-passkeys (QB_PASSKEYS=1), wire the pinned # passkeys auth build into the scaffold HERE so the agent doesn't spend ~half # its session on identical, version-pinned plumbing. We bake the DETERMINISTIC # parts (deps, backend auth files, schema authTables, provider, keys+env); the # agent only adds the PasskeyButton + gates content (app-specific). The pinned # build `a42653e` (newest on the get-convex/convex-auth `magicseth/convex-auth-passkeys` # branch / PR #352) exports `Passkey` as a NAMED export and adds the EMAIL-FIRST flow: # `usePasskeyAuth().signInOrRegisterWithPasskey({ email })` enumerates by email # (allowCredentialsByIdentifier default on) + conditional-UI autofill. The skill's A0 # tells the agent to build the email-first sign-in UI around it. if feature_on PASSKEYS; then step "Passkeys mode: pre-baking @convex-dev/auth (pinned a42653e) + WebAuthn" # 1) authTables into the schema we just wrote (adds authAccounts, # authSessions, users, + the passkey-challenge table). node - <<'JS' const fs = require("fs"); const p = "convex/schema.ts"; let s = fs.readFileSync(p, "utf8"); if (!s.includes("authTables")) { s = s.replace( 'import { defineSchema, defineTable } from "convex/server";', 'import { defineSchema, defineTable } from "convex/server";\n' + 'import { authTables } from "@convex-dev/auth/server";', ).replace("export default defineSchema({", "export default defineSchema({\n ...authTables,"); fs.writeFileSync(p, s); console.log(" injected ...authTables into convex/schema.ts"); } JS # 2) backend auth files (exact pinned wiring). cat > convex/auth.config.ts <<'EOF' export default { providers: [{ domain: process.env.CONVEX_SITE_URL, applicationID: "convex" }], }; EOF cat > convex/auth.ts <<'EOF' import { Passkey } from "@convex-dev/auth/providers/Passkey"; import { convexAuth } from "@convex-dev/auth/server"; export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ providers: [Passkey], }); EOF cat > convex/http.ts <<'EOF' import { httpRouter } from "convex/server"; import { auth } from "./auth"; const http = httpRouter(); auth.addHttpRoutes(http); export default http; EOF # 3) install the pinned build + peers (jose for key-gen) via the project PM, # BEFORE convex dev starts so the authTables schema compiles on first push. step "Installing @convex-dev/auth@a42653e + peers ($PKG_MGR)" if [[ "$PKG_MGR" == "pnpm" ]]; then pnpm add "https://pkg.pr.new/@convex-dev/auth@a42653e" @auth/core@0.41.1 jose >/dev/null 2>&1 \ || warn "passkeys auth install failed — convex dev will error on authTables; agent must run A0.1" else npm i "https://pkg.pr.new/@convex-dev/auth@a42653e" @auth/core@0.41.1 jose >/dev/null 2>&1 \ || warn "passkeys auth install failed — convex dev will error on authTables; agent must run A0.1" fi # The pinned build SHIPS .d.ts files but its package.json `exports` map only # declares a `types` condition for SOME subpaths (./server has it; ./react, # ./providers/Passkey, ./nextjs/* don't). Without it, `next build` tsc errors # TS7016 on every auth import and (worse) collapses schema Id inference. Patch # the installed exports map to point each subpath at its adjacent .d.ts. node - <<'PATCH_AUTH' 2>/dev/null || warn "passkeys: could not patch @convex-dev/auth exports (next build may TS7016 on auth imports)" const fs = require("fs"); const dir = "node_modules/@convex-dev/auth"; const pf = dir + "/package.json"; if (!fs.existsSync(pf)) process.exit(0); const j = JSON.parse(fs.readFileSync(pf, "utf8")); const e = j.exports || {}; const adjDts = (imp) => imp && imp.replace(/\.js$/, ".d.ts"); const addTypes = (val) => { if (typeof val === "string") { const d = adjDts(val); return d && fs.existsSync(dir + "/" + d.replace(/^\.\//, "")) ? { types: d, default: val } : val; } if (val && typeof val === "object") { const imp = val.import || val.default || val.require; const d = adjDts(imp); if (!val.types && d && fs.existsSync(dir + "/" + d.replace(/^\.\//, ""))) return { types: d, ...val }; } return val; }; for (const k of Object.keys(e)) e[k] = addTypes(e[k]); // providers/Passkey is often absent from exports entirely — add it if the file is there. if (!e["./providers/Passkey"] && fs.existsSync(dir + "/dist/providers/Passkey.js")) { e["./providers/Passkey"] = { types: "./dist/providers/Passkey.d.ts", import: "./dist/providers/Passkey.js" }; } j.exports = e; fs.writeFileSync(pf, JSON.stringify(j, null, 2)); PATCH_AUTH step "Patched @convex-dev/auth exports map (types conditions → next build tsc resolves auth imports)" fi # The panel backend (featureRequests/refinementQuestions/todos/progress) is used # only by the Chef panel UI, itself gated on QB_PANEL below. Gate the backend on # the same flag so minimal apps carry no unused panel functions or tables. if feature_on PANEL; then step "Writing convex/featureRequests.ts (submit / upvote / listPublic / setState)" cat > convex/featureRequests.ts <<'EOF' import { v } from "convex/values"; import { mutation, internalMutation, query } from "./_generated/server"; const vState = v.union( v.literal("requested"), v.literal("inProgress"), v.literal("completed"), v.literal("rejected"), ); export const submit = mutation({ args: { title: v.string(), description: v.string() }, returns: v.id("featureRequests"), handler: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); return await ctx.db.insert("featureRequests", { title: args.title, description: args.description, state: "requested", voteCount: 1, createdBy: identity?.subject, }); }, }); export const upvote = mutation({ args: { id: v.id("featureRequests") }, returns: v.null(), handler: async (ctx, args) => { const row = await ctx.db.get(args.id); if (!row) return null; await ctx.db.patch(args.id, { voteCount: row.voteCount + 1 }); return null; }, }); export const listPublic = query({ args: { state: v.optional(vState), limit: v.optional(v.number()), }, returns: v.array( v.object({ _id: v.id("featureRequests"), _creationTime: v.number(), title: v.string(), description: v.string(), state: vState, voteCount: v.number(), }), ), handler: async (ctx, args) => { const limit = args.limit ?? 10; const rows = args.state ? await ctx.db .query("featureRequests") .withIndex("by_state", (q) => q.eq("state", args.state!)) .order("desc") .take(limit) : await ctx.db.query("featureRequests").order("desc").take(limit); return rows.map((r) => ({ _id: r._id, _creationTime: r._creationTime, title: r.title, description: r.description, state: r.state, voteCount: r.voteCount, })); }, }); // Just the still-unhandled requests (state === "requested"), newest first. // Zero-arg so an agent harness / monitor can watch it without passing JSON // args, and a stable contract decoupled from listPublic's shape. The // convex-feature-requests monitor polls this to push new requests to the agent. export const listPending = query({ args: {}, returns: v.array( v.object({ _id: v.id("featureRequests"), _creationTime: v.number(), title: v.string(), description: v.string(), state: vState, voteCount: v.number(), }), ), handler: async (ctx) => { const rows = await ctx.db .query("featureRequests") .withIndex("by_state", (q) => q.eq("state", "requested")) .order("desc") .take(50); return rows.map((r) => ({ _id: r._id, _creationTime: r._creationTime, title: r.title, description: r.description, state: r.state, voteCount: r.voteCount, })); }, }); export const setState = internalMutation({ args: { id: v.id("featureRequests"), state: vState }, returns: v.null(), handler: async (ctx, args) => { await ctx.db.patch(args.id, { state: args.state }); return null; }, }); EOF step "Writing convex/refinementQuestions.ts (ask / answer / skip / listOpen)" cat > convex/refinementQuestions.ts <<'EOF' import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; const vState = v.union( v.literal("open"), v.literal("answered"), v.literal("skipped"), ); // Agent: post a clarifying question. Returns the row's _id so the // agent can correlate the eventual answer. export const ask = mutation({ args: { text: v.string() }, returns: v.id("refinementQuestions"), handler: async (ctx, args) => { return await ctx.db.insert("refinementQuestions", { text: args.text, state: "open", askedAtMs: Date.now(), }); }, }); // User: type the reply directly into the inline form on the page. // Stores the text and flips state to "answered" so the agent picks it up. export const answer = mutation({ args: { id: v.id("refinementQuestions"), answer: v.string() }, returns: v.null(), handler: async (ctx, args) => { await ctx.db.patch(args.id, { answer: args.answer, state: "answered", answeredAtMs: Date.now(), }); return null; }, }); // User: dismiss the question without answering. export const skip = mutation({ args: { id: v.id("refinementQuestions") }, returns: v.null(), handler: async (ctx, args) => { await ctx.db.patch(args.id, { state: "skipped", answeredAtMs: Date.now(), }); return null; }, }); // Agent: watch this for the latest open + answered rows. Once a row's // state flips from "open" to "answered", the answer is in `answer`. export const listOpen = query({ args: { limit: v.optional(v.number()) }, returns: v.array( v.object({ _id: v.id("refinementQuestions"), _creationTime: v.number(), text: v.string(), answer: v.optional(v.string()), state: vState, askedAtMs: v.number(), answeredAtMs: v.optional(v.number()), }), ), handler: async (ctx, args) => { const limit = args.limit ?? 20; // Return both open AND recently-answered so the agent's --watch // stream always shows state transitions, and the page can render // the answer briefly before the agent acts on it. const rows = await ctx.db .query("refinementQuestions") .order("desc") .take(limit); return rows .filter((r) => r.state !== "skipped") .map((r) => ({ _id: r._id, _creationTime: r._creationTime, text: r.text, answer: r.answer, state: r.state, askedAtMs: r.askedAtMs, answeredAtMs: r.answeredAtMs, })); }, }); EOF step "Writing convex/todos.ts (plan / setStatus / listAll)" cat > convex/todos.ts <<'EOF' import { v } from "convex/values"; import { mutation, internalMutation, query } from "./_generated/server"; const vStatus = v.union( v.literal("pending"), v.literal("active"), v.literal("done"), ); // Agent: seed the todo list at the start of the build with 4–6 items // the user will see as a visible checklist in the Chef panel. Replaces // any existing list so re-planning mid-build is allowed. export const plan = internalMutation({ args: { items: v.array(v.string()) }, returns: v.null(), handler: async (ctx, args) => { const existing = await ctx.db.query("todoItems").collect(); for (const row of existing) await ctx.db.delete(row._id); for (let i = 0; i < args.items.length; i++) { await ctx.db.insert("todoItems", { text: args.items[i].slice(0, 120), order: i, status: i === 0 ? "active" : "pending", }); } return null; }, }); // Agent: flip a single item's status. Conventionally one item is "active" // at a time, but the schema allows any combination so re-planning works. export const setStatus = internalMutation({ args: { id: v.id("todoItems"), status: vStatus }, returns: v.null(), handler: async (ctx, args) => { await ctx.db.patch(args.id, { status: args.status }); return null; }, }); // Agent: shortcut — mark the current active item done and advance to // the next pending one. Lets the agent walk the list with a single // call per step instead of two setStatus calls. export const advance = internalMutation({ args: {}, returns: v.null(), handler: async (ctx) => { const rows = await ctx.db .query("todoItems") .withIndex("by_order") .collect(); rows.sort((a, b) => a.order - b.order); const active = rows.find((r) => r.status === "active"); if (active) await ctx.db.patch(active._id, { status: "done" }); const nextPending = rows.find( (r) => r.status === "pending" && (!active || r.order > active.order), ); if (nextPending) await ctx.db.patch(nextPending._id, { status: "active" }); return null; }, }); // Page: full ordered list for the Chef panel checklist. export const listAll = query({ args: {}, returns: v.array( v.object({ _id: v.id("todoItems"), _creationTime: v.number(), text: v.string(), order: v.number(), status: vStatus, }), ), handler: async (ctx) => { const rows = await ctx.db.query("todoItems").withIndex("by_order").collect(); rows.sort((a, b) => a.order - b.order); return rows.map((r) => ({ _id: r._id, _creationTime: r._creationTime, text: r.text, order: r.order, status: r.status, })); }, }); EOF step "Writing convex/progress.ts (post / listRecent)" cat > convex/progress.ts <<'EOF' import { v } from "convex/values"; import { mutation, internalMutation, query } from "./_generated/server"; const vKind = v.union( v.literal("step"), v.literal("shipped"), v.literal("note"), ); // Agent: post a one-line progress update. Show up immediately in the // floating Chef panel via Convex reactivity. Call this whenever you // start a non-trivial chunk of work ("Writing convex/X.ts", "Designing // the player lobby schema") OR ship a feature ("Live game board // ready"). Keep messages short — under ~60 chars renders best. export const post = internalMutation({ // Accept BOTH `message` and `text` — agents frequently send {text} because the // sibling refinementQuestions:ask uses `text`, and a hard {message}-only schema // rejected it with a confusing ArgumentValidationError. args: { message: v.optional(v.string()), text: v.optional(v.string()), kind: v.optional(vKind) }, // Returns nothing on purpose. A progress post is fire-and-forget; returning // an id only invited bugs where the agent passed this progressUpdates id to // todos:setStatus (which wants a todoItems id) → ArgumentValidationError. returns: v.null(), handler: async (ctx, args) => { await ctx.db.insert("progressUpdates", { message: (args.message ?? args.text ?? "").slice(0, 200), kind: args.kind ?? "step", }); return null; }, }); // Page: the most recent N updates, newest first. Reactive — flips // when the agent posts. export const listRecent = query({ args: { limit: v.optional(v.number()) }, returns: v.array( v.object({ _id: v.id("progressUpdates"), _creationTime: v.number(), message: v.string(), kind: vKind, }), ), handler: async (ctx, args) => { const rows = await ctx.db .query("progressUpdates") .order("desc") .take(args.limit ?? 5); return rows.map((r) => ({ _id: r._id, _creationTime: r._creationTime, message: r.message, kind: r.kind, })); }, }); EOF fi # end PANEL-gated panel backend (featureRequests/refinementQuestions/todos/progress) # ------------------- 2.5. install baseline shadcn primitives via `shadcn add` # Without this, agents that see our wow-shell page.tsx (which now imports # shadcn primitives) won't have the component files in place when Next # compiles. Idempotent: shadcn add skips files already present. --yes # bypasses the "use defaults?" prompt; --overwrite silently re-writes # stale versions. # # The agents in the May 11 batch never imported any shadcn components # (0/7 sampled) — they all rolled inline styles. Pre-importing in the # scaffolded page.tsx breaks that pattern by giving them a working # starting point to extend. step "Adding baseline shadcn primitives (button/card/input/textarea/label)" yes "" 2>/dev/null \ | npx --yes shadcn@latest add button card input textarea label \ --yes --overwrite 2>&1 \ | tail -n 6 \ || warn "shadcn add failed — page.tsx imports below may not resolve until you run 'npx shadcn@latest add button card input textarea label'" # ---------------------------------------------------------- 3. wow shell page step "Writing app/page.tsx (wow shell)" # Two-stage: write a template with shell-safe placeholders, then sed-substitute. # This keeps the tsx body in a heredoc without bash interpolating the JSX. cat > app/page.tsx <<'TSX_EOF' "use client"; import { useState } from "react"; import { useMutation, useQuery } from "convex/react"; import { api } from "../convex/_generated/api"; // Shadcn primitives are pre-installed by the bootstrap script. Use them // in any UI you write — DO NOT roll new inline-styled inputs/buttons. // Build new features by composing these (and other shadcn components you // can add with `npx shadcn@latest add `) instead of writing // style={{}} props. The bootstrap audit showed 0/7 agents reaching for // shadcn when the wow shell used inline styles, so the page now imports // them up-front to set the right idiom. import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; export default function Home() { return (
{/* Title + the user's prompt verbatim + Haiku's one-sentence read of who it's for. Compact: no separate "the agent thinks you're building this for" hedge, no extra paragraph — just the facts. */}

__TITLE__

“__IDEA__”

Built for __AUDIENCE__. Starting with __PRIORITY__.

{/* "What you're looking at" — promoted ABOVE the placeholders so the user reads the explanation before they wonder why three cards are spinning. */}

In flight right now

{/* The floating Chef panel (lower-right) is mounted in app/layout.tsx, NOT here. Don't add it inline — it survives across page rewrites that way. */}
); } function Placeholder({ label, hint }: { label: string; hint: string }) { return (
{label}
{hint}
); } function Spinner() { return (
); } TSX_EOF # Substitute the title, idea, audience, priority, and placeholders. # Use node for safe escaping (and to scrub any '"' / '“' / '”' / '<' # in the values that would break JSX or HTML rendering). Values pass via # env so the heredoc can carry the program on stdin. QB_TITLE="$TITLE" QB_PH1="$PH1" QB_PH2="$PH2" QB_PH3="$PH3" \ QB_IDEA="${IDEA:-}" QB_AUDIENCE="${AUDIENCE:-everyone who could use this}" \ QB_PRIORITY="${PRIORITY:-the most distinctive feature for this idea}" \ node - <<'JS' const fs = require("fs"); const e = process.env; // Strip quotes and angle brackets so the substituted text can't close a // JSX attribute or open a tag. Also strip trailing punctuation — Haiku's // audience/priority strings often end with a period, and the template adds // its own period after them, which produced "Built for X.." double periods. // We do NOT html-entity-encode because the slot is plain text inside a JSX // element, not an attribute — entities would render as their raw form there. const jsxSafe = (s) => String(s == null ? "" : s) .replace(/\\/g, "") .replace(/"/g, "") .replace(/“/g, "") .replace(/”/g, "") .replace(//g, "") .replace(/`/g, "") .replace(/[.!?,;:\s]+$/, ""); const sub = (src, token, val) => src.split(token).join(val); const p = "app/page.tsx"; let src = fs.readFileSync(p, "utf8"); src = sub(src, "__TITLE__", jsxSafe(e.QB_TITLE)); src = sub(src, "__PH1__", jsxSafe(e.QB_PH1)); src = sub(src, "__PH2__", jsxSafe(e.QB_PH2)); src = sub(src, "__PH3__", jsxSafe(e.QB_PH3)); src = sub(src, "__IDEA__", jsxSafe(e.QB_IDEA) || "your idea"); src = sub(src, "__AUDIENCE__", jsxSafe(e.QB_AUDIENCE)); src = sub(src, "__PRIORITY__", jsxSafe(e.QB_PRIORITY)); fs.writeFileSync(p, src); JS # ----------- 3.25. ensure ConvexClientProvider wraps the React tree # Our scaffolded app/page.tsx uses `useQuery` / `useMutation` from # convex/react, which require a ConvexProvider higher in the tree. # Some create-convex templates auto-wire this (and some don't, especially # the "no auth" variants), so write a minimal client provider + a layout # that uses it. Overwriting layout.tsx is acceptable here because we're # bootstrapping a fresh project — the user can re-style later. step "Writing app/ConvexClientProvider.tsx and app/layout.tsx" cat > app/ConvexClientProvider.tsx <<'EOF' "use client"; import { ReactNode } from "react"; import { ConvexProvider, ConvexReactClient } from "convex/react"; const convex = new ConvexReactClient( // Fall back to a placeholder absolute URL so `next build` static prerender // (e.g. /_not-found) doesn't throw "Provided address was not an absolute // URL" when NEXT_PUBLIC_CONVEX_URL is unset at build time. The real URL is // present at runtime in the browser. process.env.NEXT_PUBLIC_CONVEX_URL ?? "https://placeholder.convex.cloud", ); export function ConvexClientProvider({ children }: { children: ReactNode }) { return {children}; } EOF # (passkeys mode) swap the plain provider for ConvexAuthProvider so passkey # auth is available app-wide. Same export name + client prop, so layout.tsx is # unchanged; unauthenticated users still see the app (auth is opt-in per # component) — the agent adds the sign-in gate where the feature needs it. if feature_on PASSKEYS; then cat > app/ConvexClientProvider.tsx <<'EOF' "use client"; import { ReactNode } from "react"; import { ConvexAuthProvider } from "@convex-dev/auth/react"; import { ConvexReactClient } from "convex/react"; const convex = new ConvexReactClient( process.env.NEXT_PUBLIC_CONVEX_URL ?? "https://placeholder.convex.cloud", ); export function ConvexClientProvider({ children }: { children: ReactNode }) { return {children}; } EOF fi cat > app/layout.tsx <<'EOF' import type { Metadata } from "next"; import { ConvexClientProvider } from "./ConvexClientProvider"; __PANEL_IMPORT__ import "./globals.css"; export const metadata: Metadata = { title: "__LAYOUT_TITLE__", description: "Built live via the Convex quickstart bootstrap.", }; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( {children} __PANEL_RENDER__ ); } EOF # Personalize the browser tab title in layout.tsx with Haiku's appTitle. # Without this, every quickstart-built app shows "Convex Quickstart App" # in the tab regardless of what the user is building. # Panel gate: the floating Chef feedback panel is OFF by default. It's part of # the "ship later" goodness — set QB_PANEL=1 to wire it back in. When off, the # layout has no ChefPanel import/render and app/_chef-panel.tsx is never written. if feature_on PANEL; then QB_PANEL_IMPORT='import { ChefPanel } from "./_chef-panel";' QB_PANEL_RENDER=' ' else QB_PANEL_IMPORT='' QB_PANEL_RENDER='' fi QB_TITLE="$TITLE" QB_PANEL_IMPORT="$QB_PANEL_IMPORT" QB_PANEL_RENDER="$QB_PANEL_RENDER" node - <<'JS' const fs = require("fs"); // Strip characters that would break a JS string literal. const title = (process.env.QB_TITLE || "My Convex App") .replace(/\\/g, "") .replace(/"/g, "") .replace(/\n/g, " ") .trim() || "My Convex App"; const p = "app/layout.tsx"; let s = fs.readFileSync(p, "utf8").split("__LAYOUT_TITLE__").join(title); // Panel placeholders: when empty, drop the whole placeholder LINE so no blank // import/render line is left behind. const imp = process.env.QB_PANEL_IMPORT || ""; const ren = process.env.QB_PANEL_RENDER || ""; s = imp ? s.split("__PANEL_IMPORT__").join(imp) : s.replace(/__PANEL_IMPORT__\n/g, ""); s = ren ? s.split("__PANEL_RENDER__").join(ren) : s.replace(/__PANEL_RENDER__\n/g, ""); fs.writeFileSync(p, s); JS # Write the floating Chef panel to its OWN file, imported from # app/layout.tsx. Living in a dedicated file (underscore-prefixed so the # agent doesn't auto-route it as a Next page) means agents can rewrite # app/page.tsx freely without accidentally removing the panel — it # stays mounted globally via the layout. Don't fold this back into # page.tsx; that's the failure mode this structure prevents. if feature_on PANEL; then step "Writing app/_chef-panel.tsx (served Chef panel loader — minimized FAB + Cmd/Ctrl+Enter)" # The panel UI lives in chef-panel.js, served from anteater, so display fixes # (minimized-by-default, Cmd/Ctrl+Enter submit, no tabs) ship instantly with no # rebuild — and it's used in BOTH modes (QB_FEEDBACK swaps the BACKEND to the # component, but the panel UI is always this served web component, never an # inline React fallback that drifts out of sync). cat > app/_chef-panel.tsx <<'EOF' "use client"; import { useEffect } from "react"; // NAMED export — layout.tsx does `import { ChefPanel } from "./_chef-panel"`. const PANEL_JS = "__PANEL_JS__"; export function ChefPanel() { useEffect(() => { if (typeof window === "undefined") return; const url = process.env.NEXT_PUBLIC_CONVEX_URL; if (!url) return; if (!document.querySelector('script[data-chef-panel]')) { const s = document.createElement("script"); s.type = "module"; s.src = PANEL_JS; s.setAttribute("data-chef-panel", "1"); document.head.appendChild(s); } const el = document.createElement("chef-panel"); el.setAttribute("convex-url", url); el.setAttribute("prefix", "wow"); document.body.appendChild(el); return () => { el.remove(); }; }, []); return null; } EOF sed -i.bak "s|__PANEL_JS__|$QB_PANEL_JS_URL|" app/_chef-panel.tsx && rm -f app/_chef-panel.tsx.bak fi # Soft-fail boundary for the brief windows where the agent has updated # app/page.tsx to reference a component but hasn't yet written the # component file — without this, the user sees Next's full-screen red # "X is not defined" overlay during every feature add. With it, the # page renders a calm "Chef is still cooking" message and auto-retries # every 1.5s. Next's dev overlay still appears (and is ESC-dismissible), # but the underlying app is in a graceful state when it goes away. cat > app/error.tsx <<'EOF' "use client"; import { useEffect } from "react"; export default function ChefIsCooking({ error: _error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { // Auto-retry every 1.5s — Chef is almost certainly finishing the // component reference that just broke. If it's a real bug that // persists, the user can ask Chef to fix it in the bubble. useEffect(() => { const t = setInterval(reset, 1500); return () => clearInterval(t); }, [reset]); return (

Chef is still cooking…

A piece of the page is mid-update. The page will refresh on its own in a moment. If the same error keeps showing for more than ~10 seconds, ask Chef to fix it in the bubble (lower right).

); } EOF # Write a globals.css that BOTH (a) makes the layout's `import "./globals.css"` # resolve, AND (b) preserves the template's @tailwind directives + shadcn's # HSL theme tokens (--background, --foreground, --primary, etc.) so any # shadcn primitive the agent reaches for actually renders. Without this, # `bg-primary text-primary-foreground` etc. silently resolve to nothing # and the agent has to abandon shadcn for inline styles. cat > app/globals.css <<'EOF' @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --popover: 0 0% 100%; --popover-foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; --radius: 0.5rem; } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; --card: 222.2 84% 4.9%; --card-foreground: 210 40% 98%; --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; --primary: 210 40% 98%; --primary-foreground: 222.2 47.4% 11.2%; --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 98%; --muted: 217.2 32.6% 17.5%; --muted-foreground: 215 20.2% 65.1%; --accent: 217.2 32.6% 17.5%; --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 212.7 26.8% 83.9%; } } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, "Helvetica Neue", Arial, sans-serif; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } } EOF # NOTE: the NEXT_PUBLIC_CONVEX_URL bridge in .env.local has to wait # until `convex dev` has run and written CONVEX_URL — see step 4a below. # --------------------- 3.5. rewrite package.json scripts only # Previously this section also installed @convex-dev/static-hosting, # wrote convex.config.ts/staticHosting.ts/http.ts, and switched # next.config.ts to static-export mode so `npm run deploy` could # publish out of the box. We dropped all of that: # # - The install hung for ~6% of runs in the May-11 batch (agent 03), # stalling the bootstrap with no recovery # - Agents almost never reach STEP C in a single session, so the # up-front setup cost was paid for a feature most runs never used # - Static-export mode also prevents server-side features the agent # might add later (route handlers, Server Actions), so removing it # is a wash even for users who do eventually deploy # # To publish later, STEP C in the final report tells the agent the # one-liner (install + setup + deploy) — paid lazily, only when asked. step "Rewriting package.json scripts (dev → next-only)" # Use node + JSON manipulation so we don't fight indentation/comments. # # CRITICAL: nextjs-shadcn (and most Convex starter templates) ship a `dev` # script of the form `npm-run-all --parallel dev:frontend dev:backend`, # where dev:backend is `convex dev`. If we leave it alone, our explicit # `npx convex dev` launch in step 4 collides with the one npm run dev # spawns: TWO local backends, each with a different anonymous deployment # name, both writing to the same sqlite file → corruption + 500 errors. # Force `dev` to be just `next dev` so npm run dev is frontend-only and # our step-4 launch is the single source of truth for the backend. node - <<'EOF' const fs = require("fs"); const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); pkg.scripts = pkg.scripts || {}; pkg.scripts.dev = "next dev"; // Drop the now-orphaned dev:backend / dev:frontend split if the template // added them — leaving them is harmless but confusing. delete pkg.scripts["dev:backend"]; delete pkg.scripts["dev:frontend"]; fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n"); EOF # Silence Next's "inferred your workspace root / multiple lockfiles" warning. # When the agent's $HOME (or a parent dir) carries its own lockfile, Next # guesses the wrong tracing root and prints a noisy warning every dev/build. # Pin it to the project dir. `process.cwd()` is valid in .ts/.mjs/.js configs # alike and equals the project root (Next always runs from there). Best-effort: # if the config doesn't match the standard object-literal shape, skip silently. node - <<'EOF' const fs = require("fs"); const cfg = ["next.config.ts","next.config.mjs","next.config.js"].find((f) => fs.existsSync(f)); if (!cfg) process.exit(0); let src = fs.readFileSync(cfg, "utf8"); if (src.includes("outputFileTracingRoot")) process.exit(0); // Inject right after the FIRST config-object opening brace // const nextConfig... = { -> = {\n outputFileTracingRoot: process.cwd(), const m = src.match(/(=\s*\{)/); if (!m) process.exit(0); const i = src.indexOf(m[1]) + m[1].length; src = src.slice(0, i) + "\n outputFileTracingRoot: process.cwd()," + src.slice(i); fs.writeFileSync(cfg, src); EOF # ----------------------- 4. start convex dev, then bridge env, then start Next # These run SERIALLY because Next reads NEXT_PUBLIC_CONVEX_URL from # .env.local exactly once at startup, and that var only exists after # `convex dev` has populated CONVEX_URL. Starting them in parallel # results in Next booting with NEXT_PUBLIC_CONVEX_URL undefined and the # ConvexClientProvider throwing in the browser. # # Convex dev mode selection. # # DEFAULT: cloud mode when the user is logged in (~/.convex/config.json # present OR CONVEX_DEPLOY_KEY set). Cloud creates a real project so # `npm run deploy` can publish to a public .convex.site URL without # further setup, and the auto-team-picker below handles multi-team users # without prompting. # # Anonymous local is used when: # - User isn't logged in, OR # - QB_USE_CLOUD=0 is explicitly set (opt-out, e.g. for offline work) # # Cloud only takes effect if we can resolve a team — either CONVEX_TEAM # is set, CONVEX_DEPLOY_KEY is set, or the auto-picker succeeds. If we # can't resolve a team, fall back to anonymous to avoid the team-prompt # hang. WANT_CLOUD=0 if [[ "${QB_USE_CLOUD:-1}" != "0" \ && (-f "$HOME/.convex/config.json" || -n "${CONVEX_DEPLOY_KEY:-}") ]]; then WANT_CLOUD=1 fi if [[ "$WANT_CLOUD" == "1" ]]; then # Auto-pick CONVEX_TEAM if not set: query Big Brain for the user's # teams, prefer one whose name contains an apostrophe (heuristic for # the user's personal "'s team"), fall back to the first team. # Without this, multi-team users hang on the team-selection prompt. if [[ -z "${CONVEX_TEAM:-}" && -f "$HOME/.convex/config.json" ]]; then AUTH_TOKEN=$(node -e 'try { process.stdout.write(JSON.parse(require("fs").readFileSync(process.env.HOME + "/.convex/config.json", "utf8")).accessToken || ""); } catch (e) {}' 2>/dev/null || true) if [[ -n "$AUTH_TOKEN" ]]; then TEAMS_JSON=$(curl -fsSL \ -H "Authorization: Bearer $AUTH_TOKEN" \ https://api.convex.dev/api/teams 2>/dev/null || true) if [[ -n "$TEAMS_JSON" ]]; then CONVEX_TEAM=$(echo "$TEAMS_JSON" | node -e ' let teams; try { teams = JSON.parse(require("fs").readFileSync(0, "utf8")); } catch (e) { process.exit(0); } if (!Array.isArray(teams) || !teams.length) process.exit(0); // Prefer team whose name contains an apostrophe (s) — usually the // user_s personal default. Fall back to first. (apostrophe via charCode // so this stays inside a single-quoted shell argument.) const apos = String.fromCharCode(39); const preferred = teams.find((t) => t && typeof t === "object" && String(t.name || "").includes(apos)); const pick = preferred || teams[0]; process.stdout.write((pick && pick.slug) || ""); ' 2>/dev/null || true) [[ -n "$CONVEX_TEAM" ]] && step "Auto-picked CONVEX_TEAM=$CONVEX_TEAM" fi fi fi # Last safety check: if we still don't have a team and don't have a # deploy key, drop to anonymous instead of risking the team prompt. if [[ -z "${CONVEX_TEAM:-}" && -z "${CONVEX_DEPLOY_KEY:-}" ]]; then warn "Cloud mode requested but no team could be resolved (auto-pick failed). Falling back to anonymous local. Set CONVEX_TEAM= to force cloud mode." WANT_CLOUD=0 fi fi # --------------------- 3f. QB_FEEDBACK: swap inline chef panel → @convex-dev/feedback component # OFF by default (the inline chef panel above is the base). With QB_FEEDBACK=1 we # install the reusable component, OVERWRITE the four inline function files (same # names → existing references still resolve) + drop their tables, and swap # app/_chef-panel.tsx for a thin loader that pulls the anteater-served # web component (so CSS/display fixes ship instantly, no rebuild). if feature_on FEEDBACK; then QB_FEEDBACK_REF="${QB_FEEDBACK_REF:-github:sethconvex/feedback#ec56a617bb0affdc52aecaceb559449589bac632}" # Extension-less path: Cloudflare force-caches `*.js` ~4h, which would block # instant panel fixes; the no-ext route honors our short max-age. QB_PANEL_JS_URL="${QB_PANEL_JS_URL:-${QB_FEEDBACK_URL%/feedback}/chef-panel}" step "Feedback mode: installing @convex-dev/feedback component ($PKG_MGR)" if [[ "$PKG_MGR" == "pnpm" ]]; then pnpm add "$QB_FEEDBACK_REF" >/dev/null 2>&1 || warn "feedback install failed — staying on inline tables" else npm i "$QB_FEEDBACK_REF" >/dev/null 2>&1 || warn "feedback install failed — staying on inline tables" fi # Guard (defense-in-depth for the #1 bootstrap killer): Convex's module-path # validator rejects any path component containing a hyphen/dot beyond the # extension (e.g. panel/chef-panel.js → "is not a valid path to a Convex # module"), and it scans EVERY file a used component ships. A single stray # hyphenated asset there fails every `convex dev` push until it's removed. # The web component is served by the anteater, so the component # never needs to ship it. Such files can never load as Convex modules anyway, # so pruning them is always safe — do it BEFORE the first push. # ONLY hyphens make a filename an invalid Convex module (e.g. chef-panel.js). # Match a literal hyphen in the filename — NOT "any non-alphanumeric after # stripping one extension", which wrongly deleted convex.config.ts (basename # `convex.config` has a dot) and killed `convex dev` with "Could not resolve # @convex-dev/feedback/convex.config". convex.config.ts / schema.ts / *.d.ts # have no hyphen, so they're kept. if [[ -d node_modules/@convex-dev ]]; then PRUNED=$(find node_modules/@convex-dev -type f \( -name '*-*.js' -o -name '*-*.ts' \) 2>/dev/null) if [[ -n "$PRUNED" ]]; then printf '%s\n' "$PRUNED" | while IFS= read -r f; do [[ -n "$f" ]] && rm -f "$f"; done warn "Pruned hyphenated file(s) from @convex-dev component(s) that Convex's bundler rejects:" printf '%s\n' "$PRUNED" | sed 's/^/ - /' fi fi if [[ -d node_modules/@convex-dev/feedback ]]; then step "Feedback mode: writing convex.config.ts + wrappers, dropping inline tables" cat > convex/convex.config.ts <<'EOF' import { defineApp } from "convex/server"; import feedback from "@convex-dev/feedback/convex.config"; const app = defineApp(); app.use(feedback); export default app; EOF # Schema: drop the 4 inline chef tables; keep authTables only if passkeys wired. if [[ -f convex/auth.ts ]]; then cat > convex/schema.ts <<'EOF' import { defineSchema } from "convex/server"; import { authTables } from "@convex-dev/auth/server"; // Feedback tables live in the @convex-dev/feedback component, not here. export default defineSchema({ ...authTables }); EOF else cat > convex/schema.ts <<'EOF' import { defineSchema } from "convex/server"; // Feedback tables live in the @convex-dev/feedback component, not here. export default defineSchema({}); EOF fi cat > convex/feedbackClient.ts <<'EOF' import { components } from "./_generated/api"; import { Feedback } from "@convex-dev/feedback"; export const feedback = new Feedback(components.feedback); // The wow-shell agent (CLI / deploy key) authenticates to gated reads with this // key, provisioned once at scaffold time and set as a deployment env var. // Read at CALL TIME (not module-load) so the key set after `convex dev` applies. export const agentKey = () => process.env.FEEDBACK_AGENT_KEY; EOF cat > convex/setupFeedback.ts <<'EOF' import { mutation, internalMutation } from "./_generated/server"; import { v } from "convex/values"; import { feedback } from "./feedbackClient"; // Provision the agent key once at scaffold time; the bootstrap captures the // returned key and sets it as FEEDBACK_AGENT_KEY on the deployment. export const provisionAgentKey = internalMutation({ args: {}, returns: v.any(), handler: async (ctx) => feedback.agentKeys.create(ctx, { name: "wow-agent", adminUserId: "system" }), }); EOF cat > convex/progress.ts <<'EOF' import { mutation, internalMutation, query } from "./_generated/server"; import { v } from "convex/values"; import { feedback } from "./feedbackClient"; const vKind = v.union(v.literal("step"), v.literal("shipped"), v.literal("note")); export const post = internalMutation({ // Accept both `message` and `text` (agents guess `text` from refinementQuestions:ask). args: { message: v.optional(v.string()), text: v.optional(v.string()), kind: v.optional(vKind) }, returns: v.null(), handler: async (ctx, { message, text, kind }) => { await feedback.progress.post(ctx, { message: message ?? text ?? "", kind: kind ?? "step" }); return null; // never return an id (avoids the todos:setStatus id mixup) }, }); export const listRecent = query({ args: { limit: v.optional(v.number()) }, handler: (ctx, args) => feedback.progress.listRecent(ctx, args), }); EOF cat > convex/todos.ts <<'EOF' import { mutation, internalMutation, query } from "./_generated/server"; import { v } from "convex/values"; import { feedback } from "./feedbackClient"; export const plan = internalMutation({ args: { items: v.array(v.string()) }, returns: v.null(), handler: async (ctx, args) => { await feedback.todos.plan(ctx, args); return null; }, }); export const advance = internalMutation({ args: {}, returns: v.null(), handler: async (ctx) => { await feedback.todos.advance(ctx); return null; }, }); export const setStatus = internalMutation({ args: { id: v.string(), status: v.union(v.literal("pending"), v.literal("active"), v.literal("done")) }, returns: v.null(), handler: async (ctx, args) => { await feedback.todos.setStatus(ctx, args as any); return null; }, }); export const listAll = query({ args: {}, handler: (ctx) => feedback.todos.listAll(ctx), }); EOF cat > convex/featureRequests.ts <<'EOF' import { mutation, internalMutation, query } from "./_generated/server"; import { v } from "convex/values"; import { feedback, agentKey } from "./feedbackClient"; const project = (i: any) => ({ _id: i._id, _creationTime: i._creationTime, title: i.title, description: i.description, state: i.state, voteCount: i.stats?.totalAmount ?? 0 }); export const submit = mutation({ args: { title: v.string(), description: v.string() }, returns: v.null(), handler: async (ctx, a) => { await feedback.items.create(ctx, { userId: "system", title: a.title, description: a.description, autoApprove: true }); return null; }, }); export const listPublic = query({ args: { state: v.optional(v.string()), limit: v.optional(v.number()) }, handler: async (ctx, a) => { const res: any = await feedback.items.listPublic(ctx, { limit: a.limit }); return (res.page || []).map(project); }, }); export const listPending = query({ args: { limit: v.optional(v.number()) }, handler: async (ctx, a) => { // Gated read: if the agent key isn't set (e.g. anonymous local dev), the // component throws Unauthorized. Treat that as "nothing visible" rather // than letting an uncaught error spam the watched error log. try { const res: any = await feedback.items.listByState(ctx, { state: "requested", limit: a.limit, agentKey: agentKey() }); return (res.page || []).map(project); } catch (e: any) { if (String(e?.message ?? e).includes("Unauthorized")) return []; throw e; } }, }); export const setState = internalMutation({ args: { id: v.string(), state: v.string() }, returns: v.null(), handler: async (ctx, a) => { await feedback.items.transitionState(ctx, { itemId: a.id as any, state: a.state as any }); return null; }, }); EOF cat > convex/refinementQuestions.ts <<'EOF' import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { feedback, agentKey } from "./feedbackClient"; // ask: the agent asks a clarifying question (a refinement item). export const ask = mutation({ args: { text: v.string(), description: v.optional(v.string()) }, returns: v.null(), handler: async (ctx, a) => { await feedback.items.create(ctx, { userId: "system", title: a.text, description: a.description ?? "", kind: "refinement", autoApprove: true }); return null; }, }); // answer: the user's reply is a devLog on the item (component model). export const answer = mutation({ args: { id: v.string(), answer: v.string(), viewer: v.optional(v.union(v.string(), v.null())) }, returns: v.null(), handler: async (ctx, a) => { await feedback.devLogs.post(ctx, { itemId: a.id as any, authorId: a.viewer ?? "user", message: a.answer }); return null; }, }); export const skip = mutation({ args: { id: v.string() }, returns: v.null(), handler: async (ctx, a) => { await feedback.items.transitionState(ctx, { itemId: a.id as any, state: "rejected" }); return null; }, }); // listOpen (gated): reshape component refinements → {_id,text,answer?,state} for the agent + panel. export const listOpen = query({ args: { limit: v.optional(v.number()) }, handler: async (ctx, a) => { // Gated read. A MISSING key means FEEDBACK_AGENT_KEY was never set (e.g. the // first push failed before provisioning) — THROW a clear, identifiable error // so the agent's answer-poll loop sees a real failure instead of an empty // list it misreads as "the user already answered". Silent [] here is exactly // what made past runs falsely report "ANSWERED after 3s". if (!agentKey()) { throw new Error("FEEDBACK_AGENT_KEY not set — refinement reads are gated. Provision it: `npx convex run setupFeedback:provisionAgentKey` then `npx convex env set FEEDBACK_AGENT_KEY `."); } const items: any[] = await feedback.items.listRefinementOpen(ctx, { limit: a.limit, agentKey: agentKey() }); const out = []; for (const it of items) { const logs: any[] = await feedback.devLogs.listForItem(ctx, { itemId: it._id }); const answer = logs.length ? logs[logs.length - 1].message : undefined; out.push({ _id: it._id, text: it.title, answer, state: answer ? "answered" : "open", askedAtMs: it._creationTime }); } return out; }, }); EOF cat > convex/wow.ts <<'EOF' import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { feedback } from "./feedbackClient"; const project = (i: any) => ({ _id: i._id, _creationTime: i._creationTime, title: i.title, description: i.description, state: i.state, voteCount: i.stats?.totalAmount ?? 0 }); const vViewer = v.optional(v.union(v.string(), v.null())); // Panel build chrome (gated server-side): pass the signed-in viewer so admins // see build status and members don't (no agentKey from the browser). export const agentState = query({ args: { viewer: vViewer }, handler: (ctx, a) => feedback.agentState.snapshot(ctx, { viewer: a.viewer ?? null }), }); export const listPublicItems = query({ args: { viewer: vViewer }, handler: async (ctx) => { const r: any = await feedback.items.listPublic(ctx, {}); return (r.page || []).map(project); }, }); export const submitRequest = mutation({ args: { title: v.string(), description: v.optional(v.string()), viewer: vViewer }, returns: v.null(), handler: async (ctx, a) => { await feedback.items.create(ctx, { userId: a.viewer ?? "anon", title: a.title, description: a.description ?? "", autoApprove: true }); return null; }, }); export const upvoteRequest = mutation({ args: { id: v.string(), viewer: vViewer }, returns: v.null(), handler: async (ctx, a) => { await feedback.bids.place(ctx, { userId: a.viewer ?? "anon", itemId: a.id as any, amount: 1 }); return null; }, }); export const answerRefinement = mutation({ args: { id: v.string(), answer: v.string(), viewer: vViewer }, returns: v.null(), handler: async (ctx, a) => { await feedback.devLogs.post(ctx, { itemId: a.id as any, authorId: a.viewer ?? "user", message: a.answer }); return null; }, }); export const skipRefinement = mutation({ args: { id: v.string(), viewer: vViewer }, returns: v.null(), handler: async (ctx, a) => { await feedback.items.transitionState(ctx, { itemId: a.id as any, state: "rejected" }); return null; }, }); EOF cat > convex/users.ts <<'EOF' import { mutation } from "./_generated/server"; import { v } from "convex/values"; import { feedback } from "./feedbackClient"; // Called from the passkey sign-in path: first user becomes admin (creator). export const ensure = mutation({ args: { userId: v.string() }, returns: v.object({ userId: v.string(), role: v.union(v.literal("admin"), v.literal("member")) }), handler: (ctx, a) => feedback.users.ensure(ctx, a), }); EOF # Served-panel loader: replaces the inline bubble. Pulls the anteater-served # web component; CSS/display fixes ship instantly (no rebuild). cat > app/_chef-panel.tsx <<'EOF' "use client"; import { useEffect } from "react"; // The panel's UI lives in chef-panel.js, served from anteater so display/CSS // fixes ship instantly. It talks to the host's wow:* wrapper functions. const PANEL_JS = "__PANEL_JS__"; // NAMED export — layout.tsx does `import { ChefPanel } from "./_chef-panel"`. // A default export makes ChefPanel undefined → "Element type is invalid" → 500. export function ChefPanel() { useEffect(() => { if (typeof window === "undefined") return; const url = process.env.NEXT_PUBLIC_CONVEX_URL; if (!url) return; if (!document.querySelector(`script[data-chef-panel]`)) { const s = document.createElement("script"); s.type = "module"; s.src = PANEL_JS; s.setAttribute("data-chef-panel", "1"); document.head.appendChild(s); } const el = document.createElement("chef-panel"); el.setAttribute("convex-url", url); el.setAttribute("prefix", "wow"); document.body.appendChild(el); return () => { el.remove(); }; }, []); return null; } EOF sed -i.bak "s|__PANEL_JS__|$QB_PANEL_JS_URL|" app/_chef-panel.tsx && rm -f app/_chef-panel.tsx.bak step "Feedback mode: component wired (panel served from $QB_PANEL_JS_URL)" fi fi # --------------------- 3g. QB_VERSION: live "a new version is available" banner # Auto-wires the version UX that pairs with convex.app publishing (it was # previously an optional manual runbook step that nobody did, so the publish # script's appVersion:recordDeploy call silently no-op'd). Writes the # appVersion function + table, the reactive , and mounts it # in the layout so any open tab offers a reload the instant a re-publish lands. if feature_on VERSION; then step "Version banner: wiring appVersion table + (reactive reload prompt)" cat > convex/appVersion.ts <<'EOF' import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; // Tracks the currently-published static-site version in a single row, so open // tabs can show "a new version is available" the instant a re-publish lands. // `node publish-convex-app.mjs` calls recordDeploy after a successful upload. export const getCurrent = query({ args: {}, returns: v.union( v.object({ version: v.number(), deploymentName: v.string(), deployedAt: v.number() }), v.null(), ), handler: async (ctx) => { const row = await ctx.db.query("appVersion").first(); return row ? { version: row.version, deploymentName: row.deploymentName, deployedAt: row.deployedAt } : null; }, }); export const recordDeploy = mutation({ args: { deploymentName: v.string() }, returns: v.number(), handler: async (ctx, { deploymentName }) => { const row = await ctx.db.query("appVersion").first(); if (row) { const version = row.version + 1; await ctx.db.patch(row._id, { version, deploymentName, deployedAt: Date.now() }); return version; } await ctx.db.insert("appVersion", { version: 1, deploymentName, deployedAt: Date.now() }); return 1; }, }); EOF # Inject the appVersion table into schema.ts (ensuring defineTable + v imports # exist — the feedback-mode schema imports neither). node - <<'JS' const fs = require("fs"); const p = "convex/schema.ts"; let s = fs.readFileSync(p, "utf8"); if (!s.includes("defineTable")) { s = s.replace('import { defineSchema } from "convex/server";', 'import { defineSchema, defineTable } from "convex/server";'); } if (!s.includes('from "convex/values"')) { s = s.replace('import { defineSchema', 'import { v } from "convex/values";\nimport { defineSchema'); } if (!s.includes("appVersion:")) { s = s.replace("export default defineSchema({", 'export default defineSchema({\n appVersion: defineTable({ version: v.number(), deploymentName: v.string(), deployedAt: v.number() }),'); } fs.writeFileSync(p, s); JS mkdir -p components cat > components/NewVersionBanner.tsx <<'EOF' "use client"; import { useEffect, useRef, useState } from "react"; import { useQuery } from "convex/react"; import { api } from "@/convex/_generated/api"; // Shows a "new version available" banner when the published static-site version // increments past the one this tab loaded with. Reactive: appVersion.getCurrent // updates the instant `node publish-convex-app.mjs` records a new deploy. export function NewVersionBanner() { const current = useQuery(api.appVersion.getCurrent); const baseline = useRef(null); const [stale, setStale] = useState(false); useEffect(() => { if (current == null) return; if (baseline.current === null) baseline.current = current.version; else if (current.version > baseline.current) setStale(true); }, [current]); if (!stale) return null; return (
A new version is available
); } EOF # Mount in the layout, next to . node - <<'JS' const fs = require("fs"); const p = "app/layout.tsx"; let s = fs.readFileSync(p, "utf8"); if (!s.includes("NewVersionBanner")) { s = s.replace('import { ChefPanel } from "./_chef-panel";', 'import { ChefPanel } from "./_chef-panel";\nimport { NewVersionBanner } from "@/components/NewVersionBanner";'); s = s.replace("", "\n "); fs.writeFileSync(p, s); } JS step "Version banner wired (republish bumps appVersion → open tabs offer reload)" fi if [[ "$WANT_CLOUD" == "1" ]]; then step "Starting npx convex dev (cloud project: $APP_NAME) in background" TEAM_FLAG="" if [[ -n "${CONVEX_TEAM:-}" ]]; then TEAM_FLAG="--team '$CONVEX_TEAM'" fi # Feed a few newlines on stdin so a fresh-project REGION prompt (newer Convex # CLI asks new cloud projects to pick a region) — and any other default-having # prompt — accepts its default instead of blocking forever. There's no human # TTY to answer it, so without this `convex dev` hangs, no deployment is # provisioned, and every downstream `env set` (auth keys, feedback key) # silently fails. Mirrors the anonymous path's piped stdin; EOF after the # newlines is fine (watch mode keeps running, same as the `echo n` path). daemonize "$CONVEX_LOG" script -q /dev/null bash -c \ "printf '\\n\\n\\n' | npx convex dev --configure new --project '$APP_NAME' $TEAM_FLAG" CONVEX_MODE_USED="cloud" else step "Starting npx convex dev (anonymous local) in background" # `script -q /dev/null` gives the CLI a pseudo-TTY; `echo n` declines the # link-to-cloud prompt; CONVEX_AGENT_MODE=anonymous covers the case where # the CLI still prompts for Team selection. daemonize "$CONVEX_LOG" script -q /dev/null bash -c \ 'echo n | CONVEX_AGENT_MODE=anonymous npx convex dev' CONVEX_MODE_USED="anonymous" fi # ----------------------- 4a. wait for Convex functions ready # The raw CONVEX_LOG comes through `script`'s pseudo-TTY, so it's full of # spinner/cursor ANSI that makes `tail`/Read near-useless. Keep an ANSI-stripped # copy the agent can read directly, and surface known-fatal push errors the # instant they appear instead of timing out 90s on a generic message. CONVEX_CLEAN_LOG="$LOG_DIR/convex-dev.clean.log" strip_ansi() { sed -E $'s/\x1b\\[[0-9;?]*[a-zA-Z]//g; s/\x1b[]][^\x07]*\x07//g; s/\r//g'; } # "functions will NEVER become ready" errors — no point waiting the full 90s, # and the agent needs the actual line, not a timeout. CONVEX_FATAL_RE='InvalidConfig|is not a valid path to a Convex module|Schema validation failed|ReturnsValidationError|ArgumentValidationError|Unable to start push|Hit an error while pushing' printf " waiting for Convex to be ready" CONVEX_FATAL="" for _ in $(seq 1 90); do if grep -q "Convex functions ready" "$CONVEX_LOG" 2>/dev/null; then echo " ✓" break fi CONVEX_FATAL=$(cat "$CONVEX_LOG" 2>/dev/null | strip_ansi | grep -aoE "($CONVEX_FATAL_RE).*" | head -n1 || true) if [[ -n "$CONVEX_FATAL" ]]; then echo " ✗" break fi printf "." sleep 1 done # Refresh the clean copy so the agent can `Read`/`tail` it without sed. strip_ansi < "$CONVEX_LOG" > "$CONVEX_CLEAN_LOG" 2>/dev/null || true if [[ -n "$CONVEX_FATAL" ]]; then warn "BOOTSTRAP_PUSH_ERROR: Convex push failed — functions will NOT be ready until this is fixed:" printf " %s\n" "$CONVEX_FATAL" printf " (clean log: %s)\n" "$CONVEX_CLEAN_LOG" elif ! grep -q "Convex functions ready" "$CONVEX_LOG" 2>/dev/null; then warn "Convex didn't print 'functions ready' in 90s — check $CONVEX_CLEAN_LOG (ANSI-stripped)" fi # --------------------- 4a-feedback. provision the feedback agent key # Gives the wow-shell agent privileged access to the gated component reads # (listOpen/listPending). The component fns can take a beat to be callable # right after "functions ready", so retry a few times. refinementQuestions:listOpen # now THROWS when this key is missing (instead of silently returning []), so a # failure here surfaces loudly to the agent rather than masquerading as an empty # queue — but provision it here so the happy path never hits that error. if feature_on FEEDBACK; then step "Feedback: provisioning agent key (FEEDBACK_AGENT_KEY)" FB_KEY="" for _ in 1 2 3 4 5; do FB_KEY=$(npx convex run setupFeedback:provisionAgentKey '{}' 2>/dev/null | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).key' 2>/dev/null || true) [[ -n "$FB_KEY" ]] && break sleep 2 done KEY_OK=0 if [[ -n "$FB_KEY" ]] && npx convex env set FEEDBACK_AGENT_KEY "$FB_KEY" >/dev/null 2>&1; then # Verify it actually landed on the deployment (env set can succeed-but-noop # if the push was still settling). This is the authoritative check. if npx convex env list 2>/dev/null | grep -q '^FEEDBACK_AGENT_KEY='; then KEY_OK=1 step "FEEDBACK_AGENT_KEY set + verified (agent can read the full queue)" fi fi if [[ "$KEY_OK" != "1" ]]; then warn "BOOTSTRAP_WARN: FEEDBACK_AGENT_KEY could not be provisioned — gated reads (refinementQuestions:listOpen) will THROW until it's set. Once 'convex dev' is healthy, run:" printf " npx convex run setupFeedback:provisionAgentKey # copy .key\n" printf " npx convex env set FEEDBACK_AGENT_KEY \n" fi fi # ----------------------- 4a-bis. bridge .env.local for Next.js # `convex dev` writes CONVEX_URL (and sometimes VITE_CONVEX_URL) to # .env.local, but Next only exposes env vars to the browser if they're # prefixed NEXT_PUBLIC_*. Without this bridge, the ConvexClientProvider's # `process.env.NEXT_PUBLIC_CONVEX_URL` is undefined and the page errors. if [[ -f .env.local ]] && ! grep -q "^NEXT_PUBLIC_CONVEX_URL=" .env.local; then # `|| true` so a .env.local that lacks both keys doesn't kill the # script via set -e + pipefail. CONVEX_URL_VAL=$( { grep -E '^(VITE_CONVEX_URL|CONVEX_URL)=' .env.local \ | head -n1 | cut -d= -f2-; } || true) if [[ -n "$CONVEX_URL_VAL" ]]; then echo "NEXT_PUBLIC_CONVEX_URL=$CONVEX_URL_VAL" >> .env.local step "Bridged NEXT_PUBLIC_CONVEX_URL=$CONVEX_URL_VAL into .env.local" else warn ".env.local exists but has neither CONVEX_URL nor VITE_CONVEX_URL — Next will boot with undefined NEXT_PUBLIC_CONVEX_URL" fi fi # ----------------------- 4b. start npm run dev (now sees the bridged env) # Pre-allocate a free port via node's "listen on 0, ask kernel for one" # trick, then pass it explicitly to Next via PORT=. Why bother: # - Next's auto-fallthrough (3000 → 3001 → 3002) only avoids ports it # can't *bind* to. If another process is listening on 127.0.0.1:3001 # and we bind to *:3001, both binds succeed but localhost: clients # route to the more-specific listener — not us. Curl + browser see # the wrong app, even though Next reported "Local: 3001". # - Pre-asking the kernel for a port avoids the collision class entirely. NEXT_PORT=$(node -e 'const s = require("net").createServer(); s.listen(0, "127.0.0.1", () => { const port = s.address().port; s.close(() => process.stdout.write(String(port))); });' 2>/dev/null || true) if [[ -n "$NEXT_PORT" ]]; then step "Starting npm run dev on pre-allocated port :$NEXT_PORT" PORT="$NEXT_PORT" daemonize "$NEXT_LOG" npm run dev else # node missing or socket bind failed — fall back to letting Next # pick its own port and parsing it from the log later. warn "Couldn't pre-allocate a free port; letting Next.js pick" step "Starting npm run dev in background" daemonize "$NEXT_LOG" npm run dev fi # ----------------------- 4c. wait for Next.js to actually answer # When we pre-allocated NEXT_PORT, the URL is fixed and we just curl-probe # until Next's listener accepts. Otherwise we fall back to parsing the # Local: line out of the log (with all the safety hedges below). APP_URL="" if [[ -n "$NEXT_PORT" ]]; then APP_URL="http://localhost:$NEXT_PORT" printf " waiting for Next.js to answer at %s" "$APP_URL" # Two-stage wait: (1) port accepts a TCP connection and Next responds # to a curl probe; (2) Next has actually finished compiling — its log # prints "Ready in" or "compiled" once the page is interactive. The # second stage prevents opening the browser to a "compiling..." stub # that breaks the user's first impression. for _ in $(seq 1 60); do if curl -sf -o /dev/null "$APP_URL"; then break fi printf "." sleep 1 done printf " (compiling)" for _ in $(seq 1 60); do if grep -qE 'Ready in|✓ Compiled|compiled successfully' "$NEXT_LOG" 2>/dev/null; then break fi printf "." sleep 1 done echo " ✓" else # No pre-allocated port → parse from log. CRITICAL: anchor on the # "Local:" line, NOT a generic "http://localhost:NNNN" match — Next # prints "Port 3000 is in use, trying 3001 instead" BEFORE the actual # Local: line, and a naive grep+head would grab :3000 from that warning. # Trailing `|| true` defuses set -euo pipefail when grep finds nothing. printf " waiting for Next.js Local: line" for _ in $(seq 1 60); do APP_URL=$( { grep -oE 'Local:[[:space:]]+http://localhost:[0-9]+' "$NEXT_LOG" 2>/dev/null \ | grep -oE 'http://localhost:[0-9]+' \ | tail -n1; } || true) if [[ -n "$APP_URL" ]] && curl -sf -o /dev/null "$APP_URL"; then echo " ✓ $APP_URL" break fi printf "." sleep 1 done if [[ -z "$APP_URL" ]]; then warn "Next.js didn't print a Local: line in 60s — falling back to log-grep" APP_URL=$( { grep -oE 'http://localhost:[0-9]+' "$NEXT_LOG" 2>/dev/null \ | tail -n1; } || true) if [[ -z "$APP_URL" ]]; then warn "No URL at all in $NEXT_LOG — defaulting to :3000" APP_URL="http://localhost:3000" fi fi fi # --------------------- 5.5. (passkeys mode) generate auth keys + set env # Now that the deployment is up (convex dev) and APP_URL is known, finish the # pre-bake: generate the RS256 keypair with jose and set the three env vars. # On localhost the passkey RP ID + origin default from SITE_URL, so no # AUTH_PASSKEY_* needed for dev. This is the rest of what STEP A0 used to do. if feature_on PASSKEYS; then step "Passkeys mode: generating auth keys + setting deployment env" AUTH_KEYS=$(node -e ' import("jose").then(async ({ generateKeyPair, exportPKCS8, exportJWK }) => { const keys = await generateKeyPair("RS256", { extractable: true }); const privateKey = await exportPKCS8(keys.privateKey); const publicKey = await exportJWK(keys.publicKey); const jwks = JSON.stringify({ keys: [{ use: "sig", ...publicKey }] }); process.stdout.write(JSON.stringify({ JWT_PRIVATE_KEY: privateKey.trimEnd().replace(/\n/g, " "), JWKS: jwks })); }).catch(e => { console.error(e); process.exit(1); }); ' 2>/dev/null || true) if [[ -n "$AUTH_KEYS" ]]; then JWT_VAL=$(printf '%s' "$AUTH_KEYS" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).JWT_PRIVATE_KEY') JWKS_VAL=$(printf '%s' "$AUTH_KEYS" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).JWKS') # NAME=VALUE form: JWT_PRIVATE_KEY starts with '-----BEGIN', so a separate # arg would be parsed as a flag by the CLI. npx convex env set "JWT_PRIVATE_KEY=$JWT_VAL" >/dev/null 2>&1 || warn "passkeys: env set JWT_PRIVATE_KEY failed (agent must do A0.2)" npx convex env set "JWKS=$JWKS_VAL" >/dev/null 2>&1 || warn "passkeys: env set JWKS failed" npx convex env set "SITE_URL=$APP_URL" >/dev/null 2>&1 || warn "passkeys: env set SITE_URL failed" step "Passkey auth env set (SITE_URL=$APP_URL; RP_ID/ORIGIN default from it on localhost)" else warn "passkeys: jose key-gen failed — agent must set JWT_PRIVATE_KEY/JWKS/SITE_URL (A0.2)" fi fi # ----------------------------------------------------- 6. open browser # The bootstrap deliberately does NOT open the browser itself: in a real # desktop env the OS opener AND the agent's open both fire → the user gets # two tabs. The agent is the single, authoritative opener (it works across # harnesses — native tool or a posted clickable link). We just emit the URL. # Authoritative signal: a clearly-marked block the agent should grep for # and act on immediately. Print this NOW (before the final report) so the # agent can hand the URL to the user the moment the script returns. cat </dev/null \\ | grep --line-buffered -E 'error TS|ReturnsValidationError|ArgumentValidationError|Schema validation failed|Could not resolve|Cannot resolve|✘|\\bERROR\\b|Failed to authenticate|nonInteractiveError|Server Error|Uncaught Error|TypeError|ReferenceError|RangeError|Map maximum size|use node|SystemTimeoutError|Too many reads|Too many writes|exceeds the limit|LimitsExceeded|hit a limit|OCC conflict|Error:' \\ | grep --line-buffered -v 'Unknown passkey' " daemonize "$NEXT_ERR_LOG" bash -c " tail -F '$NEXT_LOG' 2>/dev/null \\ | grep --line-buffered -E '⨯|\\bERROR\\b|Error:|RangeError|Map maximum size|Failed to compile|Module not found|Cannot find module|ECONNREFUSED|ENOTFOUND| 500 | 502 | 503' " step "Starting featureRequests watch subscription → $FR_LOG" # Capture the project dir NOW so the subshell cd's correctly even if the # parent shell moves later. WATCH_DIR="$(pwd)" daemonize "$FR_LOG" bash -c " cd '$WATCH_DIR' || exit 1 while true; do echo \"[watcher] starting at \$(date -u +%Y-%m-%dT%H:%M:%SZ)\" npx convex run --watch featureRequests:listPublic \\ '{\"state\":\"requested\",\"limit\":10}' 2>&1 echo \"[watcher] exited (rc=\$?); restarting in 2s to re-read .env.local\" sleep 2 done " step "Starting refinementQuestions watch subscription → $RQ_LOG" daemonize "$RQ_LOG" bash -c " cd '$WATCH_DIR' || exit 1 while true; do echo \"[watcher] starting at \$(date -u +%Y-%m-%dT%H:%M:%SZ)\" npx convex run --watch refinementQuestions:listOpen '{\"limit\":10}' 2>&1 echo \"[watcher] exited (rc=\$?); restarting in 2s\" sleep 2 done " # --------------------------- 7.4. convex dev death detector # Failure mode that cost a real run ~6 recovery turns: the local `convex dev` # (the process that PUSHES code + regenerates convex/_generated) dies silently, # but `npx convex run` keeps succeeding because it hits the CLOUD deployment — # which still has the OLD functions. So the agent believes new backend code is # live when it was never pushed. Emit a LOUD marker into the convex-errors.log # the agent already polls, so the death is caught immediately (detection, not # auto-restart: a blind restart would re-run `--configure new`). daemonize "$LOG_DIR/convex-dev-health.log" bash -c " cd '$WATCH_DIR' || exit 1 sleep 20 # let the initial 'configure new' + first push settle warned=0 fails=0 stale=0 stale_warned=0 key_healed=0 key_attempts=0 qbfb='${QB_FEEDBACK:-}' while true; do # Liveness: a LOOSER pattern than a \$WATCH_DIR-anchored one (which misses a # convex dev launched via a different argv → permanent false-dead), corroborated # by the local backend port. Alive if EITHER hits. alive=0 if pgrep -f 'convex/bin/main.js dev' >/dev/null 2>&1; then alive=1 elif lsof -ti :3210 >/dev/null 2>&1; then alive=1; fi if [ \"\$alive\" = 1 ]; then fails=0 if [ \"\$warned\" = 1 ]; then echo \"CONVEX_DEV_RECOVERED at \$(date -u +%H:%M:%SZ) — 'convex dev' is alive again; IGNORE the CONVEX_DEV_DEAD line above this point. Your backend code is being pushed again.\" >> '$CONVEX_ERR_LOG' warned=0 fi # --- stale-push: alive but not deploying edits --- # convex dev's process is up, but a convex/ source edit has stayed NEWER than # the dev log for ~60s → the watcher wedged after a deploy (a real failure seen # in testing) and your saves aren't reaching the backend. (Distinct from DEAD, # which is process-gone.) Warn once; clear when it catches up. if find convex -name '*.ts' -not -path '*/_generated/*' -newer '$CONVEX_LOG' 2>/dev/null | grep -q .; then stale=\$((stale+1)) if [ \"\$stale\" -ge 12 ] && [ \"\$stale_warned\" = 0 ]; then echo \"CONVEX_DEV_STALE at \$(date -u +%H:%M:%SZ) — 'convex dev' is running but hasn't pushed your latest convex/ edit (a source file has been newer than the dev log for ~60s); the watcher likely wedged after a deploy. Force a push: (cd '$WATCH_DIR' && npx convex dev --once), or kill + restart the watcher.\" >> '$CONVEX_ERR_LOG' stale_warned=1 fi else stale=0; stale_warned=0 fi # --- self-heal: ensure FEEDBACK_AGENT_KEY once the deployment is alive --- # If the synchronous provisioning step (4a-feedback) failed because convex # dev wasn't ready yet (the classic region-prompt aftermath: it comes up # only AFTER the 90s wait), set the key now. Otherwise refinementQuestions: # listOpen THROWS forever and the agent can never consume the refinement # queue. Check-before-provision (provisionAgentKey isn't idempotent), cap # attempts, and on success drop a HEALED marker into the error log the # agent polls so the stale 'not set' lines above it stop misleading it. if [ \"\$qbfb\" = 1 ] && [ \"\$key_healed\" = 0 ]; then if npx convex env list 2>/dev/null | grep -q '^FEEDBACK_AGENT_KEY='; then key_healed=1 elif [ \"\$key_attempts\" -lt 12 ]; then key_attempts=\$((key_attempts+1)) K=\$(npx convex run setupFeedback:provisionAgentKey '{}' 2>/dev/null | node -pe 'JSON.parse(require(\"fs\").readFileSync(0,\"utf8\")).key' 2>/dev/null || true) if [ -n \"\$K\" ] && npx convex env set FEEDBACK_AGENT_KEY \"\$K\" >/dev/null 2>&1 && npx convex env list 2>/dev/null | grep -q '^FEEDBACK_AGENT_KEY='; then key_healed=1 echo \"FEEDBACK_AGENT_KEY_HEALED at \$(date -u +%H:%M:%SZ) — the feedback agent key is now set; IGNORE any earlier 'FEEDBACK_AGENT_KEY not set' errors above this line. refinementQuestions:listOpen works now, so resume polling the refinement queue.\" >> '$CONVEX_ERR_LOG' fi fi fi else # Require N consecutive misses (~15s) before declaring death — a single # pgrep miss during an internal convex-dev respawn is NOT death (that was # the false-positive that misled agents). fails=\$((fails+1)) if [ \"\$fails\" -ge 3 ] && [ \"\$warned\" = 0 ]; then echo \"CONVEX_DEV_DEAD at \$(date -u +%H:%M:%SZ) — 'convex dev' has been unreachable for ~15s (process gone AND local port closed). Backend code is NO LONGER being pushed and convex/_generated is STALE; cloud 'npx convex run' will keep hitting OLD functions and mislead you. Restart it: (cd '$WATCH_DIR' && npx convex dev >> '$CONVEX_LOG' 2>&1 &)\" >> '$CONVEX_ERR_LOG' warned=1 fi fi sleep 5 done " # ----- next dev liveness: catch an IPv6-only / wedged dev server ----- # The next error-mirror only catches LOGGED errors; a dev server that binds # IPv6-only (or wedges mid-compile) serves NOTHING on 127.0.0.1 yet logs no # error, so the agent waits forever. Probe the IPv4 loopback explicitly: ANY # HTTP response code = serving; only 000 (refused/timeout) = wedged. if [[ -n "${NEXT_PORT:-}" ]]; then daemonize "$LOG_DIR/next-dev-health.log" bash -c " sleep 25 # let next compile + bind first nwarn=0 nfails=0 while true; do code=\$(curl -sS -o /dev/null --max-time 5 -w '%{http_code}' 'http://127.0.0.1:$NEXT_PORT/' 2>/dev/null || echo 000) if [ \"\$code\" != \"000\" ]; then nfails=0 if [ \"\$nwarn\" = 1 ]; then echo \"NEXT_DEV_RECOVERED at \$(date -u +%H:%M:%SZ) — Next is serving on http://127.0.0.1:$NEXT_PORT again; IGNORE the NEXT_DEV_WEDGED line above.\" >> '$NEXT_ERR_LOG'; nwarn=0; fi else nfails=\$((nfails+1)) if [ \"\$nfails\" -ge 3 ] && [ \"\$nwarn\" = 0 ]; then echo \"NEXT_DEV_WEDGED at \$(date -u +%H:%M:%SZ) — Next dev is NOT serving on http://127.0.0.1:$NEXT_PORT after ~15s (likely an IPv6-only bind or a wedged compile). Restart it bound to IPv4: (cd '$WATCH_DIR' && HOST=127.0.0.1 PORT=$NEXT_PORT npm run dev >> '$NEXT_LOG' 2>&1 &)\" >> '$NEXT_ERR_LOG' nwarn=1 fi fi sleep 5 done " fi # --------------------------- 7.5. (removed) auto-deploy on login # Previously kicked off `npm run deploy` in the background when the user # was already logged in. Removed alongside the static-hosting install # (see step 3.5). Publishing is now an opt-in STEP C the agent runs only # when the user asks for it. # ------------------------------------------------------ 7.9 domain brainstorm # Brainstorm custom-domain candidates in the BACKGROUND while the agent builds, # so a ready-to-pick list (available names + prices) is waiting at publish time. # Anteater's /domain/brainstorm: Haiku names → live DNSimple availability/price. # Writes .quickstart-domains.json in the project dir; the runbook's publish step # reads it and offers the user a domain. Best-effort + non-blocking. # Domain feature is OFF by default ("ship later" goodness) — set QB_DOMAIN=1 to # bring back the background brainstorm + the publish-time domain offer. QB_BRAINSTORM_BASE="${QB_BRAINSTORM_BASE:-${QB_FEEDBACK_URL%/feedback}}" if feature_on DOMAIN && [[ -n "${IDEA:-}" && -n "$QB_BRAINSTORM_BASE" ]]; then # 60s was too tight — Haiku naming + live DNSimple availability/price checks can # run longer, leaving NO file (no domain offer). Give it 150s and write only a # complete, valid JSON ({available:[…]}); a partial/empty body leaves no file. ( BS=$(curl -fsS --max-time 150 -X POST "$QB_BRAINSTORM_BASE/domain/brainstorm" \ -H 'content-type: application/json' \ --data "$(node -e 'process.stdout.write(JSON.stringify({idea:process.argv[1]}))' "$IDEA")" 2>/dev/null) \ && printf '%s' "$BS" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);if(Array.isArray(j.available)){process.stdout.write(s);process.exit(0)}}catch{};process.exit(1)})' > .quickstart-domains.json \ || rm -f .quickstart-domains.json ) & step "Brainstorming custom-domain options in the background → .quickstart-domains.json" fi # ----------------------------------------------------- 7.95 prefetch publish deps # STEP C (publish to *.convex.app) runs AFTER the build, often inside an agent # sandbox where mid-build network egress (npm install / curl) is blocked unless # the agent passes a sandbox-escape flag. Pre-stage the two network-bound pieces # NOW, while the bootstrap already has network: install fflate (the only runtime # dep of the publish script) and fetch publish-convex-app.mjs into the project. # Then STEP C is a pure-offline `node publish-convex-app.mjs` — no sandbox flag. # Gated on QB_PUBLISH: the publish gateway is down this release, so the minimal # profile skips staging the publisher entirely (no STEP C either — see the report). if feature_on PUBLISH; then PUBLISH_SRC="https://basic-anteater-667.convex.site/publish-convex-app" if curl -fsSL --max-time 30 "$PUBLISH_SRC" -o publish-convex-app.mjs 2>/dev/null; then # Install fflate with the SAME package manager the scaffold used — `npm install` # into a pnpm tree (no package-lock) is the exact path that failed at publish. if [[ "$PKG_MGR" == "pnpm" ]]; then FFLATE_INSTALL=(pnpm add -D fflate); else FFLATE_INSTALL=(npm install -D fflate); fi if "${FFLATE_INSTALL[@]}" >/dev/null 2>&1; then step "Pre-staged publish deps (fflate + publish-convex-app.mjs via $PKG_MGR) — STEP C is now offline" else step "Fetched publish-convex-app.mjs (fflate install deferred to STEP C)" fi fi fi # ------------------------------------------------------------ 8. final report PROJECT_DIR="$(pwd)" # Fetch the FULL personalized runbook to a LOCAL file so the agent reads it off # disk instead of WebFetching a URL — WebFetch has repeatedly hallucinated a # "refusal" and silently dropped the agent back to defaults. Print parse-stable # RUNBOOK_URL / RUNBOOK_OK markers either way. RUNBOOK_FILE="$PROJECT_DIR/.quickstart-runbook.md" if [[ -n "${BESPOKE_ID:-}" ]]; then RUNBOOK_URL="$QB_ARGS_BASE/q/$BESPOKE_ID/full.md" else RUNBOOK_URL="$QB_ARGS_BASE/quickstart.md" fi if curl -fsSL --max-time 25 "$RUNBOOK_URL" -o "$RUNBOOK_FILE" 2>/dev/null && [[ -s "$RUNBOOK_FILE" ]]; then RUNBOOK_OK=1 else curl -fsSL --max-time 25 "$QB_ARGS_BASE/quickstart.md" -o "$RUNBOOK_FILE" 2>/dev/null || true [[ -s "$RUNBOOK_FILE" ]] && RUNBOOK_OK=1 || RUNBOOK_OK=0 fi # STEP D (custom domain) only appears in the runbook when the domain feature is # ON (QB_DOMAIN=1). De-scoped from the shipped build; the brainstorm above is # gated the same way, so with it off there's no .quickstart-domains.json anyway. if feature_on DOMAIN; then DOMAIN_STEP=$(cat <<'DSTEP' ═══ STEP D — offer a custom domain (after publishing) ═══ While the app was building, the scaffold brainstormed available custom-domain names for the idea (Haiku names + live availability/price) into `.quickstart-domains.json` in the project root. After you publish to `*.convex.app`, read that file and offer the user a real domain: cat .quickstart-domains.json # { idea, available: [{ domain, registrationPrice, renewalPrice }] } If `available` has entries, tell the user their app is live and ask if they want a real domain, listing the top few with prices, e.g.: "🎉 Live at https://.convex.app. Want a real domain? These are available: • quipp.app — $22/yr • rankd.io — $32/yr … — pick one or skip." On a clear yes, register + wire it (this also rebinds passkeys to the new origin, then re-publish): follow the `domain` skill / run the domain client with `register `. On skip, you're done. If the file is missing/empty (brainstorm failed or no names were available), just skip — don't block on it. DSTEP ) else DOMAIN_STEP="" fi cat < feature-requests.tail\` for monitoring. That was wrong: bash redirects to files are block-buffered (8KB+ before flush), so the mirror stalls at \`[]\` while the source log gets fresh JSON dumps. Watch \$FR_LOG directly. If $FR_LOG goes >5 minutes without a JSON dump despite the user clicking the request form, suspect: 1. The UI itself isn't connected (check $NEXT_LOG + browser console). 2. The watcher is stuck on ECONNREFUSED — \`grep ECONNREFUSED $FR_LOG\`. If hits, kill the bash wrapper PID and re-run the watcher block manually. 3. The Convex backend died — \`tail $CONVEX_LOG\` for a stack trace. 4. You're polling a stale mirror — \`ls -la $LOG_DIR\` to check that the file you're tail'ing is \$FR_LOG itself, not feature-requests.tail. Don't assume "no requests means user isn't typing" without checking these. ═══ STEP B — build features as the user requests them ═══ **Ask at least ONE refinement question** before declaring v1 done — even when the prompt looks complete. The user always has an opinion on audience, tone, or visual style that the prompt doesn't pin down, and asking widens the conversation. Use \`refinementQuestions:ask\` (it renders inline in the Chef bubble, no chat needed): npx convex run refinementQuestions:ask \\ '{"text":"Should this lean playful with bold colors, or restrained and elegant? I can pick either way but the design will be different."}' Then keep building. Watch \$LOG_DIR for the answer to land (the agent's widget mutation flips state to "answered"); when it does, factor the answer into the next change. Skipping this step is the most common failure mode for "shipped but felt rote" runs. **Cleaning up after route removal:** if you delete a route under app/ (e.g., \`rm -rf app/foo\`), also \`rm -rf .next/types\` immediately after. Next's typegen leaves stale validators referencing the deleted route, and \`tsc --noEmit\` fails on the next push otherwise. **Build visible-first, backend-in-parallel. THIS IS THE BIGGEST PERCEIVED-LATENCY RULE.** Users wait minutes for backend plumbing and see nothing on screen. They lose patience and the build feels rote. Instead: 1. **Visual layout FIRST** with hardcoded sample data in the component. \`app/page.tsx\` should render the FULL UI of the feature in your first commit — even if cards/columns/rows are baked-in constants. The user sees real pixels in ~30s. 2. **Backend in parallel or right after.** Schema + mutations + the query that replaces the hardcoded data come next, while the user is already looking at something. Swap the constants for \`useQuery(...)\` results once the query exists. 3. **Interactivity (drag-and-drop, animations, etc.) AFTER the data is live**, because those depend on real IDs. Pre-flighting backend before the user sees a screen is a smell. If your todo list starts with "schema" before "static UI", reorder it. EOF # ── Build-loop narration: Chef panel (chef) vs chat, driven by QB_NARRATE. # When the panel is off we never tell the agent to post to chef — it narrates # in chat instead. The chef block below is unchanged; only its gating moved. if [[ "$QB_NARRATE" == "chef" ]]; then cat </dev/null \\ | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).filter(r => r.state === 'open').length") [[ "\$OPEN" == "0" ]] && break sleep 3 done # Now read the answer and act on it. npx convex run refinementQuestions:listOpen **After you declare v1 shipped, DO NOT yield the turn.** Same reason — no push, so you must poll. Tail \$FR_LOG and \$RQ_LOG for at least 60 seconds before stopping. Users typically click "Request a feature" or answer a pending question within the first minute; if you've already yielded, it sits unprocessed. If 60s elapses with no activity, post a \`note\` ("idle — ask in the bubble when you're ready to refine") and then yield. **Pre-yield checklist — verify ALL of these before your final message:** 1. \`npx convex run todos:listAll\` shows every item as \`done\`. If any are \`pending\` or \`active\`, call \`todos:advance '{}'\` to walk through them. **Use \`advance\` — it needs NO id, it just completes the current active item and activates the next.** Avoid \`todos:setStatus\`, which takes a \`todoItems\` id and is easy to call with the wrong id (e.g. a \`progress:post\` id — that throws ArgumentValidationError). The user reads the checklist as their mental model of "is the agent finished?" — leaving an item active means "agent quit halfway." 2. Your most recent \`progress:post\` was \`kind:"shipped"\` with a v1-summary message. If your last post was \`step\`, the user thinks you're still mid-step. 3. \`app/layout.tsx\` has a \`metadata.title\` that names YOUR app (e.g., "Backgammon Spunk"), not the scaffold default ("My Convex App" / "Convex Quickstart App"). Browser tabs default to whatever's there. 4. \`refinementQuestions:listOpen\` shows zero open rows — every question is \`answered\` or \`skipped\`. If any are \`open\`, do not yield: enter the poll loop above and wait for the user. **Advance todos one at a time, immediately after the matching step finishes. NEVER chain advances.** Don't write \`npx convex run todos:advance && npx convex run todos:advance && …\` — the live checklist will jump from 1/5 straight to 5/5 in one tick and the user sees a flicker instead of motion. One real run did exactly this (5-in-1 chain) and the user noticed. Call advance once per finished step, paired with the matching \`progress:post\` for that step. **ABSOLUTE RULE: never remove the \`\` line from app/layout.tsx, and never delete app/_chef-panel.tsx.** ChefPanel is the user's only channel into this build loop — progress feed + clarifying questions + feature-request form, in one floating bubble (lower-right). It's mounted in the LAYOUT (not the page) on purpose, so any redesign of app/page.tsx leaves it intact. If you find yourself unmounting it, deleting the file, "moving it to a tab," or "hiding it behind a button" — stop, undo. Non-negotiable. **Edit order: write the new component file FIRST, then update app/page.tsx to reference it.** If you write the JSX reference first (\`\`) and then the component file in a second write, HMR recompiles between the two writes, and the user sees a runtime ReferenceError ("MyNewPanel is not defined") on the live page for a few seconds. app/error.tsx softens this with a "Chef is still cooking" fallback, but the cleanest fix is the order: write the file, save, THEN modify page.tsx in the same turn whenever possible. 1. Watch $FR_LOG. Each fresh JSON dump = the latest set of "requested" rows. Diff against the previous dump to find new work. 2. For each new request {_id, title, description}: a. Mark inProgress (the user's UI badge updates instantly): npx convex run featureRequests:setState \\ '{"id":"","state":"inProgress"}' b. Build the feature in $PROJECT_DIR following the rules at https://docs.convex.dev/quickstart.md — withIndex, returns validators, object-form functions, no .filter() for indexable lookups. c. Replace the matching spinner Placeholder in app/page.tsx with the real component you just built. **Do NOT touch app/_chef-panel.tsx or the mount in app/layout.tsx while doing this** — both stay exactly where the bootstrap put them. d. Mark completed: npx convex run featureRequests:setState \\ '{"id":"","state":"completed"}' 3. If two requests arrive close together, do them in order. The badge transitions are the user's feedback loop — don't batch. 4. If a request is ambiguous, ship a reasonable interpretation and post a clarifying question (see below) — do not stall waiting. ═══ HARD RULE: \`npx convex run\` is BANNED for verification ═══ **Do not** call \`npx convex run\` from your bash tool to check whether a feature works. This is non-negotiable, not a hint. The May-11 batch audit found agents averaging **15 \`convex run\` invocations per build** (one outlier hit 42), each paying ~2-10s of CLI cold start + push-settle wait. That is the single biggest preventable waste in a quickstart run. How to verify features instead: 1. **Read your watchers.** \`convex dev\` is already running with \`--tail-logs\`; \`next dev\` is already streaming. Tail the log files the bootstrap script created (paths in the report block above). If the function compiled and pushed cleanly, the next \`useQuery\` / \`useMutation\` from the running browser hits real data. 2. **Open the running app.** \`$APP_URL\` is live. curl it (\`curl -s "$APP_URL" | head -50\`) or check the user's browser. If the page renders without errors and the data looks right, the feature works. 3. **Add a one-shot seed mutation if you need fixtures.** When you genuinely need to insert test data, write ONE internalMutation called \`seedTestData\` that creates everything in a single transaction, then call it ONCE: npx convex run seed:seedTestData That's the sanctioned exception. ONE call. Not five. 4. **For runtime probes, write an httpAction.** A small \`httpAction\` that calls your query/mutation and returns JSON can be \`curl\`'d without paying the convex-CLI cold start each time. If you find yourself reaching for \`npx convex run\` to check state, **stop and tail the logs instead**. The agent who does five separate \`getState\` reads with \`convex run\` will lose 30+ seconds of wall time that the user is staring at a still page for. ═══ Clarifying questions: ASK IN THE WIDGET, NOT IN CHAT ═══ Whenever you need to ask the user something — target audience, design direction, feature scope, "what color" — call \`refinementQuestions:ask\`. The wow-shell page renders open questions in a gold panel ABOVE the feature request list, with an inline answer textarea. The user types their answer there and clicks "Send answer"; the row's state flips to "answered" and the answer text lands in the \`answer\` field. npx convex run refinementQuestions:ask \\ '{"text":"Who is this app primarily for? Touring magicians who book paid gigs, hobbyist magicians, or magic-club organizers?"}' Watch the parallel watcher subscription for $RQ_LOG (the script also started \`npx convex run --watch refinementQuestions:listOpen\` for you). When you see a row whose state changed to "answered", read its \`answer\` and act on it. There's nothing for you to mark — the user's submit already moved the state. DO NOT also ask the question in chat. The widget is the single source of truth for the refinement dialogue — duplicates create friction. Ask only ONE question at a time per topic — don't drown the panel in five simultaneous questions. Wait for the answer (or 30s of silence) before asking the next. If you want to retire a question that's no longer relevant (e.g., the user answered something in chat that obviates it), call: npx convex run refinementQuestions:skip \\ '{"id":""}' EOF else cat <\` reference first makes HMR flash a "NewThing is not defined" runtime error for a few seconds. EOF fi if feature_on PUBLISH; then cat <.convex.app through the Convex hosting gateway: # 1. Be on a CLOUD deployment and logged in (anonymous/local can't host). npx convex login # if not already logged in # 2. Set Next.js to static-export mode in next.config.ts: # output: "export", # images: { unoptimized: true }, # 3. Publish (re-run after any change): node publish-convex-app.mjs # The bootstrap already pre-staged publish-convex-app.mjs + fflate, so this # is a pure-offline build+upload — no sandbox-escape flag needed. If either # is somehow missing (older scaffold), fetch them first; those two commands # DO hit the network, so inside a sandboxed agent run them with the # dangerouslyDisableSandbox flag: # curl -fsSL https://basic-anteater-667.convex.site/publish-convex-app -o publish-convex-app.mjs # pnpm add -D fflate # (or: npm install -D fflate — match this project's package manager) The script builds the app, zips the static export, and uploads it through the gateway — which runs an automated content-safety check before serving. On success it prints the public URL \`https://.convex.app\` — pass it back to the user. If the upload is rejected for content, it prints the reasons; don't retry blindly. Optional but nice — live "a new version is available" banner. Add it ONCE so open tabs offer a reload the instant you re-publish: curl -fsSL https://basic-anteater-667.convex.site/hosting/app-version.ts -o convex/appVersion.ts curl -fsSL https://basic-anteater-667.convex.site/hosting/new-version-banner.tsx -o components/NewVersionBanner.tsx Then add the \`appVersion\` table to convex/schema.ts: appVersion: defineTable({ version: v.number(), deploymentName: v.string(), deployedAt: v.number() }), and mount inside your ConvexProvider. After that, every \`node publish-convex-app.mjs\` bumps the version (the script already calls appVersion:recordDeploy) and open tabs show the reload prompt. DO NOT run this proactively — wait for the explicit ask. Publishing needs a cloud deployment; if the project is on an anonymous/local deployment, the user must first \`npx convex dev\` into a cloud project. EOF else cat <