+
+
+ Max rows
+
+
+ setMaxRowCountInput(e.currentTarget.value)}
+ onBlur={() => {
+ if (!maxRowCountInput.trim()) return;
+ const value = Number(maxRowCountInput);
+ if (Number.isInteger(value) && value >= 1) {
+ setMaxRowCountInput(String(value));
+ }
+ }}
+ className="min-w-0 flex-1 rounded-lg border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none transition-colors focus:border-foreground/30 disabled:opacity-50"
+ />
+
+
+
+ {maxRowCountValidationError ??
+ maxRowCountSaveError ??
+ (maxRowCountRemaining !== undefined
+ ? `${maxRowCountRemaining.toLocaleString()} row operations available`
+ : "Applies to the next populate run")}
+
+
Refresh cadence
diff --git a/frontend/app/dataset/new/page.tsx b/frontend/app/dataset/new/page.tsx
index 285cbde..bb4dded 100644
--- a/frontend/app/dataset/new/page.tsx
+++ b/frontend/app/dataset/new/page.tsx
@@ -4,7 +4,7 @@ import { useEffect, useState, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useAuth } from "@clerk/nextjs";
-import { useMutation, useConvexAuth } from "convex/react";
+import { useMutation, useQuery, useConvexAuth } from "convex/react";
import { api } from "@/convex/_generated/api";
import { EVENTS, track } from "@/lib/analytics";
import { inferSchema, type InferredColumn } from "@/lib/backend";
@@ -43,6 +43,8 @@ const BACKEND_TYPE_MAP: Record
= {
boolean: "boolean",
};
+const DEFAULT_MAX_ROW_COUNT = 100;
+
function mapBackendColumn(col: InferredColumn, index: number): ProposedColumn {
return {
id: String(index + 1),
@@ -81,6 +83,9 @@ export default function NewDatasetPage() {
const [step, setStep] = useState("describe");
const [prompt, setPrompt] = useState("");
const [refreshCadence, setRefreshCadence] = useState("daily");
+ const [maxRowCountInput, setMaxRowCountInput] = useState(
+ String(DEFAULT_MAX_ROW_COUNT),
+ );
const [columns, setColumns] = useState([]);
const [datasetName, setDatasetName] = useState("");
const [isCreating, setIsCreating] = useState(false);
@@ -92,6 +97,10 @@ export default function NewDatasetPage() {
const { getToken } = useAuth();
const createDataset = useMutation(api.datasets.create);
+ const usage = useQuery(
+ api.quota.getMy,
+ isAuthenticated ? {} : "skip",
+ );
// Page-view event: fires once when the wizard becomes visible (after
// auth resolves and the user is authenticated; we don't want to fire
@@ -163,6 +172,17 @@ export default function NewDatasetPage() {
async function handleConfirm() {
if (isCreating) return;
+ const maxRowCount = Number(maxRowCountInput);
+ if (!Number.isInteger(maxRowCount) || maxRowCount < 1) {
+ setError("Max rows must be a whole number greater than 0.");
+ return;
+ }
+ if (usage && maxRowCount > usage.remaining) {
+ setError(
+ `Max rows cannot exceed your remaining monthly quota of ${usage.remaining.toLocaleString()} row operations.`,
+ );
+ return;
+ }
setIsCreating(true);
setError(null);
let datasetId: string;
@@ -171,6 +191,7 @@ export default function NewDatasetPage() {
name: datasetName,
description: prompt,
refreshCadence,
+ maxRowCount,
columns: columns.map((c) => ({
name: c.name,
type: c.type,
@@ -195,6 +216,7 @@ export default function NewDatasetPage() {
datasetId,
column_count: columns.length,
refreshCadence,
+ maxRowCount,
});
} catch {}
router.push(`/dataset/${datasetId}`);
@@ -320,6 +342,34 @@ export default function NewDatasetPage() {
))}
+
+
+
+
setMaxRowCountInput(e.currentTarget.value)}
+ onBlur={() => {
+ if (!maxRowCountInput.trim()) return;
+ const value = Number(maxRowCountInput);
+ if (Number.isInteger(value) && value >= 1) {
+ setMaxRowCountInput(String(value));
+ }
+ }}
+ className="w-36 rounded-lg border border-border bg-surface px-4 py-2.5 text-sm font-medium outline-none focus:border-foreground/30 transition-colors"
+ />
+ {usage && (
+
+ Up to {usage.remaining.toLocaleString()} row operations available this month.
+
+ )}
+
diff --git a/frontend/components/SideSheet.tsx b/frontend/components/SideSheet.tsx
new file mode 100644
index 0000000..215d477
--- /dev/null
+++ b/frontend/components/SideSheet.tsx
@@ -0,0 +1,228 @@
+"use client";
+
+import { useEffect, useRef, useState, useCallback } from "react";
+import type { DatasetColumn } from "@/components/table/types";
+
+function IconX() {
+ return (
+
+ );
+}
+function IconCopy() {
+ return (
+
+ );
+}
+function IconCheck() {
+ return (
+
+ );
+}
+function IconExternalLink() {
+ return (
+
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* Shell */
+/* ------------------------------------------------------------------ */
+
+interface SideSheetProps {
+ open: boolean;
+ onClose: () => void;
+ children: React.ReactNode;
+}
+
+export function SideSheet({ open, onClose, children }: SideSheetProps) {
+ const panelRef = useRef
(null);
+
+ // Lock body scroll while open.
+ useEffect(() => {
+ if (!open) return;
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => { document.body.style.overflow = prev; };
+ }, [open]);
+
+ // Keyboard: Escape closes, Tab stays trapped inside.
+ useEffect(() => {
+ if (!open || !panelRef.current) return;
+ const panel = panelRef.current;
+
+ const focusable = panel.querySelectorAll(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
+ );
+ focusable[0]?.focus();
+
+ function onKey(e: KeyboardEvent) {
+ if (e.key === "Escape") { onClose(); return; }
+ if (e.key !== "Tab" || focusable.length === 0) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (e.shiftKey && document.activeElement === first) {
+ e.preventDefault(); last.focus();
+ } else if (!e.shiftKey && document.activeElement === last) {
+ e.preventDefault(); first.focus();
+ }
+ }
+
+ panel.addEventListener("keydown", onKey);
+ return () => panel.removeEventListener("keydown", onKey);
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ return (
+
+ {/* Backdrop */}
+
+ {/* Modal */}
+
+
+
Cell Detail
+
+
+
{children}
+
+
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* Content */
+/* ------------------------------------------------------------------ */
+
+interface CellDetailProps {
+ column: DatasetColumn;
+ value: unknown;
+ /** Row-level sources stored by the populate agent. */
+ sources?: string[];
+}
+
+function isValidHttpUrl(src: string): boolean {
+ try {
+ const { protocol } = new URL(src);
+ return protocol === "http:" || protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+export function CellDetail({ column, value, sources }: CellDetailProps) {
+ const [copied, setCopied] = useState(false);
+ const copyTimerRef = useRef | null>(null);
+ const displayValue = value == null || value === "" ? "β" : String(value);
+
+ // Clear any pending timer when the component unmounts to avoid calling
+ // setCopied on an already-gone component.
+ useEffect(() => {
+ return () => {
+ if (copyTimerRef.current != null) clearTimeout(copyTimerRef.current);
+ };
+ }, []);
+
+ const handleCopy = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(value == null ? "" : String(value));
+ setCopied(true);
+ if (copyTimerRef.current != null) clearTimeout(copyTimerRef.current);
+ copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
+ } catch {
+ // Clipboard API unavailable (e.g. non-HTTPS dev); silently ignore.
+ }
+ }, [value]);
+
+ return (
+
+ {/* Column name + description */}
+
+
{column.name}
+ {column.description && (
+
{column.description}
+ )}
+
+
+ {/* Value */}
+
+
+
+ Value ({column.type})
+
+
+
+
+
+
+ {/* Sources */}
+ {sources && sources.length > 0 && (
+
+
+ Sources
+
+
+ {sources.map((src, i) => (
+ -
+ {isValidHttpUrl(src) ? (
+
+
+ {src}
+
+ ) : (
+
+
+ {src}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/components/ThemeToggle.tsx b/frontend/components/ThemeToggle.tsx
index 346a509..c185991 100644
--- a/frontend/components/ThemeToggle.tsx
+++ b/frontend/components/ThemeToggle.tsx
@@ -32,11 +32,19 @@ function applyTheme(theme: Theme): void {
function subscribeToThemeChange(onThemeChange: () => void): () => void {
if (typeof window === "undefined") return () => {};
- window.addEventListener("storage", onThemeChange);
- window.addEventListener(THEME_CHANGED_EVENT, onThemeChange);
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+ function syncTheme() {
+ applyTheme(readEffectiveTheme());
+ onThemeChange();
+ }
+
+ window.addEventListener("storage", syncTheme);
+ window.addEventListener(THEME_CHANGED_EVENT, syncTheme);
+ mediaQuery.addEventListener("change", syncTheme);
return () => {
- window.removeEventListener("storage", onThemeChange);
- window.removeEventListener(THEME_CHANGED_EVENT, onThemeChange);
+ window.removeEventListener("storage", syncTheme);
+ window.removeEventListener(THEME_CHANGED_EVENT, syncTheme);
+ mediaQuery.removeEventListener("change", syncTheme);
};
}
diff --git a/frontend/components/table/DataRow.tsx b/frontend/components/table/DataRow.tsx
index 1fe0974..2d54661 100644
--- a/frontend/components/table/DataRow.tsx
+++ b/frontend/components/table/DataRow.tsx
@@ -7,12 +7,21 @@ import type { DatasetRow, DatasetColumn } from "./types";
import { CellValue } from "./CellValue";
import { floorWidth } from "./utils";
+function IconMaximize2() {
+ return (
+
+ );
+}
+
export interface DataRowData {
rows: Row[];
columns: DatasetColumn[];
columnWidths: number[];
isSelected: (id: string) => boolean;
toggleRow: (id: string, shiftKey: boolean) => void;
+ onCellExpand: (columnName: string, value: unknown, rowId: string) => void;
isBuilding: boolean;
pendingRowIds: Set;
flashingCells: Set;
@@ -27,7 +36,7 @@ function DataRowImpl({
index: number;
style: CSSProperties;
}) {
- const { rows, columns, columnWidths, isSelected, toggleRow, isBuilding, pendingRowIds, flashingCells } = data;
+ const { rows, columns, columnWidths, isSelected, toggleRow, onCellExpand, isBuilding, pendingRowIds, flashingCells } = data;
const row = rows[index];
if (!row) {
@@ -112,7 +121,7 @@ function DataRowImpl({
+
{isPending &&
}
);
diff --git a/frontend/components/table/DatasetTable.tsx b/frontend/components/table/DatasetTable.tsx
index f921875..e74c2bb 100644
--- a/frontend/components/table/DatasetTable.tsx
+++ b/frontend/components/table/DatasetTable.tsx
@@ -85,11 +85,13 @@ export function DatasetTable({
rows,
datasetId,
selection,
+ onCellExpand,
}: {
dataset: DatasetMeta;
rows: DatasetRow[];
datasetId: string;
selection: Selection;
+ onCellExpand: (columnName: string, value: unknown, rowId: string) => void;
}) {
const tableContainerRef = useRef(null);
const previousResizingColumnIdRef = useRef(false);
@@ -168,11 +170,12 @@ export function DatasetTable({
columnWidths,
isSelected: selection.has,
toggleRow,
+ onCellExpand,
isBuilding,
pendingRowIds,
flashingCells,
}),
- [tableRows, dataset.columns, columnWidths, selection.has, toggleRow, isBuilding, pendingRowIds, flashingCells],
+ [tableRows, dataset.columns, columnWidths, selection.has, toggleRow, onCellExpand, isBuilding, pendingRowIds, flashingCells],
);
return (
diff --git a/frontend/components/table/types.ts b/frontend/components/table/types.ts
index 5fc380c..6eff009 100644
--- a/frontend/components/table/types.ts
+++ b/frontend/components/table/types.ts
@@ -28,5 +28,6 @@ export interface DatasetRow {
_id: string;
_creationTime: number;
data: Record;
+ sources?: string[];
updateStatus?: "pending";
}
diff --git a/frontend/convex/datasetRows.ts b/frontend/convex/datasetRows.ts
index db609f7..eac39ea 100644
--- a/frontend/convex/datasetRows.ts
+++ b/frontend/convex/datasetRows.ts
@@ -5,7 +5,7 @@ import type { Id } from "./_generated/dataModel.js";
import { assertRowInDataset, loadReadableDataset } from "./lib/authz.js";
import { consumeQuotaForDataset } from "./lib/quota.js";
-const MAX_DATASET_ROWS = 100;
+const DEFAULT_MAX_DATASET_ROWS = 100;
/**
* Authoritative row count for a dataset. O(N), so use only on the slow
@@ -77,9 +77,10 @@ export const insert = internalMutation({
typeof dataset.rowCount === "number"
? dataset.rowCount
: await actualRowCount(ctx, args.datasetId);
- if (previousCount >= MAX_DATASET_ROWS) {
+ const maxRowCount = dataset.maxRowCount ?? DEFAULT_MAX_DATASET_ROWS;
+ if (previousCount >= maxRowCount) {
throw new Error(
- `Row limit reached: BigSet datasets are capped at ${MAX_DATASET_ROWS} rows. Stop inserting rows and finish the run.`,
+ `Row limit reached: this BigSet dataset is capped at ${maxRowCount} rows. Stop inserting rows and finish the run.`,
);
}
@@ -311,6 +312,34 @@ export const clearUpdateStatus = internalMutation({
},
});
+/**
+ * Bulk-clear all pending update statuses for a dataset.
+ *
+ * Called when a user stops an in-flight update workflow. Workers exit early
+ * via AbortError, so rows they never reached still have `updateStatus:
+ * "pending"`. This clears them so the UI doesn't show stale shimmer
+ * indicators after the run is marked live.
+ *
+ * Uses the `by_dataset_update_status` compound index so only the pending
+ * rows are scanned β the query never touches rows that have already been
+ * processed (updateStatus === undefined).
+ */
+export const clearAllPendingUpdateStatus = internalMutation({
+ args: { datasetId: v.id("datasets") },
+ handler: async (ctx, args) => {
+ const pendingRows = await ctx.db
+ .query("datasetRows")
+ .withIndex("by_dataset_update_status", (q) =>
+ q.eq("datasetId", args.datasetId).eq("updateStatus", "pending"),
+ )
+ .collect();
+ for (const row of pendingRows) {
+ await ctx.db.patch(row._id, { updateStatus: undefined });
+ }
+ return pendingRows.length;
+ },
+});
+
/**
* Admin-only row listing for a dataset. Used by the populate agent's
* `list_rows` tool to see what's already been inserted in the dataset
diff --git a/frontend/convex/datasets.ts b/frontend/convex/datasets.ts
index 4f34619..d295327 100644
--- a/frontend/convex/datasets.ts
+++ b/frontend/convex/datasets.ts
@@ -13,7 +13,7 @@ import {
loadReadableDataset,
requireIdentity,
} from "./lib/authz.js";
-import { requireQuotaRemaining } from "./lib/quota.js";
+import { FREE_TIER_MONTHLY_QUOTA, requireQuotaRemaining } from "./lib/quota.js";
import {
nextRefreshAtFor,
refreshCadenceValidator,
@@ -62,6 +62,19 @@ function refreshCadenceFromLegacyLabel(
}
const PREVIEW_ROW_COUNT = 5;
+const DEFAULT_MAX_ROW_COUNT = 100;
+
+function validateMaxRowCount(maxRowCount: number): void {
+ if (
+ !Number.isInteger(maxRowCount) ||
+ maxRowCount < 1 ||
+ maxRowCount > FREE_TIER_MONTHLY_QUOTA
+ ) {
+ throw new Error(
+ `Max row count must be a whole number between 1 and ${FREE_TIER_MONTHLY_QUOTA}.`,
+ );
+ }
+}
async function attachPreview(ctx: QueryCtx, dataset: Doc<"datasets">) {
// Mini-table preview: just the first N rows. `.take` keeps the
@@ -270,6 +283,7 @@ export const claimScheduledRefreshInternal = internalMutation({
description: dataset.description,
columns: dataset.columns,
ownerId: dataset.ownerId,
+ maxRowCount: dataset.maxRowCount ?? DEFAULT_MAX_ROW_COUNT,
},
};
},
@@ -360,6 +374,7 @@ export const create = mutation({
name: v.string(),
description: v.string(),
refreshCadence: refreshCadenceValidator,
+ maxRowCount: v.number(),
columns: v.array(columnValidator),
retrievalStrategy: v.optional(
v.union(
@@ -373,10 +388,11 @@ export const create = mutation({
handler: async (ctx, args) => {
const identity = await requireIdentity(ctx);
assertNotReservedOwner(identity.subject);
+ validateMaxRowCount(args.maxRowCount);
// Block dataset creation at full exhaustion β a dataset you can't
// populate is just clutter. Row generation later will re-check, so
// this is a UX safeguard, not the only line of defense.
- await requireQuotaRemaining(ctx, identity.subject, 1);
+ await requireQuotaRemaining(ctx, identity.subject, args.maxRowCount);
return await ctx.db.insert("datasets", {
...args,
@@ -408,6 +424,23 @@ export const updateRefreshSettings = mutation({
},
});
+export const updateMaxRowCount = mutation({
+ args: {
+ id: v.id("datasets"),
+ maxRowCount: v.number(),
+ },
+ handler: async (ctx, args) => {
+ const dataset = await loadOwnedDataset(ctx, args.id);
+ validateMaxRowCount(args.maxRowCount);
+ const currentRowCount = dataset.rowCount ?? 0;
+ const additionalRowsNeeded = Math.max(0, args.maxRowCount - currentRowCount);
+ await requireQuotaRemaining(ctx, dataset.ownerId, additionalRowsNeeded);
+ await ctx.db.patch(dataset._id, {
+ maxRowCount: args.maxRowCount,
+ });
+ },
+});
+
export const backfillRefreshSettings = internalMutation({
args: {
defaultCadence: v.optional(refreshCadenceValidator),
diff --git a/frontend/convex/schema.ts b/frontend/convex/schema.ts
index 7907d6c..4e7b5f4 100644
--- a/frontend/convex/schema.ts
+++ b/frontend/convex/schema.ts
@@ -50,6 +50,9 @@ export default defineSchema({
// with rows created before this field existed β write paths self-heal
// on first hit, and `datasets.backfillRowCounts` migrates all at once.
rowCount: v.optional(v.number()),
+ // User-selected target/limit for populate runs. Optional so existing
+ // datasets keep the legacy 100-row behavior until touched.
+ maxRowCount: v.optional(v.number()),
columns: v.array(
v.object({
name: v.string(),
@@ -86,7 +89,11 @@ export default defineSchema({
howFound: v.optional(v.string()),
updateStatus: v.optional(v.literal("pending")),
scrapeScript: v.optional(v.string()),
- }).index("by_dataset", ["datasetId"]),
+ })
+ .index("by_dataset", ["datasetId"])
+ // Compound index used by clearAllPendingUpdateStatus to scan only the rows
+ // that need clearing without a full-dataset read.
+ .index("by_dataset_update_status", ["datasetId", "updateStatus"]),
datasetHistory: defineTable({
datasetRowId: v.id("datasetRows"),
diff --git a/frontend/lib/analytics.ts b/frontend/lib/analytics.ts
index bb2cb4a..a2a2757 100644
--- a/frontend/lib/analytics.ts
+++ b/frontend/lib/analytics.ts
@@ -34,6 +34,7 @@ export const EVENTS = {
DATASET_EXPORTED: "dataset_exported",
DATASET_POPULATE_STARTED: "dataset_populate_started",
DATASET_UPDATE_STARTED: "dataset_update_started",
+ DATASET_STOP_REQUESTED: "dataset_stop_requested",
// Creation flow
DATASET_CREATION_STARTED: "dataset_creation_started",
diff --git a/frontend/lib/backend.ts b/frontend/lib/backend.ts
index 9406a18..f023a35 100644
--- a/frontend/lib/backend.ts
+++ b/frontend/lib/backend.ts
@@ -211,6 +211,7 @@ export async function populate(
datasetId: string,
datasetName: string,
description: string,
+ maxRowCount: number,
columns: PopulateColumn[],
token: string,
): Promise {
@@ -220,7 +221,7 @@ export async function populate(
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
- body: JSON.stringify({ datasetId, datasetName: datasetName, description, columns }),
+ body: JSON.stringify({ datasetId, datasetName, description, maxRowCount, columns }),
});
if (!res.ok) {
@@ -259,3 +260,25 @@ export async function update(
return res.json();
}
+
+export async function stopPopulation(
+ datasetId: string,
+ token: string,
+): Promise<{ success: boolean }> {
+ const res = await fetch(`${BACKEND_URL}/stop`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ datasetId }),
+ });
+
+ if (!res.ok) {
+ const body = await res.json().catch(() => null);
+ const message = body?.error || `Backend error (${res.status})`;
+ throw new Error(message);
+ }
+
+ return res.json();
+}
diff --git a/makefiles/Makefile b/makefiles/Makefile
index 81c3d6b..2eb758a 100644
--- a/makefiles/Makefile
+++ b/makefiles/Makefile
@@ -31,10 +31,11 @@ dev: validate-dev-env install-deps
validate-dev-env:
@test -f .env || { echo "Error: .env not found. Run: cp .env.example .env"; exit 1; }
- @missing=""; \
+ @env_value() { grep "^$$1=" .env | tail -n1 | cut -d= -f2- | tr -d '\r'; }; \
+ missing=""; \
check_env() { \
key="$$1"; placeholder="$$2"; \
- value="$$(grep "^$$key=" .env | cut -d= -f2-)"; \
+ value="$$(env_value "$$key")"; \
if [[ -z "$$value" || "$$value" == "$$placeholder" || "$$value" == *"..."* ]]; then \
missing="$$missing $$key"; \
fi; \
@@ -84,7 +85,8 @@ validate-dev-env:
fi
ensure-admin-key:
- @admin_key="$$(grep '^CONVEX_SELF_HOSTED_ADMIN_KEY=' .env | cut -d= -f2-)"; \
+ @env_value() { grep "^$$1=" .env | tail -n1 | cut -d= -f2- | tr -d '\r'; }; \
+ admin_key="$$(env_value CONVEX_SELF_HOSTED_ADMIN_KEY)"; \
needs_gen=false; \
if [[ -z "$$admin_key" ]]; then \
needs_gen=true; \
@@ -115,8 +117,9 @@ ensure-admin-key:
convex-env:
@test -f .env || { echo "Error: .env not found. Run: cp .env.example .env"; exit 1; }
- @issuer="$$(grep '^CLERK_JWT_ISSUER_DOMAIN=' .env | cut -d= -f2-)"; \
- admin_key="$$(grep '^CONVEX_SELF_HOSTED_ADMIN_KEY=' .env | cut -d= -f2-)"; \
+ @env_value() { grep "^$$1=" .env | tail -n1 | cut -d= -f2- | tr -d '\r'; }; \
+ issuer="$$(env_value CLERK_JWT_ISSUER_DOMAIN)"; \
+ admin_key="$$(env_value CONVEX_SELF_HOSTED_ADMIN_KEY)"; \
if [[ -z "$$issuer" || "$$issuer" == "https://your-app.clerk.accounts.dev" ]]; then \
echo "Error: CLERK_JWT_ISSUER_DOMAIN must be your Clerk issuer URL in root .env"; \
exit 1; \
@@ -132,7 +135,8 @@ convex-env:
convex-push:
@test -f .env || { echo "Error: .env not found. Run: cp .env.example .env"; exit 1; }
- @admin_key="$$(grep '^CONVEX_SELF_HOSTED_ADMIN_KEY=' .env | cut -d= -f2-)"; \
+ @env_value() { grep "^$$1=" .env | tail -n1 | cut -d= -f2- | tr -d '\r'; }; \
+ admin_key="$$(env_value CONVEX_SELF_HOSTED_ADMIN_KEY)"; \
if [[ -z "$$admin_key" ]]; then \
echo "Error: CONVEX_SELF_HOSTED_ADMIN_KEY is missing in root .env"; \
echo "Run make dev to generate it automatically."; \