From 49a0c1083f722f246040659e17091f1d9d3c0d42 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 25 Jun 2026 17:51:08 -0700 Subject: [PATCH] chore: address config agent review cleanup --- .../build-image.ts} | 4 +- .../link-project-to-github.ts | 2 +- .../{ => config-agent}/seed-config-test.ts | 2 +- .../spike-orchestrator-e2e.mts | 4 +- apps/backend/src/lib/config/index.tsx | 4 +- .../config/{repo-agent.tsx => repo-agent.ts} | 4 +- .../backend/src/oauth/ssrf-protection.test.ts | 47 +- apps/backend/src/oauth/ssrf-protection.ts | 23 +- .../project-onboarding-wizard.test.tsx | 2 +- .../project-onboarding-wizard.tsx | 2 +- .../app/(main)/(protected)/layout-client.tsx | 2 +- .../@modal/(.)apps/[appId]/page-client.tsx | 2 +- .../analytics/queries/page-client.tsx | 2 +- .../analytics/tables/ai-query-dialog.tsx | 2 +- .../[projectId]/api-keys-app/page-client.tsx | 2 +- .../[projectId]/apps/[appId]/page-client.tsx | 2 +- .../[projectId]/auth-methods/page-client.tsx | 2 +- .../dashboards/[dashboardId]/page-client.tsx | 2 +- .../[projectId]/dashboards/page-client.tsx | 2 +- .../stores/[storeId]/page-client.tsx | 2 +- .../data-vault/stores/page-client.tsx | 2 +- .../[projectId]/domains/page-client.tsx | 2 +- .../email-settings/domain-settings.tsx | 2 +- .../[projectId]/email-themes/page-client.tsx | 2 +- .../[projectId]/emails/page-client.tsx | 2 +- .../projects/[projectId]/layout.tsx | 2 +- .../[projectId]/onboarding/page-client.tsx | 2 +- .../payments/customers/page-client.tsx | 2 +- .../projects/[projectId]/payments/layout.tsx | 2 +- .../products/[productId]/edit/page-client.tsx | 2 +- .../products/[productId]/page-client.tsx | 2 +- .../payments/products/new/page-client.tsx | 2 +- .../products/page-client-list-view.tsx | 2 +- .../page-client-product-lines-view.tsx | 2 +- .../payments/settings/page-client.tsx | 2 +- .../payments/settings/test-mode-toggle.tsx | 2 +- .../projects/[projectId]/sidebar-layout.tsx | 2 +- .../[projectId]/sign-up-rules/page-client.tsx | 2 +- .../[projectId]/team-settings/page-client.tsx | 2 +- apps/dashboard/src/components/app-square.tsx | 2 +- .../create-dashboard-preview.tsx | 2 +- .../src/components/commands/run-query.tsx | 2 +- .../config-agent-run-watcher.tsx} | 3 +- .../config-update/github-push-dialog.tsx | 584 ++++++++ .../src/components/config-update/index.tsx | 296 ++++ .../config-update/progress-content.tsx | 187 +++ .../remote-development-environment.ts | 33 + .../src/components/config-update/shared.tsx | 74 + .../data-table/payment-product-table.tsx | 2 +- .../components/email-verification-setting.tsx | 2 +- .../components/payments/product-dialog.tsx | 2 +- apps/dashboard/src/lib/config-update.tsx | 1325 ----------------- packages/shared/src/config-eval.ts | 11 + packages/shared/src/schema-fields.ts | 39 +- 54 files changed, 1335 insertions(+), 1381 deletions(-) rename apps/backend/scripts/{build-config-agent-image.ts => config-agent/build-image.ts} (89%) rename apps/backend/scripts/{ => config-agent}/link-project-to-github.ts (97%) rename apps/backend/scripts/{ => config-agent}/seed-config-test.ts (99%) rename apps/backend/scripts/{ => config-agent}/spike-orchestrator-e2e.mts (96%) rename apps/backend/src/lib/config/{repo-agent.tsx => repo-agent.ts} (99%) rename apps/dashboard/src/{lib/config-agent-run.tsx => components/config-update/config-agent-run-watcher.tsx} (96%) create mode 100644 apps/dashboard/src/components/config-update/github-push-dialog.tsx create mode 100644 apps/dashboard/src/components/config-update/index.tsx create mode 100644 apps/dashboard/src/components/config-update/progress-content.tsx create mode 100644 apps/dashboard/src/components/config-update/remote-development-environment.ts create mode 100644 apps/dashboard/src/components/config-update/shared.tsx delete mode 100644 apps/dashboard/src/lib/config-update.tsx diff --git a/apps/backend/scripts/build-config-agent-image.ts b/apps/backend/scripts/config-agent/build-image.ts similarity index 89% rename from apps/backend/scripts/build-config-agent-image.ts rename to apps/backend/scripts/config-agent/build-image.ts index 3de578e1c..395fb190e 100644 --- a/apps/backend/scripts/build-config-agent-image.ts +++ b/apps/backend/scripts/config-agent/build-image.ts @@ -5,7 +5,7 @@ * one). Every dashboard config write warm-boots from it and then clones the repo * fresh, so you only pay the SDK install once — here. * - * cd apps/backend && pnpm run with-env:dev tsx scripts/build-config-agent-image.ts + * cd apps/backend && pnpm run with-env:dev tsx scripts/config-agent/build-image.ts * * Then set the printed id so config writes warm-boot from it (either spelling works; * the env shim maps STACK_* <-> HEXCLAVE_*): @@ -13,7 +13,7 @@ * HEXCLAVE_CONFIG_AGENT_BASE_SNAPSHOT_ID= * * Needs HEXCLAVE_VERCEL_SANDBOX_TOKEN (+ team/project ids) configured. Re-run this - * whenever AGENT_SDK_VERSION in repo-agent.tsx changes; old base snapshots can be + * whenever AGENT_SDK_VERSION in repo-agent.ts changes; old base snapshots can be * deleted from the Vercel dashboard. */ import { buildConfigAgentBaseSnapshot } from "@/lib/config/repo-agent"; diff --git a/apps/backend/scripts/link-project-to-github.ts b/apps/backend/scripts/config-agent/link-project-to-github.ts similarity index 97% rename from apps/backend/scripts/link-project-to-github.ts rename to apps/backend/scripts/config-agent/link-project-to-github.ts index d1bff0a00..03aececaa 100644 --- a/apps/backend/scripts/link-project-to-github.ts +++ b/apps/backend/scripts/config-agent/link-project-to-github.ts @@ -5,7 +5,7 @@ * GitHub Actions runner can't POST to localhost, so the `config push` CI step * fails and the project never advances to "linked"). Run this to set the state. * - * cd apps/backend && LINK_PROJECT_ID= pnpm run with-env:dev tsx scripts/link-project-to-github.ts + * cd apps/backend && LINK_PROJECT_ID= pnpm run with-env:dev tsx scripts/config-agent/link-project-to-github.ts * * Env (defaults shown): * LINK_PROJECT_ID= (REQUIRED) diff --git a/apps/backend/scripts/seed-config-test.ts b/apps/backend/scripts/config-agent/seed-config-test.ts similarity index 99% rename from apps/backend/scripts/seed-config-test.ts rename to apps/backend/scripts/config-agent/seed-config-test.ts index aeb89654f..11ff93e9b 100644 --- a/apps/backend/scripts/seed-config-test.ts +++ b/apps/backend/scripts/config-agent/seed-config-test.ts @@ -16,7 +16,7 @@ * config directly. * * Run it (clickhouse + postgres must be up, i.e. the local stack running): - * pnpm --filter @hexclave/backend run with-env:dev tsx scripts/seed-config-test.ts + * pnpm --filter @hexclave/backend run with-env:dev tsx scripts/config-agent/seed-config-test.ts * * Override the target repo / config file via env (defaults shown): * STACK_CONFIG_TEST_OWNER=mantrakp04 diff --git a/apps/backend/scripts/spike-orchestrator-e2e.mts b/apps/backend/scripts/config-agent/spike-orchestrator-e2e.mts similarity index 96% rename from apps/backend/scripts/spike-orchestrator-e2e.mts rename to apps/backend/scripts/config-agent/spike-orchestrator-e2e.mts index b5f75ad26..42f611498 100644 --- a/apps/backend/scripts/spike-orchestrator-e2e.mts +++ b/apps/backend/scripts/config-agent/spike-orchestrator-e2e.mts @@ -12,7 +12,7 @@ * * Run from apps/backend: * pnpm dlx dotenv-cli -e .env.development.local -e .env.development -- \ - * pnpm tsx scripts/spike-orchestrator-e2e.mts + * pnpm tsx scripts/config-agent/spike-orchestrator-e2e.mts * * The GitHub token comes from $GITHUB_TOKEN, falling back to `gh auth token`. */ @@ -21,7 +21,7 @@ import { applyConfigUpdate, commitConfigUpdate, type GithubRepoRef, -} from "../src/lib/config/repo-agent"; +} from "../../src/lib/config/repo-agent"; if (!process.env.SPIKE_OWNER || !process.env.SPIKE_REPO || !process.env.SPIKE_BRANCH) { console.error("SPIKE_OWNER, SPIKE_REPO, and SPIKE_BRANCH must all be set explicitly.\nThis script pushes commits to a real repo — refusing to fall back to defaults."); diff --git a/apps/backend/src/lib/config/index.tsx b/apps/backend/src/lib/config/index.tsx index 306b29574..368330851 100644 --- a/apps/backend/src/lib/config/index.tsx +++ b/apps/backend/src/lib/config/index.tsx @@ -2,7 +2,7 @@ import { Prisma } from "@/generated/prisma/client"; import { Config, getInvalidConfigReason, normalize, override, removeKeysFromConfig } from "@hexclave/shared/dist/config/format"; import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, CompleteConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyBranchDefaults, applyEnvironmentDefaults, applyOrganizationDefaults, applyProjectDefaults, branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, getIncompleteConfigWarnings, migrateConfigOverride, organizationConfigSchema, projectConfigSchema, sanitizeBranchConfig, sanitizeEnvironmentConfig, sanitizeOrganizationConfig, sanitizeProjectConfig } from "@hexclave/shared/dist/config/schema"; import { ProjectsCrud } from "@hexclave/shared/dist/interface/crud/projects"; -import { branchConfigSourceSchema, yupBoolean, yupMixed, yupObject, yupRecord, yupString, yupUnion } from "@hexclave/shared/dist/schema-fields"; +import { branchConfigSourceSchema, type ConfigAgentSafeErrorMessage, yupBoolean, yupMixed, yupObject, yupRecord, yupString, yupUnion } from "@hexclave/shared/dist/schema-fields"; import { isTruthy } from "@hexclave/shared/dist/utils/booleans"; import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { HexclaveAssertionError, captureError } from "@hexclave/shared/dist/utils/errors"; @@ -720,7 +720,7 @@ export async function recordConfigAgentRunResult(options: { outcome: | { status: "success", commitUrl?: string, newCommitHash?: string } | { status: "no-change" } - | { status: "error", error: string }, + | { status: "error", error: ConfigAgentSafeErrorMessage }, }): Promise { await retryTransaction(globalPrismaClient, async (tx) => { const rows = await tx.$queryRaw<{ source: any }[]>` diff --git a/apps/backend/src/lib/config/repo-agent.tsx b/apps/backend/src/lib/config/repo-agent.ts similarity index 99% rename from apps/backend/src/lib/config/repo-agent.tsx rename to apps/backend/src/lib/config/repo-agent.ts index 63fa80628..a8aaff0b3 100644 --- a/apps/backend/src/lib/config/repo-agent.tsx +++ b/apps/backend/src/lib/config/repo-agent.ts @@ -11,7 +11,7 @@ * so the closest analog is a single SHARED, repo-independent base snapshot that * bakes in only the agent's OWN runtime (node + the Claude Agent SDK + git bot * identity) — never any repo or token. Build it once with - * `scripts/build-config-agent-image.ts` and point `STACK_CONFIG_AGENT_BASE_SNAPSHOT_ID` + * `scripts/config-agent/build-image.ts` and point `STACK_CONFIG_AGENT_BASE_SNAPSHOT_ID` * at the printed id; every config write warm-boots from it. If the env var is * unset (local/dev, or before the image is built) we cold-boot a node24 sandbox * and install the SDK inline — slower, but self-sufficient. @@ -399,7 +399,7 @@ async function bootAgentSandbox(creds: SandboxCreds): Promise { } // --------------------------------------------------------------------------- -// Base snapshot build (one-off, via scripts/build-config-agent-image.ts) +// Base snapshot build (one-off, via scripts/config-agent/build-image.ts) // --------------------------------------------------------------------------- /** diff --git a/apps/backend/src/oauth/ssrf-protection.test.ts b/apps/backend/src/oauth/ssrf-protection.test.ts index b429fb8a8..e376cdb95 100644 --- a/apps/backend/src/oauth/ssrf-protection.test.ts +++ b/apps/backend/src/oauth/ssrf-protection.test.ts @@ -1,6 +1,6 @@ import { StatusError } from "@hexclave/shared/dist/utils/errors"; -import { describe, expect, it } from "vitest"; -import { assertSafeOAuthResolvedAddress, assertSafeOAuthUrlWithoutDns, isBlockedOAuthIpAddress } from "./ssrf-protection"; +import { describe, expect, it, vi } from "vitest"; +import { assertSafeOAuthResolvedAddress, assertSafeOAuthUrlWithoutDns, isBlockedOAuthIpAddress, safeOAuthDnsLookup } from "./ssrf-protection"; describe("isBlockedOAuthIpAddress", () => { it("blocks AWS metadata, loopback, and private IPv4 ranges", () => { @@ -50,3 +50,46 @@ describe("assertSafeOAuthResolvedAddress", () => { }); }); +describe("safeOAuthDnsLookup", () => { + async function withProductionOAuthSsrfProtection(fn: () => Promise): Promise { + vi.stubEnv("NODE_ENV", "production"); + try { + return await fn(); + } finally { + vi.unstubAllEnvs(); + } + } + + it("passes blocked single-address DNS results to the callback as errors", async () => { + await withProductionOAuthSsrfProtection(async () => { + await new Promise((resolve, reject) => { + safeOAuthDnsLookup("127.0.0.1", { all: false }, (error, address, family) => { + try { + expect(error).toBeInstanceOf(StatusError); + expect(address).toBe(""); + expect(family).toBe(0); + resolve(); + } catch (assertionError) { + reject(assertionError); + } + }); + }); + }); + }); + + it("passes blocked multi-address DNS results to the callback as errors", async () => { + await withProductionOAuthSsrfProtection(async () => { + await new Promise((resolve, reject) => { + safeOAuthDnsLookup("127.0.0.1", { all: true }, (error, addresses) => { + try { + expect(error).toBeInstanceOf(StatusError); + expect(addresses).toEqual([]); + resolve(); + } catch (assertionError) { + reject(assertionError); + } + }); + }); + }); + }); +}); diff --git a/apps/backend/src/oauth/ssrf-protection.ts b/apps/backend/src/oauth/ssrf-protection.ts index 4e601abe3..7d6e1146b 100644 --- a/apps/backend/src/oauth/ssrf-protection.ts +++ b/apps/backend/src/oauth/ssrf-protection.ts @@ -108,9 +108,14 @@ export async function assertSafeOAuthUrl(urlString: string): Promise { } export function assertSafeOAuthResolvedAddress(address: string): void { - if (isBlockedOAuthIpAddress(address)) { - throw new StatusError(StatusError.BadRequest, OAUTH_SSRF_PROTECTION_ERROR); - } + const error = getUnsafeOAuthResolvedAddressError(address); + if (error != null) throw error; +} + +function getUnsafeOAuthResolvedAddressError(address: string): StatusError | null { + return isBlockedOAuthIpAddress(address) + ? new StatusError(StatusError.BadRequest, OAUTH_SSRF_PROTECTION_ERROR) + : null; } type DnsLookupCallback = ( @@ -134,7 +139,11 @@ export function safeOAuthDnsLookup(hostname: string, options: dns.LookupOptions, } for (const address of addresses) { - assertSafeOAuthResolvedAddress(address.address); + const unsafeAddressError = getUnsafeOAuthResolvedAddressError(address.address); + if (unsafeAddressError != null) { + callback(unsafeAddressError, []); + return; + } } callback(null, addresses); }); @@ -148,7 +157,11 @@ export function safeOAuthDnsLookup(hostname: string, options: dns.LookupOptions, return; } - assertSafeOAuthResolvedAddress(address); + const unsafeAddressError = getUnsafeOAuthResolvedAddressError(address); + if (unsafeAddressError != null) { + callback(unsafeAddressError, "", 0); + return; + } callback(null, address, family); }); } diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx index 6ebb38125..18b8be10e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx @@ -86,7 +86,7 @@ vi.mock("@/lib/env", () => ({ getPublicEnvVar: () => "false", })); -vi.mock("@/lib/config-update", () => ({ +vi.mock("@/components/config-update", () => ({ useUpdateConfig: () => mockUpdateConfig, })); diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx index e50b27414..4d6be5aa8 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx @@ -19,7 +19,7 @@ import { Typography, } from "@/components/ui"; import { getPublicEnvVar } from "@/lib/env"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { ArrowsClockwiseIcon, ChartBarIcon, diff --git a/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx b/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx index 45d6c815f..1120c68d2 100644 --- a/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx @@ -2,7 +2,7 @@ import Loading from "@/app/loading"; import { CursorBlastEffect } from "@hexclave/dashboard-ui-components"; -import { ConfigUpdateDialogProvider } from "@/lib/config-update"; +import { ConfigUpdateDialogProvider } from "@/components/config-update"; import { HexclaveRebrandModal } from "@/components/hexclave-rebrand-modal"; import { getPublicEnvVar } from '@/lib/env'; import { useStackApp, useUser } from "@hexclave/next"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/@modal/(.)apps/[appId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/@modal/(.)apps/[appId]/page-client.tsx index e381e7f81..bffba0ae8 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/@modal/(.)apps/[appId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/@modal/(.)apps/[appId]/page-client.tsx @@ -6,7 +6,7 @@ import { useRouter } from "@/components/router"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui"; import { ALL_APPS_FRONTEND, getAppPath } from "@/lib/apps-frontend"; import { isAppEnabled } from "@/lib/apps-utils"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { AppId, getParentAppId } from "@hexclave/shared/dist/apps/apps-config"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx index 220778d1e..a447d45a8 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx @@ -13,7 +13,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { SimpleTooltip } from "@/components/ui/simple-tooltip"; import { Textarea } from "@/components/ui/textarea"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { cn } from "@/lib/utils"; import { CaretDownIcon, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-dialog.tsx index bc2668c25..6273cc4ee 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-dialog.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-dialog.tsx @@ -18,7 +18,7 @@ import { Label } from "@/components/ui/label"; import { SimpleTooltip } from "@/components/ui/simple-tooltip"; import { Textarea } from "@/components/ui/textarea"; import { CreateDashboardPreview } from "@/components/commands/create-dashboard/create-dashboard-preview"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { AssistantRuntimeProvider, type ToolCallContentPartProps } from "@assistant-ui/react"; import { ArrowClockwiseIcon, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys-app/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys-app/page-client.tsx index fa1d5854d..30276a43f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys-app/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/api-keys-app/page-client.tsx @@ -7,7 +7,7 @@ import { type DesignEditableGridItem, } from "@/components/design-components"; import { Switch } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { GearSix, KeyIcon, UsersIcon } from "@phosphor-icons/react"; import { useMemo, useState } from "react"; import { AppEnabledGuard } from "../app-enabled-guard"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/[appId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/[appId]/page-client.tsx index 9f08249da..2e7ea9f01 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/[appId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/[appId]/page-client.tsx @@ -3,7 +3,7 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; import { AppStoreEntry } from "@/components/app-store-entry"; import { useRouter } from "@/components/router"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { ALL_APPS_FRONTEND, getAppPath, getDocumentationHref, type AppId } from "@/lib/apps-frontend"; import { isAppEnabled } from "@/lib/apps-utils"; import { getParentAppId } from "@hexclave/shared/dist/apps/apps-config"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx index c19338952..b899bd227 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx @@ -3,7 +3,7 @@ import { InlineSaveDiscard } from "@/components/inline-save-discard"; import { ActionDialog, BrandIcons, BrowserFrame, FormControl, FormField, FormItem, FormLabel, FormMessage, InlineCode, Label, SimpleTooltip, Switch, Typography } from "@/components/ui"; import { FormDialog } from "@/components/form-dialog"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { useDashboardInternalUser } from "@/lib/dashboard-user"; import { DesignAlert, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx index 2cfe5f5c5..1f73b4b18 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx @@ -15,7 +15,7 @@ import { type AssistantComposerApi, } from "@/components/vibe-coding"; import { ToolCallContent } from "@/components/vibe-coding/chat-adapters"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { useDashboardUser } from "@/lib/dashboard-user"; import { cn } from "@/lib/utils"; import { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/page-client.tsx index a140b820e..180e44069 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/page-client.tsx @@ -6,7 +6,7 @@ import { InputField } from "@/components/form-fields"; import { useRouter } from "@/components/router"; import { ActionDialog, Button, Typography } from "@/components/ui"; import { getShortcutModifierKeyLabel } from "@/lib/keyboard-shortcuts"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { ChartBarIcon, PlusIcon, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/[storeId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/[storeId]/page-client.tsx index 0b744d263..00f88aabb 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/[storeId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/[storeId]/page-client.tsx @@ -10,7 +10,7 @@ import { type DesignEditableGridItem, } from "@/components/design-components"; import { ActionDialog, Label, toast } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { ArrowLeftIcon, CopyIcon, DatabaseIcon, HashIcon, TagIcon, TrashIcon } from "@phosphor-icons/react"; import { deindent } from "@hexclave/shared/dist/utils/strings"; import { useMemo, useState } from "react"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/page-client.tsx index e7c910cf7..a2f5d87ea 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/page-client.tsx @@ -6,7 +6,7 @@ import { DesignListItemRow, } from "@/components/design-components"; import { ActionDialog, Label, toast } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { DatabaseIcon, PlusIcon } from "@phosphor-icons/react"; import { getUserSpecifiedIdErrorMessage, isValidUserSpecifiedId, sanitizeUserSpecifiedId } from "@hexclave/shared/dist/schema-fields"; import { typedEntries } from "@hexclave/shared/dist/utils/objects"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx index 6d1f253cc..4d164d264 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx @@ -5,7 +5,7 @@ import { InlineSaveDiscard } from "@/components/inline-save-discard"; import { DesignAlert } from "@/components/design-components"; import { SettingCard, SettingSwitch } from "@/components/settings"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, ActionCell, ActionDialog, Alert, Button, Typography } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { DataGrid, useDataGridUrlState, useDataSource, type DataGridColumnDef } from "@hexclave/dashboard-ui-components"; import { yupString } from "@hexclave/shared/dist/schema-fields"; import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/domain-settings.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/domain-settings.tsx index 3e508f1c5..fb84e9cff 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/domain-settings.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-settings/domain-settings.tsx @@ -8,7 +8,7 @@ import { DesignCard, DesignInput, } from "@/components/design-components"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { getPublicEnvVar } from "@/lib/env"; import { cn } from "@/lib/utils"; import { AdminEmailConfig } from "@hexclave/next"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/page-client.tsx index bcb2f2ab0..4b5cdff2a 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/page-client.tsx @@ -5,7 +5,7 @@ import { FormDialog } from "@/components/form-dialog"; import { InputField } from "@/components/form-fields"; import { useRouter } from "@/components/router"; import { ActionDialog, Alert, AlertDescription, AlertTitle, Button, Typography } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { cn } from "@/lib/utils"; import { CheckIcon, DeviceMobile, DeviceTablet, Monitor, Palette, Plus, Trash } from "@phosphor-icons/react"; import { DEFAULT_EMAIL_THEMES, DEFAULT_EMAIL_THEME_ID, previewTemplateSource } from "@hexclave/shared/dist/helpers/emails"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx index 6646149cc..3ab184448 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx @@ -5,7 +5,7 @@ import { FormDialog } from "@/components/form-dialog"; import { InputField, SelectField, TextAreaField } from "@/components/form-fields"; import { ActionDialog, Alert, AlertDescription, AlertTitle, Button, SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Typography, useToast } from "@/components/ui"; import { useRouter } from "@/components/router"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { getPublicEnvVar } from "@/lib/env"; import { ArrowSquareOut, CheckCircle, Envelope, HardDrive, Sliders, WarningCircleIcon, XCircle, XIcon } from "@phosphor-icons/react"; import { AdminEmailConfig, AdminProject, AdminSentEmail, ServerUser, UserAvatar } from "@hexclave/next"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/layout.tsx index 05ff07c08..36b93ca81 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/layout.tsx @@ -1,4 +1,4 @@ -import { ConfigAgentRunWatcher } from "@/lib/config-agent-run"; +import { ConfigAgentRunWatcher } from "@/components/config-update/config-agent-run-watcher"; import { UrlPrefetcher } from "@/lib/prefetch/url-prefetcher"; import SidebarLayout from "./sidebar-layout"; import { AdminAppProvider } from "./use-admin-app"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/onboarding/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/onboarding/page-client.tsx index 55604f032..3773cee0f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/onboarding/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/onboarding/page-client.tsx @@ -8,7 +8,7 @@ import { DesignDialogClose, } from "@/components/design-components"; import { Typography } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { WarningCircle } from "@phosphor-icons/react"; import type { RestrictedReason } from "@hexclave/shared/dist/schema-fields"; import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/customers/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/customers/page-client.tsx index 4f397803d..4f00ae4e0 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/customers/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/customers/page-client.tsx @@ -20,7 +20,7 @@ import { toast, Typography, } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { CaretUpDownIcon } from "@phosphor-icons/react"; import { createDefaultDataGridState, DataGrid, useDataGridUrlState, useDataSource, type DataGridColumnDef } from "@hexclave/dashboard-ui-components"; import { KnownErrors } from "@hexclave/shared"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx index 7b141cde8..5ac62d532 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx @@ -5,7 +5,7 @@ import { SelectField } from "@/components/form-fields"; import { Link } from "@/components/link"; import { StripeConnectProvider } from "@/components/payments/stripe-connect-provider"; import { ActionDialog, Button, Card, CardContent, Typography } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { cn } from "@/lib/utils"; import { getPublicEnvVar } from "@/lib/env"; import { ArrowRightIcon, ArrowsClockwiseIcon, ChartBarIcon, FlaskIcon, ShieldIcon, WalletIcon, WarningIcon, WebhooksLogoIcon } from "@phosphor-icons/react"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx index ab129901d..dd4da486d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/edit/page-client.tsx @@ -21,7 +21,7 @@ import { Typography, } from "@/components/ui"; import { SubpageHeader } from "@/components/design-components/subpage-header"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { cn } from "@/lib/utils"; import { ClockIcon, HardDriveIcon, PackageIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TrashIcon } from "@phosphor-icons/react"; import { CompleteConfig } from "@hexclave/shared/dist/config/schema"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx index d817facea..91e346872 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx @@ -40,7 +40,7 @@ import { Typography, } from "@/components/ui"; import { createDefaultDataGridState, DataGrid, useDataGridUrlState, useDataSource, type DataGridColumnDef } from "@hexclave/dashboard-ui-components"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { ArrowLeftIcon, ClockIcon, CopyIcon, CurrencyDollarIcon, DotsThreeIcon, FolderOpenIcon, GiftIcon, HardDriveIcon, PackageIcon, PencilSimpleIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TagIcon, TrashIcon, UsersIcon } from "@phosphor-icons/react"; import type { CompleteConfig } from "@hexclave/shared/dist/config/schema"; import type { Transaction, TransactionEntry } from "@hexclave/shared/dist/interface/crud/transactions"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx index 3edcee8ea..71b588a12 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx @@ -24,7 +24,7 @@ import { toast, Typography, } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { cn } from "@/lib/utils"; import { ArrowSquareOutIcon, BuildingOfficeIcon, CaretDownIcon, ChatIcon, ClockIcon, CodeIcon, CopyIcon, GearIcon, HardDriveIcon, LightningIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TrashIcon, UserIcon } from "@phosphor-icons/react"; import { CompleteConfig } from "@hexclave/shared/dist/config/schema"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx index b4e3d216a..bfac898e7 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx @@ -5,7 +5,7 @@ import { ItemDialog } from "@/components/payments/item-dialog"; import { useRouter } from "@/components/router"; import { ActionDialog, Alert, AlertDescription, AlertTitle, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, toast } from "@/components/ui"; import { cn } from "@/lib/utils"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { DotsThreeVerticalIcon } from "@phosphor-icons/react"; import { CompleteConfig } from "@hexclave/shared/dist/config/schema"; import { useHover } from "@hexclave/shared/dist/hooks/use-hover"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-product-lines-view.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-product-lines-view.tsx index 2bb1e15d7..4528fbc3f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-product-lines-view.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-product-lines-view.tsx @@ -26,7 +26,7 @@ import { toast } from "@/components/ui"; import { cn } from "@/lib/utils"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { DndContext, DragOverlay, useDraggable, useDroppable, type DragEndEvent, type DragStartEvent } from '@dnd-kit/core'; import { CaretUpDownIcon, CircleNotchIcon, CodeIcon, CopyIcon, DotsSixVerticalIcon, DotsThreeVerticalIcon, EyeIcon, FileTextIcon, HardDriveIcon, InfoIcon, PencilSimpleIcon, PlusIcon, PuzzlePieceIcon, StackIcon, TrashIcon, XIcon } from "@phosphor-icons/react"; import { CompleteConfig } from "@hexclave/shared/dist/config/schema"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/page-client.tsx index 32470f6c7..992aafb6a 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/page-client.tsx @@ -2,7 +2,7 @@ import { Switch, Typography } from "@/components/ui"; import { DesignCard } from "@/components/design-components"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { cn } from "@/lib/utils"; import { LockIcon } from "@phosphor-icons/react"; import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/test-mode-toggle.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/test-mode-toggle.tsx index 31fbb91e3..375e59152 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/test-mode-toggle.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/settings/test-mode-toggle.tsx @@ -1,7 +1,7 @@ "use client"; import { Switch, Typography } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { cn } from "@/lib/utils"; import { DesignBadge, DesignCard } from "@/components/design-components"; import { FlaskIcon } from "@phosphor-icons/react"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index e2bf5154c..fe4de87bc 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -22,7 +22,7 @@ import { import { WalkthroughProvider } from "@/components/walkthrough/walkthrough-provider"; import { ALL_APPS_FRONTEND, DUMMY_ORIGIN, getAppPath, getItemPath, hasNavigationItems, testAppPath, testItemPath, type NavigableAppFrontend } from "@/lib/apps-frontend"; import { getEnabledAppIds, getEnabledNavigableAppIds } from "@/lib/apps-utils"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { cn } from "@/lib/utils"; import { CaretDownIcon, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx index 5213915c7..24357805d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx @@ -44,7 +44,7 @@ import { visualTreeToCel, type RuleNode, } from "@/lib/cel-visual-parser"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { hexclaveAppInternalsSymbol } from "@/lib/hexclave-app-internals"; import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-settings/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-settings/page-client.tsx index 0c3f2983b..ae00129e5 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-settings/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-settings/page-client.tsx @@ -8,7 +8,7 @@ import { type DesignEditableGridItem, } from "@/components/design-components"; import { ActionDialog, Checkbox, Switch, Typography } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { GearSix, ShieldCheck, ShieldIcon, UserPlus, UsersIcon } from "@phosphor-icons/react"; import { typedFromEntries } from "@hexclave/shared/dist/utils/objects"; import { useCallback, useEffect, useMemo, useState } from "react"; diff --git a/apps/dashboard/src/components/app-square.tsx b/apps/dashboard/src/components/app-square.tsx index ccacc6383..c71d15c89 100644 --- a/apps/dashboard/src/components/app-square.tsx +++ b/apps/dashboard/src/components/app-square.tsx @@ -3,7 +3,7 @@ import { useRouter } from "@/components/router"; import { Button, cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui"; import { ALL_APPS_FRONTEND, AppFrontend, getAppPath, getDocumentationHref } from "@/lib/apps-frontend"; import { isAppEnabled } from "@/lib/apps-utils"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { CheckIcon, DotsThreeVerticalIcon } from "@phosphor-icons/react"; import { ALL_APPS, AppId, getParentAppId } from "@hexclave/shared/dist/apps/apps-config"; import { appSquarePaddingExpression, appSquareWidthExpression, AppIcon as SharedAppIcon } from "@hexclave/shared/dist/apps/apps-ui"; diff --git a/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx b/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx index d697c1701..2e1d6b649 100644 --- a/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx +++ b/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx @@ -7,7 +7,7 @@ import { useDebouncedAction } from "@/hooks/use-debounced-action"; import { createUnifiedAiTransport } from "@/components/assistant-ui/chat-stream"; import { buildDashboardMessages } from "@/lib/ai-dashboard/shared-prompt"; import type { AppId } from "@/lib/apps-frontend"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { useDashboardUser } from "@/lib/dashboard-user"; import { getPublicEnvVar } from "@/lib/env"; import { cn } from "@/lib/utils"; diff --git a/apps/dashboard/src/components/commands/run-query.tsx b/apps/dashboard/src/components/commands/run-query.tsx index 2806dff4c..31dfb588c 100644 --- a/apps/dashboard/src/components/commands/run-query.tsx +++ b/apps/dashboard/src/components/commands/run-query.tsx @@ -27,7 +27,7 @@ import { SimpleTooltip } from "@/components/ui/simple-tooltip"; import { Textarea } from "@/components/ui/textarea"; import { useDebouncedAction } from "@/hooks/use-debounced-action"; import { useFromNow } from "@/hooks/use-from-now"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { ArrowClockwiseIcon, CheckCircleIcon, diff --git a/apps/dashboard/src/lib/config-agent-run.tsx b/apps/dashboard/src/components/config-update/config-agent-run-watcher.tsx similarity index 96% rename from apps/dashboard/src/lib/config-agent-run.tsx rename to apps/dashboard/src/components/config-update/config-agent-run-watcher.tsx index 41afe4f66..508e4d71d 100644 --- a/apps/dashboard/src/lib/config-agent-run.tsx +++ b/apps/dashboard/src/components/config-update/config-agent-run-watcher.tsx @@ -7,7 +7,8 @@ import type { StackAdminApp } from "@hexclave/next"; import { captureError } from "@hexclave/shared/dist/utils/errors"; import React, { useCallback, useEffect, useRef, useState } from "react"; -import { ConfigAgentRunProgressContent, type AgentStage, getAdminInterface, isGithubPushedSourceWithAgentRun, useGithubRunActive, type GithubPushedSourceWithAgentRun } from "./config-update"; +import { ConfigAgentRunProgressContent } from "./progress-content"; +import { type AgentStage, getAdminInterface, isGithubPushedSourceWithAgentRun, useGithubRunActive, type GithubPushedSourceWithAgentRun } from "./shared"; /** * Watches the linked-GitHub config source for an in-flight agent run and, when diff --git a/apps/dashboard/src/components/config-update/github-push-dialog.tsx b/apps/dashboard/src/components/config-update/github-push-dialog.tsx new file mode 100644 index 000000000..ede53d655 --- /dev/null +++ b/apps/dashboard/src/components/config-update/github-push-dialog.tsx @@ -0,0 +1,584 @@ +'use client'; + +import { Link } from "@/components/link"; +import { DesignButton, DesignDialog, DesignDialogClose } from "@/components/design-components"; +import { useDashboardInternalUser } from "@/lib/dashboard-user"; +import { ArrowsClockwise, GitBranch, GitCommit } from "@phosphor-icons/react"; +import type { OAuthConnection, StackAdminApp } from "@hexclave/next"; +import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema"; +import { captureError } from "@hexclave/shared/dist/utils/errors"; +import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; +import { GITHUB_SCOPE_REQUIREMENTS } from "@/lib/github-api"; +import React, { Suspense, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react"; + +import { AgentDiffViewer, ConfigAgentRunProgressContent } from "./progress-content"; +import { ConfigUpdateDialogContext, currentEpochMsFromPerformance, getAdminInterface, type AgentStage, type GithubPushedSource, isAgentStage, isGithubPushedSourceWithAgentRun } from "./shared"; + +type GithubPushDialogProps = { + open: boolean, + adminApp: StackAdminApp | null, + source: GithubPushedSource, + configUpdate: EnvironmentConfigOverrideOverride | null, + projectId: string | undefined, + onSettle: (result: boolean) => void, +}; + +/** + * The new GitHub push dialog: shows a staged progress bar while the agent + * runs, then a diff review panel once the agent is done. The user must + * explicitly click "Commit" to push. No auto-commit. + */ + +type ScopeCheck = + | { status: "no-account" } + | { status: "checking" } + | { status: "ok", account: OAuthConnection } + | { status: "missing-scopes" }; + +// "idle": waiting for user to start. +// "running": agent is in flight (non-dismissible; Cancel stops the sandbox). +// "cancelling": user clicked Cancel, waiting for terminal status. +// "awaiting_review": agent done, diff loaded, waiting for user to commit. +// "committing": user clicked Commit, pushing to GitHub. +type DialogPhase = "idle" | "running" | "cancelling" | "awaiting_review" | "committing"; + +function projectSettingsHref(projectId: string | undefined): string { + return `/projects/${projectId}/project-settings`; +} + +/** + * Outer shell: renders the DesignDialog synchronously; the Suspense-suspending + * body (scope check) is isolated inside. + */ +export function GithubPushDialog({ open, adminApp, source, configUpdate, projectId, onSettle }: GithubPushDialogProps) { + const [scopeStatus, setScopeStatus] = useState("checking"); + const [phase, setPhase] = useState("idle"); + const [stage, setStage] = useState(null); + const [startedAt, setStartedAt] = useState(0); + const [activity, setActivity] = useState(null); + const [diff, setDiff] = useState(null); + const [commitMessage, setCommitMessage] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + + // Expose imperative handles from the body (which can suspend) to the outer shell. + const handlersRef = useRef<{ + push: () => Promise, + connect: () => Promise, + cancel: () => Promise, + commit: () => Promise, + } | null>(null); + + const dialogContext = useContext(ConfigUpdateDialogContext); + const isNonDismissible = phase === "running" || phase === "cancelling" || phase === "committing"; + + const description = (() => { + switch (phase) { + case "idle": { + switch (scopeStatus) { + case "no-account": { return "Connect a GitHub account to push configuration changes to this repository."; } + case "checking": { return "Checking GitHub permissions…"; } + case "ok": { return `This will apply your change to ${source.owner}/${source.repo}@${source.branch}.`; } + case "missing-scopes": { return `Your linked GitHub account is missing the "repo" and "workflow" permissions. Reconnect to grant them.`; } + } + break; + } + case "running": + case "cancelling": { + return `Applying your change in a sandbox — ${source.owner}/${source.repo}@${source.branch}`; + } + case "awaiting_review": { + return `Review the changes before committing to ${source.branch}.`; + } + case "committing": { + return `Pushing to ${source.owner}/${source.repo}@${source.branch}…`; + } + } + })(); + + // Footer buttons + const footer = (() => { + if (phase === "running") { + return ( +
+ { await handlersRef.current?.cancel(); }} + > + Cancel + +
+ ); + } + if (phase === "cancelling") { + return ( + + Cancelling… + + ); + } + if (phase === "awaiting_review") { + return ( +
+ { await handlersRef.current?.cancel(); }} + > + Discard + +
+
+ + setCommitMessage(e.target.value)} + /> + { await handlersRef.current?.commit(); }} + > + + Commit + +
+
+ ); + } + if (phase === "committing") { + return ( +
+ + Committing… + +
+ ); + } + // idle + return ( +
+ + { onSettle(false); }}> + Cancel + + + {scopeStatus === "no-account" || scopeStatus === "missing-scopes" ? ( + { await handlersRef.current?.connect(); }}> + {scopeStatus === "no-account" ? "Connect with GitHub" : "Reconnect with GitHub"} + + ) : ( + { await handlersRef.current?.push(); }} + disabled={scopeStatus === "checking"} + loading={scopeStatus === "checking"} + > + + Start update + + )} +
+ ); + })(); + + // Dialog size grows when showing the diff + const dialogSize = phase === "awaiting_review" ? "3xl" : "lg"; + + return ( + { + if (o || isNonDismissible) return; + onSettle(false); + }} + size={dialogSize} + icon={GitBranch} + title="Push configuration to GitHub" + description={description} + hideTopCloseButton={isNonDismissible} + footer={footer} + contentProps={{ onPointerDownOutside: isNonDismissible ? (e) => e.preventDefault() : undefined, onEscapeKeyDown: isNonDismissible ? (e) => e.preventDefault() : undefined }} + > + Loading…
}> + + + + ); +} + +type GithubPushBodyProps = { + adminApp: StackAdminApp | null, + source: GithubPushedSource, + configUpdate: EnvironmentConfigOverrideOverride | null, + projectId: string | undefined, + onSettle: (result: boolean) => void, + phase: DialogPhase, + stage: AgentStage | null, + startedAt: number, + activity: string | null, + diff: string | null, + commitMessage: string, + errorMessage: string | null, + onScopeStatusChange: (s: ScopeCheck["status"]) => void, + onPhaseChange: (p: DialogPhase) => void, + onStageChange: (s: AgentStage | null) => void, + onStartedAtChange: (ms: number) => void, + onActivityChange: (a: string | null) => void, + onDiffChange: (d: string | null) => void, + onErrorChange: (e: string | null) => void, + handlersRef: React.MutableRefObject<{ + push: () => Promise, + connect: () => Promise, + cancel: () => Promise, + commit: () => Promise, + } | null>, + dialogContext: { setGithubRunActive: (v: boolean) => void } | null, +}; + +function GithubPushBody({ + adminApp, + source, + configUpdate, + projectId, + onSettle, + phase, + stage, + startedAt, + activity, + diff, + commitMessage, + errorMessage, + onScopeStatusChange, + onPhaseChange, + onStageChange, + onStartedAtChange, + onActivityChange, + onDiffChange, + onErrorChange, + handlersRef, + dialogContext, +}: GithubPushBodyProps) { + const user = useDashboardInternalUser(); + const githubAccounts = user.useConnectedAccounts().filter((account) => account.provider === "github"); + const githubAccountsKey = githubAccounts.map((a) => a.providerAccountId).join("|"); + + const [scopeCheck, setScopeCheck] = useState( + githubAccounts.length === 0 ? { status: "no-account" } : { status: "checking" }, + ); + + const placeholderCommitMessage = "chore(hexclave): update config from dashboard"; + + useLayoutEffect(() => { + onScopeStatusChange(scopeCheck.status); + }, [scopeCheck.status, onScopeStatusChange]); + + useEffect(() => { + if (githubAccounts.length === 0) { + setScopeCheck({ status: "no-account" }); + return; + } + const cancelToken = { cancelled: false }; + setScopeCheck({ status: "checking" }); + runAsynchronously(async () => { + for (const account of githubAccounts) { + let tokenResult; + try { + tokenResult = await account.getAccessToken({ scopes: GITHUB_SCOPE_REQUIREMENTS }); + } catch { + continue; + } + if (cancelToken.cancelled) return; + if (tokenResult.status === "ok") { + setScopeCheck({ status: "ok", account }); + return; + } + } + if (!cancelToken.cancelled) setScopeCheck({ status: "missing-scopes" }); + }); + return () => { + cancelToken.cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- githubAccountsKey + }, [githubAccountsKey]); + + const handlePush = useCallback(async () => { + if (configUpdate == null) { + onErrorChange("No configuration changes to push."); + return; + } + if (scopeCheck.status !== "ok") { + onErrorChange("Connect a GitHub account with the required scopes before pushing changes."); + return; + } + const adminInterface = getAdminInterface(adminApp); + if (adminInterface == null || typeof adminInterface.applyConfigViaAgent !== "function") { + onErrorChange("This dashboard build can't push config to GitHub. Please refresh and try again."); + return; + } + + onErrorChange(null); + try { + const tokenResult = await scopeCheck.account.getAccessToken({ scopes: GITHUB_SCOPE_REQUIREMENTS }); + if (tokenResult.status !== "ok") { + onErrorChange("Could not get a GitHub token with the required permissions. Reconnect your GitHub account and try again."); + return; + } + + const start = await adminInterface.applyConfigViaAgent({ + configUpdate, + // Pass a placeholder; the real commit message is gathered at review time. + commitMessage: placeholderCommitMessage, + githubAccessToken: tokenResult.data.accessToken, + }); + if (start.status === "already-running") { + onErrorChange("Another configuration update is already running for this project. Wait for it to finish, then try again."); + return; + } + + const runStartedAtWallMs = currentEpochMsFromPerformance(); + const runStartedAtMonotonicMs = performance.now(); + onStartedAtChange(runStartedAtWallMs); + dialogContext?.setGithubRunActive(true); + onPhaseChange("running"); + onActivityChange(null); + onStageChange("initializing_sandbox"); + + // Poll until the run transitions out of "running" (either to + // "awaiting_review", a terminal status, or times out). + const deadline = performance.now() + 8 * 60_000; + while (performance.now() < deadline) { + await new Promise((r) => setTimeout(r, 3000)); + let latest: unknown; + try { + latest = await adminInterface.getPushedConfigSource(); + } catch { + continue; + } + const run = isGithubPushedSourceWithAgentRun(latest) ? latest.agent_run : null; + // Ignore stale runs from before this one started. + if (run == null || (typeof run.started_at === "number" && run.started_at < runStartedAtWallMs - 5000)) continue; + + if (run.status === "running") { + if (typeof run.progress === "string") onActivityChange(run.progress); + if (isAgentStage(run.stage)) onStageChange(run.stage); + continue; + } + + // Non-running status: transition. + dialogContext?.setGithubRunActive(false); + + if (run.status === "awaiting_review") { + onPhaseChange("awaiting_review"); + onStageChange("awaiting_review"); + if (typeof run.diff === "string") onDiffChange(run.diff); + return; + } + if (run.status === "error") { + onPhaseChange("idle"); + onStageChange(null); + onErrorChange("The config agent failed to apply your change."); + return; + } + if (run.status === "cancelled") { + onPhaseChange("idle"); + onStageChange(null); + onSettle(false); + return; + } + if (run.status === "no-change") { + onPhaseChange("idle"); + onStageChange(null); + onErrorChange("The config agent finished without producing a diff. No commit was created; try the update again."); + return; + } + // success is only expected from older auto-commit flows or a race with + // a completed commit. Settle so the dashboard can refresh its local state. + onPhaseChange("idle"); + onStageChange(null); + onSettle(true); + return; + } + + dialogContext?.setGithubRunActive(false); + onPhaseChange("idle"); + onStageChange(null); + const elapsedSeconds = Math.floor((performance.now() - runStartedAtMonotonicMs) / 1000); + onErrorChange(`Timed out after ${elapsedSeconds}s waiting for the config agent. Your change may still be in progress; check the linked repository.`); + } catch (error) { + captureError("config-update-github-agent", { + projectId, + owner: source.owner, + repo: source.repo, + branch: source.branch, + configFilePath: source.configFilePath, + cause: error, + }); + dialogContext?.setGithubRunActive(false); + onPhaseChange("idle"); + onStageChange(null); + onErrorChange("Unknown error pushing to GitHub."); + } + }, [adminApp, configUpdate, dialogContext, onActivityChange, onDiffChange, onErrorChange, onPhaseChange, onSettle, onStageChange, onStartedAtChange, projectId, scopeCheck, source]); + + const handleCancel = useCallback(async () => { + const adminInterface = getAdminInterface(adminApp); + if (adminInterface == null || typeof adminInterface.cancelConfigAgentRun !== "function") { + onErrorChange("This dashboard build can't cancel a config run. Please refresh and try again."); + return; + } + onPhaseChange("cancelling"); + try { + await adminInterface.cancelConfigAgentRun(); + } catch (error) { + captureError("config-update-github-cancel", error); + } + // The poll loop in handlePush will observe the terminal `cancelled` status and settle. + }, [adminApp, onErrorChange, onPhaseChange]); + + const handleCommit = useCallback(async () => { + if (scopeCheck.status !== "ok") { + onErrorChange("GitHub account not connected. Please reconnect and try again."); + return; + } + const adminInterface = getAdminInterface(adminApp); + if (adminInterface == null || typeof adminInterface.commitConfigAgentRun !== "function") { + onErrorChange("This dashboard build can't commit. Please refresh and try again."); + return; + } + onPhaseChange("committing"); + onErrorChange(null); + try { + const tokenResult = await scopeCheck.account.getAccessToken({ scopes: GITHUB_SCOPE_REQUIREMENTS }); + if (tokenResult.status !== "ok") { + onPhaseChange("awaiting_review"); + onErrorChange("Could not get a GitHub token. Reconnect your GitHub account and try again."); + return; + } + const result = await adminInterface.commitConfigAgentRun({ + githubAccessToken: tokenResult.data.accessToken, + commitMessage: commitMessage.trim().length > 0 ? commitMessage : undefined, + }); + if (result.status === "sandbox-expired") { + onPhaseChange("idle"); + onErrorChange("The sandbox session expired. Please retry the update."); + return; + } + if (result.status === "not-awaiting-review") { + onPhaseChange("idle"); + onErrorChange("There is no config diff waiting to commit. Start the update again."); + return; + } + // "committing" — poll until done + const adminInterface2 = adminInterface; + const deadline = performance.now() + 2 * 60_000; + while (performance.now() < deadline) { + await new Promise((r) => setTimeout(r, 3000)); + let latest: unknown; + try { + latest = await adminInterface2.getPushedConfigSource(); + } catch { + continue; + } + const run = isGithubPushedSourceWithAgentRun(latest) ? latest.agent_run : null; + if (run == null || run.status === "awaiting_review") continue; + if (run.status === "success") { + onPhaseChange("idle"); + onSettle(true); + return; + } + if (run.status === "error") { + onPhaseChange("awaiting_review"); + onErrorChange("Failed to commit and push the changes. Please try again."); + return; + } + if (run.status === "cancelled") { + onPhaseChange("idle"); + onSettle(false); + return; + } + } + onPhaseChange("awaiting_review"); + onErrorChange("Timed out waiting for the commit. Check the repository for status."); + } catch (error) { + captureError("config-update-github-commit", error); + onPhaseChange("awaiting_review"); + onErrorChange("Unknown error committing to GitHub."); + } + }, [adminApp, commitMessage, onErrorChange, onPhaseChange, onSettle, scopeCheck]); + + const handleConnect = useCallback(async () => { + try { + await user.getOrLinkConnectedAccount("github", { scopes: GITHUB_SCOPE_REQUIREMENTS }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error connecting to GitHub."; + onErrorChange(message); + } + }, [onErrorChange, user]); + + useEffect(() => { + handlersRef.current = { push: handlePush, connect: handleConnect, cancel: handleCancel, commit: handleCommit }; + }, [handlersRef, handlePush, handleConnect, handleCancel, handleCommit]); + + return ( +
+ {/* Stage progress bar — shown while running */} + {(phase === "running" || phase === "cancelling") && ( + + )} + + {/* Diff viewer — shown when awaiting review */} + {phase === "awaiting_review" && diff != null && diff.trim().length > 0 && ( + + )} + + {/* Error */} + {phase !== "running" && phase !== "cancelling" && errorMessage != null && ( +

{errorMessage}

+ )} + + {/* Unlink hint — shown in idle state */} + {phase === "idle" && ( +

+ If your configuration is no longer on GitHub, you can{" "} + + unlink it in Project Settings + . +

+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/config-update/index.tsx b/apps/dashboard/src/components/config-update/index.tsx new file mode 100644 index 000000000..ad60cf5e0 --- /dev/null +++ b/apps/dashboard/src/components/config-update/index.tsx @@ -0,0 +1,296 @@ +'use client'; + +import { ActionDialog, Button } from "@/components/ui"; +import { getPublicEnvVar } from "@/lib/env"; +import type { PushedConfigSource, StackAdminApp } from "@hexclave/next"; +import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema"; +import { HexclaveAssertionError, captureError } from "@hexclave/shared/dist/utils/errors"; +import React, { useCallback, useContext, useState } from "react"; + +import { GithubPushDialog } from "./github-push-dialog"; +import { updateRemoteDevelopmentEnvironmentConfigFile } from "./remote-development-environment"; +import { ConfigUpdateDialogContext, getAdminInterface, isGithubPushedSourceWithAgentRun } from "./shared"; + +type ConfigUpdateDialogState = { + isOpen: boolean, + adminApp: StackAdminApp | null, + configUpdate: EnvironmentConfigOverrideOverride | null, + resolve: ((result: boolean) => void) | null, + source: PushedConfigSource | null, +}; + +export function ConfigUpdateDialogProvider({ children }: { children: React.ReactNode }) { + const [dialogState, setDialogState] = useState({ + isOpen: false, + adminApp: null, + configUpdate: null, + resolve: null, + source: null, + }); + const [githubRunActive, setGithubRunActive] = useState(false); + + const showPushableDialog = useCallback(async (adminApp: StackAdminApp, configUpdate: EnvironmentConfigOverrideOverride): Promise => { + const project = await adminApp.getProject(); + const source = await project.getPushedConfigSource(); + + // The public `PushedConfigSource` type drops `agent_run`; read the raw + // interface source so the page-load watcher can own already-running jobs. + if (source.type === "pushed-from-github") { + const iface = getAdminInterface(adminApp); + if (iface != null && typeof iface.getPushedConfigSource === "function") { + let rawSource: unknown = null; + try { + rawSource = await iface.getPushedConfigSource(); + } catch (error) { + captureError("config-update-source-agent-run-check", error); + } + if (isGithubPushedSourceWithAgentRun(rawSource) && rawSource.agent_run?.status === "running") { + return false; + } + } + } + + let shouldUpdate = true; + if (source.type !== "unlinked") { + shouldUpdate = await new Promise((resolve) => { + setDialogState({ + isOpen: true, + adminApp, + configUpdate, + resolve, + source, + }); + }); + } + + if (shouldUpdate) { + await project.updatePushedConfig(configUpdate); + if (!project.isDevelopmentEnvironment) { + await project.resetConfigOverrideKeys("environment", Object.keys(configUpdate)); + } + return true; + } + return false; + }, []); + + const settleDialog = useCallback((result: boolean) => { + const resolve = dialogState.resolve; + setDialogState({ + isOpen: false, + adminApp: null, + configUpdate: null, + resolve: null, + source: null, + }); + resolve?.(result); + }, [dialogState.resolve]); + + const projectId = dialogState.adminApp?.projectId; + + const renderDialog = () => { + if (!dialogState.isOpen || !dialogState.source) { + return null; + } + + switch (dialogState.source.type) { + case "pushed-from-github": { + return ( + + ); + } + + case "pushed-from-unknown": { + return ( + settleDialog(false)} + title="Configuration Managed by CLI" + description="This project's configuration was pushed via the Hexclave CLI." + okButton={{ + label: "Go to Project Settings", + onClick: async () => { + window.location.href = `/projects/${projectId}/project-settings`; + }, + }} + cancelButton={{ + label: "Cancel", + onClick: async () => { + settleDialog(false); + }, + }} + > +
+

+ To make changes, you can either: +

+
    +
  • Push updates through the Hexclave CLI
  • +
  • Unlink the CLI in Project Settings to edit directly on this dashboard
  • +
+
+
+ ); + } + + default: { + return null; + } + } + }; + + return ( + + {children} + {renderDialog()} + + ); +} + +function useConfigUpdateDialog() { + const context = useContext(ConfigUpdateDialogContext); + if (context == null) { + throw new Error("useConfigUpdateDialog must be used within a ConfigUpdateDialogProvider"); + } + return context; +} + +export type UpdateConfigOptions = { + adminApp: StackAdminApp, + configUpdate: EnvironmentConfigOverrideOverride, + pushable: boolean, +}; + +export function useUpdateConfig() { + const { showPushableDialog } = useConfigUpdateDialog(); + + return useCallback(async (options: UpdateConfigOptions): Promise => { + const { adminApp, configUpdate, pushable } = options; + + if (getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true") { + if (!pushable) { + throw new HexclaveAssertionError("These settings are read-only in a development environment. Update them in your production deployment instead."); + } + + if (await updateRemoteDevelopmentEnvironmentConfigFile(adminApp, configUpdate) === "redirecting") { + return false; + } + return true; + } + + if (pushable) { + return await showPushableDialog(adminApp, configUpdate); + } + + const project = await adminApp.getProject(); + if (project.isDevelopmentEnvironment) { + alert("These settings are read-only in a development environment. Update them in your production deployment instead."); + return false; + } + // eslint-disable-next-line no-restricted-syntax -- this is the hook implementation itself + await project.updateConfig(configUpdate); + return true; + }, [showPushableDialog]); +} + +export type ConfigUpdateButtonProps = { + adminApp: StackAdminApp, + configUpdate: () => Promise, + pushable: boolean, + onUpdated?: () => void | Promise, + actionType: "save" | "create", + disabled?: boolean, + className?: string, + variant?: "default" | "secondary" | "outline" | "ghost" | "destructive" | "link", + size?: "default" | "sm" | "lg" | "icon", +}; + +export function ConfigUpdateButton({ + adminApp, + configUpdate, + pushable, + onUpdated, + actionType, + disabled, + className, + variant = "default", + size = "default", +}: ConfigUpdateButtonProps) { + const updateConfig = useUpdateConfig(); + + const handleClick = async () => { + const configUpdateValue = await configUpdate(); + const success = await updateConfig({ + adminApp, + configUpdate: configUpdateValue, + pushable, + }); + if (success) { + await onUpdated?.(); + } + }; + + const label = actionType === "save" ? "Save changes" : "Create"; + + return ( + + ); +} + +export type UnsavedChangesFooterProps = { + hasChanges: boolean, + adminApp: StackAdminApp, + configUpdate: () => Promise, + pushable: boolean, + onDiscard: () => void, + onSaved?: () => void | Promise, + actionType?: "save" | "create", +}; + +export function UnsavedChangesFooter({ + hasChanges, + adminApp, + configUpdate, + pushable, + onDiscard, + onSaved, + actionType = "save", +}: UnsavedChangesFooterProps) { + if (!hasChanges) { + return null; + } + + return ( +
+ + +
+ ); +} diff --git a/apps/dashboard/src/components/config-update/progress-content.tsx b/apps/dashboard/src/components/config-update/progress-content.tsx new file mode 100644 index 000000000..651e2f1ff --- /dev/null +++ b/apps/dashboard/src/components/config-update/progress-content.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; +import React, { useEffect, useState } from "react"; +import type { FileDiffProps } from "@pierre/diffs/react"; +import type { FileDiffMetadata } from "@pierre/diffs"; + +import { currentEpochMsFromPerformance, type AgentStage } from "./shared"; + +type StepDef = { key: AgentStage, label: string, subLabel?: string }; + +const STAGE_STEPS: StepDef[] = [ + { key: "initializing_sandbox", label: "Initializing sandbox" }, + { key: "cloning_repo", label: "Cloning repo" }, + { key: "agent_making_changes", label: "Agent making changes", subLabel: "Editing config file" }, + { key: "awaiting_review", label: "Ready to review" }, +]; + +function stageIndex(stage: AgentStage | null | undefined): number { + if (stage == null) return -1; + return STAGE_STEPS.findIndex((s) => s.key === stage); +} + +/** + * A compact stage tracker shown while the agent is running. Each step shows + * elapsed seconds and a live activity sub-label for the active step. + */ +export function AgentStageProgress({ + stage, + startedAt, + activity, +}: { + stage: AgentStage | null | undefined, + /** Unix ms timestamp of when the run started (from agent_run.started_at). */ + startedAt: number, + activity?: string | null, +}) { + const [elapsedMs, setElapsedMs] = useState(0); + useEffect(() => { + const performanceStartedAt = performance.now(); + const t = setInterval(() => setElapsedMs(performance.now() - performanceStartedAt), 1000); + return () => clearInterval(t); + }, []); + + const activeIdx = stageIndex(stage); + // Server-side run timestamps are wall-clock epoch values. Once mounted, keep + // the visible elapsed counter on a monotonic clock so local clock jumps don't + // make the progress UI move backwards. + const initialElapsedMs = Math.max(0, currentEpochMsFromPerformance() - startedAt); + const overallElapsed = Math.max(0, Math.floor((initialElapsedMs + elapsedMs) / 1000)); + + return ( +
+ {STAGE_STEPS.map((step, idx) => { + const isDone = idx < activeIdx; + const isActive = idx === activeIdx; + const isPending = idx > activeIdx; + + return ( +
+
+ {isDone ? ( + + + + ) : ( + {idx + 1} + )} +
+ +
+
+ {step.label} + {isActive && ( + + {overallElapsed}s + + )} +
+ {isActive && activity != null && activity.trim().length > 0 && ( +
+ + {activity.split("\n").filter((l) => l.trim()).at(-1)} +
+ )} +
+
+ ); + })} +
+ ); +} + +export function ConfigAgentRunProgressContent({ + isCancelling, + stage, + startedAt, + activity, + errorMessage, +}: { + isCancelling: boolean, + stage: AgentStage | null | undefined, + startedAt: number, + activity?: string | null, + errorMessage?: string | null, +}) { + return ( +
+ {isCancelling ? ( +

Cancelling the update and stopping the agent…

+ ) : ( + + )} + {errorMessage != null && ( +

{errorMessage}

+ )} +
+ ); +} + +/** + * Lazy-loaded diff viewer. We parse the sandbox's full `git diff` into file + * diffs, then render each file with Pierre's React renderer. `PatchDiff` only + * accepts a single-file patch, while the config agent may legitimately edit + * helpers/imported config files too. + */ +export function AgentDiffViewer({ diff }: { diff: string }) { + const [renderer, setRenderer] = useState<{ + FileDiff: React.ComponentType>, + files: FileDiffMetadata[], + } | null>(null); + + useEffect(() => { + const cancelToken = { cancelled: false }; + runAsynchronously(async () => { + try { + const [{ parsePatchFiles }, reactMod] = await Promise.all([ + import("@pierre/diffs"), + import("@pierre/diffs/react"), + ]); + if (cancelToken.cancelled) return; + const files = parsePatchFiles(diff, "config-agent-review", true).flatMap((patch) => patch.files); + if (files.length === 0) return; + setRenderer({ FileDiff: reactMod.FileDiff, files }); + } catch { + // Module failed to load — fall back to raw diff text. + } + }); + return () => { + cancelToken.cancelled = true; + }; + }, [diff]); + + if (renderer != null) { + const { FileDiff } = renderer; + return ( +
+ {renderer.files.map((fileDiff, index) => ( + + ))} +
+ ); + } + + return ( +
+      {diff}
+    
+ ); +} diff --git a/apps/dashboard/src/components/config-update/remote-development-environment.ts b/apps/dashboard/src/components/config-update/remote-development-environment.ts new file mode 100644 index 000000000..015d578f2 --- /dev/null +++ b/apps/dashboard/src/components/config-update/remote-development-environment.ts @@ -0,0 +1,33 @@ +import { fetchWithRemoteDevelopmentEnvironmentBrowserSecret, RemoteDevelopmentEnvironmentBrowserSecretRedirectingError } from "@/app/remote-development-environment-browser-secret-client"; +import type { StackAdminApp } from "@hexclave/next"; +import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema"; + +export async function updateRemoteDevelopmentEnvironmentConfigFile( + adminApp: StackAdminApp, + configUpdate: EnvironmentConfigOverrideOverride, +): Promise<"updated" | "redirecting"> { + try { + const response = await fetchWithRemoteDevelopmentEnvironmentBrowserSecret("/api/remote-development-environment/config/apply-update", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + project_id: adminApp.projectId, + config_update: configUpdate, + wait_for_sync: true, + }), + signal: AbortSignal.timeout(130_000), + }); + if (!response.ok) { + throw new Error(`Failed to update local development environment config (${response.status}): ${await response.text()}`); + } + return "updated"; + } catch (error) { + if (error instanceof RemoteDevelopmentEnvironmentBrowserSecretRedirectingError) { + return "redirecting"; + } + throw error; + } +} diff --git a/apps/dashboard/src/components/config-update/shared.tsx b/apps/dashboard/src/components/config-update/shared.tsx new file mode 100644 index 000000000..24e2d374e --- /dev/null +++ b/apps/dashboard/src/components/config-update/shared.tsx @@ -0,0 +1,74 @@ +'use client'; + +import type { PushedConfigSource, StackAdminApp } from "@hexclave/next"; +import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema"; +import type { HexclaveAdminInterface } from "@hexclave/shared/dist/interface/admin-interface"; +import { createContext, useContext } from "react"; + +export type ConfigAgentRunStatus = "running" | "awaiting_review" | "success" | "no-change" | "error" | "cancelled"; +export type AgentStage = "initializing_sandbox" | "cloning_repo" | "agent_making_changes" | "awaiting_review"; + +export type GithubPushedSource = Extract; + +export type GithubPushedSourceWithAgentRun = GithubPushedSource & { + agent_run?: { + status: ConfigAgentRunStatus, + started_at: number, + finished_at?: number, + progress?: string, + sandbox_id?: string, + commit_url?: string, + new_commit_hash?: string, + error?: string, + stage?: AgentStage, + diff?: string, + }, +}; + +export function isAgentStage(value: unknown): value is AgentStage { + return value === "initializing_sandbox" || value === "cloning_repo" || value === "agent_making_changes" || value === "awaiting_review"; +} + +export function isGithubPushedSourceWithAgentRun(source: unknown): source is GithubPushedSourceWithAgentRun { + return typeof source === "object" && source != null && "type" in source && source.type === "pushed-from-github"; +} + +export function currentEpochMsFromPerformance(): number { + return performance.timeOrigin + performance.now(); +} + +/** + * Reaches the admin app's underlying `HexclaveAdminInterface`, which carries the + * config-agent endpoints (`applyConfigViaAgent`, `cancelConfigAgentRun`, + * `getPushedConfigSource`) we call directly — rather than via generated app + * methods — to keep this feature self-contained. `_interface` is a protected + * member, so we read it reflectively (the same pattern the SDK's own cross-domain + * tests use). Returns `null` if the app doesn't expose one. + * + * NOTE: these methods exist on the type, but the installed `@hexclave/next` build + * could predate them, so callers still runtime-check the specific method before + * use and degrade gracefully ("refresh and try again"). + */ +export function getAdminInterface(adminApp: StackAdminApp | null | undefined): HexclaveAdminInterface | null { + if (adminApp == null) return null; + // `Reflect.get` returns `any`; the typed annotation documents the contract + // without an explicit cast (and without an `instanceof`, which is unreliable + // across package-boundary copies of the class). + const iface: HexclaveAdminInterface | undefined = Reflect.get(adminApp, "_interface"); + return iface ?? null; +} + +export const ConfigUpdateDialogContext = createContext<{ + showPushableDialog: (adminApp: StackAdminApp, configUpdate: EnvironmentConfigOverrideOverride) => Promise, + // True while THIS tab's push dialog is actively managing a started run, so the + // page-load watcher (ConfigAgentRunWatcher) doesn't also pop its own modal for + // the same run. The watcher owns the modal only for runs this tab didn't start + // (other tabs / reloads). + githubRunActive: boolean, + setGithubRunActive: (active: boolean) => void, +} | null>(null); + +/** Read-only accessor for the watcher (mounted below this provider). */ +export function useGithubRunActive(): boolean { + return useContext(ConfigUpdateDialogContext)?.githubRunActive ?? false; +} diff --git a/apps/dashboard/src/components/data-table/payment-product-table.tsx b/apps/dashboard/src/components/data-table/payment-product-table.tsx index c4b5b2d28..1bd92836d 100644 --- a/apps/dashboard/src/components/data-table/payment-product-table.tsx +++ b/apps/dashboard/src/components/data-table/payment-product-table.tsx @@ -2,7 +2,7 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; import { ProductDialog } from "@/components/payments/product-dialog"; import { ActionCell, ActionDialog, toast } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { branchPaymentsSchema } from "@hexclave/shared/dist/config/schema"; import { typedEntries, typedFromEntries } from "@hexclave/shared/dist/utils/objects"; import { diff --git a/apps/dashboard/src/components/email-verification-setting.tsx b/apps/dashboard/src/components/email-verification-setting.tsx index b97e26ede..2ac072659 100644 --- a/apps/dashboard/src/components/email-verification-setting.tsx +++ b/apps/dashboard/src/components/email-verification-setting.tsx @@ -3,7 +3,7 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; import { SettingSwitch } from "@/components/settings"; import { ActionDialog, Typography } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { EnvelopeSimpleIcon } from "@phosphor-icons/react"; import type { RestrictedReason } from "@hexclave/shared/dist/schema-fields"; import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; diff --git a/apps/dashboard/src/components/payments/product-dialog.tsx b/apps/dashboard/src/components/payments/product-dialog.tsx index 32aab7f8a..29692acc2 100644 --- a/apps/dashboard/src/components/payments/product-dialog.tsx +++ b/apps/dashboard/src/components/payments/product-dialog.tsx @@ -6,7 +6,7 @@ import { CheckboxField, InputField, SelectField } from "@/components/form-fields import { IncludedItemEditorField } from "@/components/payments/included-item-editor"; import { PriceEditorField } from "@/components/payments/price-editor"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, SimpleTooltip, toast } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { AdminProject } from "@hexclave/next"; import { pricesSchema, productSchema, userSpecifiedIdSchema, yupRecord } from "@hexclave/shared/dist/schema-fields"; import { has } from "@hexclave/shared/dist/utils/objects"; diff --git a/apps/dashboard/src/lib/config-update.tsx b/apps/dashboard/src/lib/config-update.tsx deleted file mode 100644 index 22e6fade8..000000000 --- a/apps/dashboard/src/lib/config-update.tsx +++ /dev/null @@ -1,1325 +0,0 @@ -'use client'; - -import { Link } from "@/components/link"; -import { ActionDialog } from "@/components/ui/action-dialog"; -import { fetchWithRemoteDevelopmentEnvironmentBrowserSecret, RemoteDevelopmentEnvironmentBrowserSecretRedirectingError } from "@/app/remote-development-environment-browser-secret-client"; -import { DesignButton, DesignDialog, DesignDialogClose } from "@/components/design-components"; -import { useDashboardInternalUser } from "@/lib/dashboard-user"; -import { getPublicEnvVar } from "@/lib/env"; -import { ArrowsClockwise, GitBranch, GitCommit } from "@phosphor-icons/react"; -import type { OAuthConnection, PushedConfigSource, StackAdminApp } from "@hexclave/next"; -import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema"; -import type { HexclaveAdminInterface } from "@hexclave/shared/dist/interface/admin-interface"; -import { HexclaveAssertionError, captureError } from "@hexclave/shared/dist/utils/errors"; -import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; -import React, { createContext, Suspense, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react"; -import type { FileDiffProps } from "@pierre/diffs/react"; -import type { FileDiffMetadata } from "@pierre/diffs"; - -import { GITHUB_SCOPE_REQUIREMENTS } from "./github-api"; - -/** - * Reaches the admin app's underlying `HexclaveAdminInterface`, which carries the - * config-agent endpoints (`applyConfigViaAgent`, `cancelConfigAgentRun`, - * `getPushedConfigSource`) we call directly — rather than via generated app - * methods — to keep this feature self-contained. `_interface` is a protected - * member, so we read it reflectively (the same pattern the SDK's own cross-domain - * tests use). Returns `null` if the app doesn't expose one. - * - * NOTE: these methods exist on the type, but the installed `@hexclave/next` build - * could predate them, so callers still runtime-check the specific method before - * use and degrade gracefully ("refresh and try again"). - */ -export function getAdminInterface(adminApp: StackAdminApp | null | undefined): HexclaveAdminInterface | null { - if (adminApp == null) return null; - // `Reflect.get` returns `any`; the typed annotation documents the contract - // without an explicit cast (and without an `instanceof`, which is unreliable - // across package-boundary copies of the class). - const iface: HexclaveAdminInterface | undefined = Reflect.get(adminApp, "_interface"); - return iface ?? null; -} - -type GithubPushedSource = Extract; - -export type ConfigAgentRunStatus = "running" | "awaiting_review" | "success" | "no-change" | "error" | "cancelled"; -export type AgentStage = "initializing_sandbox" | "cloning_repo" | "agent_making_changes" | "awaiting_review"; - -export type GithubPushedSourceWithAgentRun = GithubPushedSource & { - agent_run?: { - status: ConfigAgentRunStatus, - started_at: number, - finished_at?: number, - progress?: string, - sandbox_id?: string, - commit_url?: string, - new_commit_hash?: string, - error?: string, - stage?: AgentStage, - diff?: string, - }, -}; - -function isAgentStage(value: unknown): value is AgentStage { - return value === "initializing_sandbox" || value === "cloning_repo" || value === "agent_making_changes" || value === "awaiting_review"; -} - -export function isGithubPushedSourceWithAgentRun(source: unknown): source is GithubPushedSourceWithAgentRun { - return typeof source === "object" && source != null && "type" in source && source.type === "pushed-from-github"; -} - -function currentEpochMsFromPerformance(): number { - return performance.timeOrigin + performance.now(); -} - -type ConfigUpdateDialogState = { - isOpen: boolean, - adminApp: StackAdminApp | null, - configUpdate: EnvironmentConfigOverrideOverride | null, - resolve: ((result: boolean) => void) | null, - source: PushedConfigSource | null, - isLoadingSource: boolean, -}; - -const ConfigUpdateDialogContext = createContext<{ - showPushableDialog: (adminApp: StackAdminApp, configUpdate: EnvironmentConfigOverrideOverride) => Promise, - // True while THIS tab's push dialog is actively managing a started run, so the - // page-load watcher (ConfigAgentRunWatcher) doesn't also pop its own modal for - // the same run. The watcher owns the modal only for runs this tab didn't start - // (other tabs / reloads). - githubRunActive: boolean, - setGithubRunActive: (active: boolean) => void, -} | null>(null); - -/** Read-only accessor for the watcher (mounted below this provider). */ -export function useGithubRunActive(): boolean { - return useContext(ConfigUpdateDialogContext)?.githubRunActive ?? false; -} - -type StepDef = { key: AgentStage, label: string, subLabel?: string }; -const STAGE_STEPS: StepDef[] = [ - { key: "initializing_sandbox", label: "Initializing sandbox" }, - { key: "cloning_repo", label: "Cloning repo" }, - { key: "agent_making_changes", label: "Agent making changes", subLabel: "Editing config file" }, - { key: "awaiting_review", label: "Ready to review" }, -]; - -function stageIndex(stage: AgentStage | null | undefined): number { - if (stage == null) return -1; - return STAGE_STEPS.findIndex((s) => s.key === stage); -} - -/** - * A compact stage tracker shown while the agent is running. Each step shows - * elapsed seconds and a live activity sub-label for the active step. - */ -export function AgentStageProgress({ - stage, - startedAt, - activity, -}: { - stage: AgentStage | null | undefined, - /** Unix ms timestamp of when the run started (from agent_run.started_at). */ - startedAt: number, - activity?: string | null, -}) { - const [elapsedMs, setElapsedMs] = useState(0); - useEffect(() => { - const performanceStartedAt = performance.now(); - const t = setInterval(() => setElapsedMs(performance.now() - performanceStartedAt), 1000); - return () => clearInterval(t); - }, []); - - const activeIdx = stageIndex(stage); - // Server-side run timestamps are wall-clock epoch values. Once mounted, keep - // the visible elapsed counter on a monotonic clock so local clock jumps don't - // make the progress UI move backwards. - const initialElapsedMs = Math.max(0, currentEpochMsFromPerformance() - startedAt); - const overallElapsed = Math.max(0, Math.floor((initialElapsedMs + elapsedMs) / 1000)); - - return ( -
- {STAGE_STEPS.map((step, idx) => { - const isDone = idx < activeIdx; - const isActive = idx === activeIdx; - const isPending = idx > activeIdx; - - return ( -
- {/* Step indicator */} -
- {isDone ? ( - - - - ) : ( - {idx + 1} - )} -
- - {/* Step label */} -
-
- {step.label} - {isActive && ( - - {overallElapsed}s - - )} -
- {isActive && activity != null && activity.trim().length > 0 && ( -
- - {activity.split("\n").filter((l) => l.trim()).at(-1)} -
- )} -
-
- ); - })} -
- ); -} - -export function ConfigAgentRunProgressContent({ - isCancelling, - stage, - startedAt, - activity, - errorMessage, -}: { - isCancelling: boolean, - stage: AgentStage | null | undefined, - startedAt: number, - activity?: string | null, - errorMessage?: string | null, -}) { - return ( -
- {isCancelling ? ( -

Cancelling the update and stopping the agent…

- ) : ( - - )} - {errorMessage != null && ( -

{errorMessage}

- )} -
- ); -} - -// --------------------------------------------------------------------------- -// Diff viewer -// --------------------------------------------------------------------------- - -/** - * Lazy-loaded diff viewer. We parse the sandbox's full `git diff` into file - * diffs, then render each file with Pierre's React renderer. `PatchDiff` only - * accepts a single-file patch, while the config agent may legitimately edit - * helpers/imported config files too. - */ -export function AgentDiffViewer({ diff }: { diff: string }) { - const [renderer, setRenderer] = useState<{ - FileDiff: React.ComponentType>, - files: FileDiffMetadata[], - } | null>(null); - - useEffect(() => { - const cancelToken = { cancelled: false }; - runAsynchronously(async () => { - try { - const [{ parsePatchFiles }, reactMod] = await Promise.all([ - import("@pierre/diffs"), - import("@pierre/diffs/react"), - ]); - if (cancelToken.cancelled) return; - const files = parsePatchFiles(diff, "config-agent-review", true).flatMap((patch) => patch.files); - if (files.length === 0) return; - setRenderer({ FileDiff: reactMod.FileDiff, files }); - } catch { - // Module failed to load — fall back to raw diff text - } - }); - return () => { - cancelToken.cancelled = true; - }; - }, [diff]); - - if (renderer != null) { - const { FileDiff } = renderer; - return ( -
- {renderer.files.map((fileDiff, index) => ( - - ))} -
- ); - } - - // Fallback: raw monospace diff - return ( -
-      {diff}
-    
- ); -} - -/** - * Provider component that enables the config update dialog functionality. - * Wrap your app or page with this provider to use the `updateConfig` utility. - */ -export function ConfigUpdateDialogProvider({ children }: { children: React.ReactNode }) { - const [dialogState, setDialogState] = useState({ - isOpen: false, - adminApp: null, - configUpdate: null, - resolve: null, - source: null, - isLoadingSource: false, - }); - const [githubRunActive, setGithubRunActive] = useState(false); - - const showPushableDialog = useCallback(async (adminApp: StackAdminApp, configUpdate: EnvironmentConfigOverrideOverride): Promise => { - // Fetch the source first - const project = await adminApp.getProject(); - const source = await project.getPushedConfigSource(); - - // If a config-agent run is already in flight for this project, don't open the - // push dialog at all — the page-load watcher already shows a non-dismissible - // progress modal and the backend would reject a second run anyway. The mapped - // `PushedConfigSource` drops `agent_run`, so read the raw interface source. - if (source.type === "pushed-from-github") { - const iface = getAdminInterface(adminApp); - if (iface != null && typeof iface.getPushedConfigSource === "function") { - let rawSource: unknown = null; - try { - rawSource = await iface.getPushedConfigSource(); - } catch { - // transient — fall through to the normal dialog rather than blocking - } - if (isGithubPushedSourceWithAgentRun(rawSource) && rawSource.agent_run?.status === "running") { - return false; - } - } - } - - let shouldUpdate = true; - if (source.type !== "unlinked") { - shouldUpdate = await new Promise((resolve) => { - setDialogState({ - isOpen: true, - adminApp, - configUpdate, - resolve, - source, - isLoadingSource: false, - }); - }); - } - - if (shouldUpdate) { - await project.updatePushedConfig(configUpdate); - if (!project.isDevelopmentEnvironment) { - await project.resetConfigOverrideKeys("environment", Object.keys(configUpdate)); - } - return true; - } - return false; - }, []); - - const settleDialog = useCallback((result: boolean) => { - // Pull `resolve` out before the state update so we never invoke it from - // inside a setState updater — React strict mode double-invokes updaters, - // which would call `resolve` twice. Promise resolution is idempotent so - // this was harmless in practice, but the pattern is wrong. - const resolve = dialogState.resolve; - setDialogState({ - isOpen: false, - adminApp: null, - configUpdate: null, - resolve: null, - source: null, - isLoadingSource: false, - }); - resolve?.(result); - }, [dialogState.resolve]); - - const projectId = dialogState.adminApp?.projectId; - - // Render the appropriate dialog based on source type - const renderDialog = () => { - if (!dialogState.isOpen || !dialogState.source) { - return null; - } - - switch (dialogState.source.type) { - case "pushed-from-github": { - return ( - - ); - } - - case "pushed-from-unknown": { - return ( - settleDialog(false)} - title="Configuration Managed by CLI" - description="This project's configuration was pushed via the Hexclave CLI." - okButton={{ - label: "Go to Project Settings", - onClick: async () => { - // Navigate to project settings - window.location.href = `/projects/${projectId}/project-settings`; - }, - }} - cancelButton={{ - label: "Cancel", - onClick: async () => { - settleDialog(false); - }, - }} - > -
-

- To make changes, you can either: -

-
    -
  • Push updates through the Hexclave CLI
  • -
  • Unlink the CLI in Project Settings to edit directly on this dashboard
  • -
-
-
- ); - } - - default: { - // This shouldn't happen since unlinked saves directly, but handle it anyway - return null; - } - } - }; - - return ( - - {children} - {renderDialog()} - - ); -} - -function useConfigUpdateDialog() { - const context = useContext(ConfigUpdateDialogContext); - if (!context) { - throw new Error("useConfigUpdateDialog must be used within a ConfigUpdateDialogProvider"); - } - return context; -} - -type GithubPushDialogProps = { - open: boolean, - adminApp: StackAdminApp | null, - source: GithubPushedSource, - configUpdate: EnvironmentConfigOverrideOverride | null, - projectId: string | undefined, - onSettle: (result: boolean) => void, -}; - -/** - * The new GitHub push dialog: shows a staged progress bar while the agent - * runs, then a diff review panel once the agent is done. The user must - * explicitly click "Commit" to push. No auto-commit. - */ - -type ScopeCheck = - | { status: "no-account" } - | { status: "checking" } - | { status: "ok", account: OAuthConnection } - | { status: "missing-scopes" }; - -// "idle": waiting for user to start. -// "running": agent is in flight (non-dismissible; Cancel stops the sandbox). -// "cancelling": user clicked Cancel, waiting for terminal status. -// "awaiting_review": agent done, diff loaded, waiting for user to commit. -// "committing": user clicked Commit, pushing to GitHub. -type DialogPhase = "idle" | "running" | "cancelling" | "awaiting_review" | "committing"; - -function projectSettingsHref(projectId: string | undefined): string { - return `/projects/${projectId}/project-settings`; -} - -/** - * Outer shell: renders the DesignDialog synchronously; the Suspense-suspending - * body (scope check) is isolated inside. - */ -function GithubPushDialog({ open, adminApp, source, configUpdate, projectId, onSettle }: GithubPushDialogProps) { - const [scopeStatus, setScopeStatus] = useState("checking"); - const [phase, setPhase] = useState("idle"); - const [stage, setStage] = useState(null); - const [startedAt, setStartedAt] = useState(0); - const [activity, setActivity] = useState(null); - const [diff, setDiff] = useState(null); - const [commitMessage, setCommitMessage] = useState(""); - const [errorMessage, setErrorMessage] = useState(null); - - // Expose imperative handles from the body (which can suspend) to the outer shell. - const handlersRef = useRef<{ - push: () => Promise, - connect: () => Promise, - cancel: () => Promise, - commit: () => Promise, - } | null>(null); - - const dialogContext = useContext(ConfigUpdateDialogContext); - const isNonDismissible = phase === "running" || phase === "cancelling" || phase === "committing"; - - const description = (() => { - switch (phase) { - case "idle": { - switch (scopeStatus) { - case "no-account": { return "Connect a GitHub account to push configuration changes to this repository."; } - case "checking": { return "Checking GitHub permissions…"; } - case "ok": { return `This will apply your change to ${source.owner}/${source.repo}@${source.branch}.`; } - case "missing-scopes": { return `Your linked GitHub account is missing the "repo" and "workflow" permissions. Reconnect to grant them.`; } - } - break; - } - case "running": - case "cancelling": { - return `Applying your change in a sandbox — ${source.owner}/${source.repo}@${source.branch}`; - } - case "awaiting_review": { - return `Review the changes before committing to ${source.branch}.`; - } - case "committing": { - return `Pushing to ${source.owner}/${source.repo}@${source.branch}…`; - } - } - })(); - - // Footer buttons - const footer = (() => { - if (phase === "running") { - return ( -
- { await handlersRef.current?.cancel(); }} - > - Cancel - -
- ); - } - if (phase === "cancelling") { - return ( - - Cancelling… - - ); - } - if (phase === "awaiting_review") { - return ( -
- { await handlersRef.current?.cancel(); }} - > - Discard - -
-
- - setCommitMessage(e.target.value)} - /> - { await handlersRef.current?.commit(); }} - > - - Commit - -
-
- ); - } - if (phase === "committing") { - return ( -
- - Committing… - -
- ); - } - // idle - return ( -
- - { onSettle(false); }}> - Cancel - - - {scopeStatus === "no-account" || scopeStatus === "missing-scopes" ? ( - { await handlersRef.current?.connect(); }}> - {scopeStatus === "no-account" ? "Connect with GitHub" : "Reconnect with GitHub"} - - ) : ( - { await handlersRef.current?.push(); }} - disabled={scopeStatus === "checking"} - loading={scopeStatus === "checking"} - > - - Start update - - )} -
- ); - })(); - - // Dialog size grows when showing the diff - const dialogSize = phase === "awaiting_review" ? "3xl" : "lg"; - - return ( - { - if (o || isNonDismissible) return; - onSettle(false); - }} - size={dialogSize} - icon={GitBranch} - title="Push configuration to GitHub" - description={description} - hideTopCloseButton={isNonDismissible} - footer={footer} - contentProps={{ onPointerDownOutside: isNonDismissible ? (e) => e.preventDefault() : undefined, onEscapeKeyDown: isNonDismissible ? (e) => e.preventDefault() : undefined }} - > - Loading…
}> - - - - ); -} - -type GithubPushBodyProps = { - adminApp: StackAdminApp | null, - source: GithubPushedSource, - configUpdate: EnvironmentConfigOverrideOverride | null, - projectId: string | undefined, - onSettle: (result: boolean) => void, - phase: DialogPhase, - stage: AgentStage | null, - startedAt: number, - activity: string | null, - diff: string | null, - commitMessage: string, - errorMessage: string | null, - onScopeStatusChange: (s: ScopeCheck["status"]) => void, - onPhaseChange: (p: DialogPhase) => void, - onStageChange: (s: AgentStage | null) => void, - onStartedAtChange: (ms: number) => void, - onActivityChange: (a: string | null) => void, - onDiffChange: (d: string | null) => void, - onErrorChange: (e: string | null) => void, - handlersRef: React.MutableRefObject<{ - push: () => Promise, - connect: () => Promise, - cancel: () => Promise, - commit: () => Promise, - } | null>, - dialogContext: { setGithubRunActive: (v: boolean) => void } | null, -}; - -function GithubPushBody({ - adminApp, - source, - configUpdate, - projectId, - onSettle, - phase, - stage, - startedAt, - activity, - diff, - commitMessage, - errorMessage, - onScopeStatusChange, - onPhaseChange, - onStageChange, - onStartedAtChange, - onActivityChange, - onDiffChange, - onErrorChange, - handlersRef, - dialogContext, -}: GithubPushBodyProps) { - const user = useDashboardInternalUser(); - const githubAccounts = user.useConnectedAccounts().filter((account) => account.provider === "github"); - const githubAccountsKey = githubAccounts.map((a) => a.providerAccountId).join("|"); - - const [scopeCheck, setScopeCheck] = useState( - githubAccounts.length === 0 ? { status: "no-account" } : { status: "checking" }, - ); - - const placeholderCommitMessage = "chore(hexclave): update config from dashboard"; - - useLayoutEffect(() => { - onScopeStatusChange(scopeCheck.status); - }, [scopeCheck.status, onScopeStatusChange]); - - useEffect(() => { - if (githubAccounts.length === 0) { - setScopeCheck({ status: "no-account" }); - return; - } - const cancelToken = { cancelled: false }; - setScopeCheck({ status: "checking" }); - runAsynchronously(async () => { - for (const account of githubAccounts) { - let tokenResult; - try { - tokenResult = await account.getAccessToken({ scopes: GITHUB_SCOPE_REQUIREMENTS }); - } catch { - continue; - } - if (cancelToken.cancelled) return; - if (tokenResult.status === "ok") { - setScopeCheck({ status: "ok", account }); - return; - } - } - if (!cancelToken.cancelled) setScopeCheck({ status: "missing-scopes" }); - }); - return () => { - cancelToken.cancelled = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- githubAccountsKey - }, [githubAccountsKey]); - - const handlePush = useCallback(async () => { - if (configUpdate == null) { - onErrorChange("No configuration changes to push."); - return; - } - if (scopeCheck.status !== "ok") { - onErrorChange("Connect a GitHub account with the required scopes before pushing changes."); - return; - } - const adminInterface = getAdminInterface(adminApp); - if (adminInterface == null || typeof adminInterface.applyConfigViaAgent !== "function") { - onErrorChange("This dashboard build can't push config to GitHub. Please refresh and try again."); - return; - } - - onErrorChange(null); - try { - const tokenResult = await scopeCheck.account.getAccessToken({ scopes: GITHUB_SCOPE_REQUIREMENTS }); - if (tokenResult.status !== "ok") { - onErrorChange("Could not get a GitHub token with the required permissions. Reconnect your GitHub account and try again."); - return; - } - - const start = await adminInterface.applyConfigViaAgent({ - configUpdate, - // Pass a placeholder; the real commit message is gathered at review time. - commitMessage: placeholderCommitMessage, - githubAccessToken: tokenResult.data.accessToken, - }); - if (start.status === "already-running") { - onErrorChange("Another configuration update is already running for this project. Wait for it to finish, then try again."); - return; - } - - const runStartedAtWallMs = currentEpochMsFromPerformance(); - const runStartedAtMonotonicMs = performance.now(); - onStartedAtChange(runStartedAtWallMs); - dialogContext?.setGithubRunActive(true); - onPhaseChange("running"); - onActivityChange(null); - onStageChange("initializing_sandbox"); - - // Poll until the run transitions out of "running" (either to - // "awaiting_review", a terminal status, or times out). - const deadline = performance.now() + 8 * 60_000; - while (performance.now() < deadline) { - await new Promise((r) => setTimeout(r, 3000)); - let latest: unknown; - try { - latest = await adminInterface.getPushedConfigSource(); - } catch { - continue; - } - const run = isGithubPushedSourceWithAgentRun(latest) ? latest.agent_run : null; - // Ignore stale runs from before this one started. - if (run == null || (typeof run.started_at === "number" && run.started_at < runStartedAtWallMs - 5000)) continue; - - if (run.status === "running") { - if (typeof run.progress === "string") onActivityChange(run.progress); - if (isAgentStage(run.stage)) onStageChange(run.stage); - continue; - } - - // Non-running status: transition. - dialogContext?.setGithubRunActive(false); - - if (run.status === "awaiting_review") { - onPhaseChange("awaiting_review"); - onStageChange("awaiting_review"); - if (typeof run.diff === "string") onDiffChange(run.diff); - return; - } - if (run.status === "error") { - onPhaseChange("idle"); - onStageChange(null); - onErrorChange("The config agent failed to apply your change."); - return; - } - if (run.status === "cancelled") { - onPhaseChange("idle"); - onStageChange(null); - onSettle(false); - return; - } - if (run.status === "no-change") { - onPhaseChange("idle"); - onStageChange(null); - onErrorChange("The config agent finished without producing a diff. No commit was created; try the update again."); - return; - } - // success is only expected from older auto-commit flows or a race with - // a completed commit. Settle so the dashboard can refresh its local state. - onPhaseChange("idle"); - onStageChange(null); - onSettle(true); - return; - } - - dialogContext?.setGithubRunActive(false); - onPhaseChange("idle"); - onStageChange(null); - const elapsedSeconds = Math.floor((performance.now() - runStartedAtMonotonicMs) / 1000); - onErrorChange(`Timed out after ${elapsedSeconds}s waiting for the config agent. Your change may still be in progress; check the linked repository.`); - } catch (error) { - captureError("config-update-github-agent", { - projectId, - owner: source.owner, - repo: source.repo, - branch: source.branch, - configFilePath: source.configFilePath, - cause: error, - }); - dialogContext?.setGithubRunActive(false); - onPhaseChange("idle"); - onStageChange(null); - onErrorChange("Unknown error pushing to GitHub."); - } - }, [adminApp, configUpdate, dialogContext, onActivityChange, onDiffChange, onErrorChange, onPhaseChange, onSettle, onStageChange, onStartedAtChange, projectId, scopeCheck, source]); - - const handleCancel = useCallback(async () => { - const adminInterface = getAdminInterface(adminApp); - if (adminInterface == null || typeof adminInterface.cancelConfigAgentRun !== "function") { - onErrorChange("This dashboard build can't cancel a config run. Please refresh and try again."); - return; - } - onPhaseChange("cancelling"); - try { - await adminInterface.cancelConfigAgentRun(); - } catch (error) { - captureError("config-update-github-cancel", error); - } - // The poll loop in handlePush will observe the terminal `cancelled` status and settle. - }, [adminApp, onErrorChange, onPhaseChange]); - - const handleCommit = useCallback(async () => { - if (scopeCheck.status !== "ok") { - onErrorChange("GitHub account not connected. Please reconnect and try again."); - return; - } - const adminInterface = getAdminInterface(adminApp); - if (adminInterface == null || typeof adminInterface.commitConfigAgentRun !== "function") { - onErrorChange("This dashboard build can't commit. Please refresh and try again."); - return; - } - onPhaseChange("committing"); - onErrorChange(null); - try { - const tokenResult = await scopeCheck.account.getAccessToken({ scopes: GITHUB_SCOPE_REQUIREMENTS }); - if (tokenResult.status !== "ok") { - onPhaseChange("awaiting_review"); - onErrorChange("Could not get a GitHub token. Reconnect your GitHub account and try again."); - return; - } - const result = await adminInterface.commitConfigAgentRun({ - githubAccessToken: tokenResult.data.accessToken, - commitMessage: commitMessage.trim().length > 0 ? commitMessage : undefined, - }); - if (result.status === "sandbox-expired") { - onPhaseChange("idle"); - onErrorChange("The sandbox session expired. Please retry the update."); - return; - } - if (result.status === "not-awaiting-review") { - onPhaseChange("idle"); - onErrorChange("There is no config diff waiting to commit. Start the update again."); - return; - } - // "committing" — poll until done - const adminInterface2 = adminInterface; - const deadline = performance.now() + 2 * 60_000; - while (performance.now() < deadline) { - await new Promise((r) => setTimeout(r, 3000)); - let latest: unknown; - try { - latest = await adminInterface2.getPushedConfigSource(); - } catch { - continue; - } - const run = isGithubPushedSourceWithAgentRun(latest) ? latest.agent_run : null; - if (run == null || run.status === "awaiting_review") continue; - if (run.status === "success") { - onPhaseChange("idle"); - onSettle(true); - return; - } - if (run.status === "error") { - onPhaseChange("awaiting_review"); - onErrorChange("Failed to commit and push the changes. Please try again."); - return; - } - if (run.status === "cancelled") { - onPhaseChange("idle"); - onSettle(false); - return; - } - } - onPhaseChange("awaiting_review"); - onErrorChange("Timed out waiting for the commit. Check the repository for status."); - } catch (error) { - captureError("config-update-github-commit", error); - onPhaseChange("awaiting_review"); - onErrorChange("Unknown error committing to GitHub."); - } - }, [adminApp, commitMessage, onErrorChange, onPhaseChange, onSettle, scopeCheck]); - - const handleConnect = useCallback(async () => { - try { - await user.getOrLinkConnectedAccount("github", { scopes: GITHUB_SCOPE_REQUIREMENTS }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error connecting to GitHub."; - onErrorChange(message); - } - }, [onErrorChange, user]); - - useEffect(() => { - handlersRef.current = { push: handlePush, connect: handleConnect, cancel: handleCancel, commit: handleCommit }; - }, [handlersRef, handlePush, handleConnect, handleCancel, handleCommit]); - - return ( -
- {/* Stage progress bar — shown while running */} - {(phase === "running" || phase === "cancelling") && ( - - )} - - {/* Diff viewer — shown when awaiting review */} - {phase === "awaiting_review" && diff != null && diff.trim().length > 0 && ( - - )} - - {/* Error */} - {phase !== "running" && phase !== "cancelling" && errorMessage != null && ( -

{errorMessage}

- )} - - {/* Unlink hint — shown in idle state */} - {phase === "idle" && ( -

- If your configuration is no longer on GitHub, you can{" "} - - unlink it in Project Settings - . -

- )} -
- ); -} - -async function updateRemoteDevelopmentEnvironmentConfigFile( - adminApp: StackAdminApp, - configUpdate: EnvironmentConfigOverrideOverride, -): Promise<"updated" | "redirecting"> { - try { - const response = await fetchWithRemoteDevelopmentEnvironmentBrowserSecret("/api/remote-development-environment/config/apply-update", { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - project_id: adminApp.projectId, - config_update: configUpdate, - wait_for_sync: true, - }), - signal: AbortSignal.timeout(130_000), - }); - if (!response.ok) { - throw new Error(`Failed to update local development environment config (${response.status}): ${await response.text()}`); - } - return "updated"; - } catch (error) { - if (error instanceof RemoteDevelopmentEnvironmentBrowserSecretRedirectingError) { - return "redirecting"; - } - throw error; - } -} - -/** - * Options for the updateConfig utility function. - */ -export type UpdateConfigOptions = { - /** - * The admin app instance to use for updating the config. - */ - adminApp: StackAdminApp, - /** - * The configuration update to apply. - */ - configUpdate: EnvironmentConfigOverrideOverride, - /** - * Whether this configuration can be pushed (i.e., it's a branch-level config). - * If true, shows a confirmation dialog before applying (based on source type). - * If false, the update is applied directly to the environment config. - */ - pushable: boolean, -}; - -/** - * Hook that returns a function to update config with optional confirmation dialog. - * - * For pushable configs, the behavior depends on the branch config source: - * - `unlinked`: Saves directly without a dialog - * - `pushed-from-github`: Shows a dialog to push changes to GitHub - * - `pushed-from-unknown`: Shows a dialog explaining CLI management - * - * For non-pushable configs, updates the environment config directly. - * - * @example - * ```tsx - * const updateConfig = useUpdateConfig(); - * - * // Update environment config (no dialog) - * await updateConfig({ - * adminApp, - * configUpdate: { 'auth.oauth.providers.google.clientSecret': 'secret' }, - * pushable: false, - * }); - * - * // Update pushed config (dialog depends on source) - * await updateConfig({ - * adminApp, - * configUpdate: { 'teams.allowClientTeamCreation': true }, - * pushable: true, - * }); - * ``` - */ -export function useUpdateConfig() { - const { showPushableDialog } = useConfigUpdateDialog(); - - return useCallback(async (options: UpdateConfigOptions): Promise => { - const { adminApp, configUpdate, pushable } = options; - - if (getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true") { - if (!pushable) { - throw new HexclaveAssertionError("These settings are read-only in a development environment. Update them in your production deployment instead."); - } - - if (await updateRemoteDevelopmentEnvironmentConfigFile(adminApp, configUpdate) === "redirecting") { - return false; - } - return true; - } - - if (pushable) { - // Show dialog (or save directly if unlinked) based on source type - return await showPushableDialog(adminApp, configUpdate); - } else { - // Update environment config directly - const project = await adminApp.getProject(); - if (project.isDevelopmentEnvironment) { - alert("These settings are read-only in a development environment. Update them in your production deployment instead."); - return false; - } - // eslint-disable-next-line no-restricted-syntax -- this is the hook implementation itself - await project.updateConfig(configUpdate); - return true; - } - }, [showPushableDialog]); -} - -/** - * Props for the ConfigUpdateButton component. - */ -export type ConfigUpdateButtonProps = { - /** - * The admin app instance to use for updating the config. - */ - adminApp: StackAdminApp, - /** - * An async function that returns the configuration update to apply. - * Called when the button is clicked. - */ - configUpdate: () => Promise, - /** - * Whether this configuration can be pushed (i.e., it's a branch-level config). - * If true, shows a confirmation dialog before applying. - * If false, the update is applied directly to the environment config. - */ - pushable: boolean, - /** - * Optional callback called after the config is successfully updated. - */ - onUpdated?: () => void | Promise, - /** - * The type of action this button represents. - * - "save": Shows "Save changes" (for updating existing config) - * - "create": Shows "Create" (for creating new config entries) - */ - actionType: "save" | "create", - /** - * Whether the button should be disabled. - */ - disabled?: boolean, - /** - * Additional class names for the button. - */ - className?: string, - /** - * Button variant. - */ - variant?: "default" | "secondary" | "outline" | "ghost" | "destructive" | "link", - /** - * Button size. - */ - size?: "default" | "sm" | "lg" | "icon", -}; - -/** - * A button component for saving configuration changes. - * - * Shows "Save changes" or "Create" based on the `actionType` prop and handles - * the configuration update flow, including the confirmation dialog for pushable configs. - * - * @example - * ```tsx - * ({ - * 'teams.allowClientTeamCreation': true, - * })} - * pushable={true} - * onUpdated={() => toast({ title: "Settings saved" })} - * actionType="save" - * /> - * ``` - */ -export function ConfigUpdateButton({ - adminApp, - configUpdate, - pushable, - onUpdated, - actionType, - disabled, - className, - variant = "default", - size = "default", -}: ConfigUpdateButtonProps) { - const updateConfig = useUpdateConfig(); - - const handleClick = async () => { - const configUpdateValue = await configUpdate(); - const success = await updateConfig({ - adminApp, - configUpdate: configUpdateValue, - pushable, - }); - if (success) { - await onUpdated?.(); - } - }; - - const label = actionType === "save" ? "Save changes" : "Create"; - - // Import Button locally to avoid circular dependency issues - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { Button } = require("@/components/ui") as typeof import("@/components/ui"); - - return ( - - ); -} - -/** - * Props for components that use the unsaved changes pattern. - */ -export type UnsavedChangesFooterProps = { - /** - * Whether there are unsaved changes. - */ - hasChanges: boolean, - /** - * The admin app instance. - */ - adminApp: StackAdminApp, - /** - * An async function that returns the configuration update to apply. - */ - configUpdate: () => Promise, - /** - * Whether this configuration can be pushed. - */ - pushable: boolean, - /** - * Callback to discard changes (reset to original values). - */ - onDiscard: () => void, - /** - * Optional callback called after the config is successfully updated. - */ - onSaved?: () => void | Promise, - /** - * The action type. - */ - actionType?: "save" | "create", -}; - -/** - * A footer component that shows Save/Discard buttons when there are unsaved changes. - * - * Use this at the bottom of a card or section to provide a consistent pattern - * for saving configuration changes. - * - * @example - * ```tsx - * const [localValue, setLocalValue] = useState(config.someValue); - * const hasChanges = localValue !== config.someValue; - * - * ({ 'some.config.key': localValue })} - * pushable={true} - * onDiscard={() => setLocalValue(config.someValue)} - * onSaved={() => toast({ title: "Settings saved" })} - * /> - * ``` - */ -export function UnsavedChangesFooter({ - hasChanges, - adminApp, - configUpdate, - pushable, - onDiscard, - onSaved, - actionType = "save", -}: UnsavedChangesFooterProps) { - // Import Button locally to avoid circular dependency issues - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { Button } = require("@/components/ui") as typeof import("@/components/ui"); - - if (!hasChanges) { - return null; - } - - return ( -
- - -
- ); -} diff --git a/packages/shared/src/config-eval.ts b/packages/shared/src/config-eval.ts index b72a5ca7d..99009fa2b 100644 --- a/packages/shared/src/config-eval.ts +++ b/packages/shared/src/config-eval.ts @@ -118,3 +118,14 @@ import.meta.vitest?.test("evalConfigFileContent rejects content without config e import.meta.vitest?.test("evalConfigFileContent rejects arbitrary string config values", ({ expect }) => { expect(() => evalConfigFileContent('export const config = "arbitrary-string";', "stack.config.ts")).toThrow(/must be "show-onboarding"/); }); + +import.meta.vitest?.test("evalConfigFileContent rejects unresolvable config factories", ({ expect }) => { + expect(() => evalConfigFileContent("export const config = makeConfig();", "stack.config.ts")).toThrow(); +}); + +import.meta.vitest?.test("evalConfigFileContent rejects missing config import targets", ({ expect }) => { + expect(() => evalConfigFileContent(` + import missingConfigPart from "./missing-config-part"; + export const config = { auth: missingConfigPart }; + `, "/tmp/hexclave-missing-import-config.ts")).toThrow(); +}); diff --git a/packages/shared/src/schema-fields.ts b/packages/shared/src/schema-fields.ts index 2d90735d9..fe2628cb5 100644 --- a/packages/shared/src/schema-fields.ts +++ b/packages/shared/src/schema-fields.ts @@ -884,6 +884,14 @@ export const oauthProviderAllowConnectedAccountsSchema = yupBoolean().meta({ ope export const oauthProviderAccountIdSchema = yupString().meta({ openapiField: { description: 'Account ID of the OAuth provider. This uniquely identifies the account on the provider side.', exampleValue: 'google-account-id-12345' } }); export const oauthProviderProviderConfigIdSchema = yupString().meta({ openapiField: { description: 'Provider config ID of the OAuth provider. This uniquely identifies the provider config on config.json file', exampleValue: 'google' } }); +export const configAgentSafeErrorMessages = [ + "The config agent failed to apply the change.", + "Sandbox session expired. Please retry the update.", + "Failed to commit and push the config changes.", +] as const; +export type ConfigAgentSafeErrorMessage = typeof configAgentSafeErrorMessages[number]; +export const configAgentSafeErrorMessageSchema = yupString().oneOf(configAgentSafeErrorMessages); + // Headers export const basicAuthorizationHeaderSchema = yupString().test('is-basic-authorization-header', 'Authorization header must be in the format "Basic "', (value) => { if (!value) return true; @@ -948,7 +956,7 @@ export const branchConfigSourceSchema = yupUnion( started_at: yupNumber().defined(), finished_at: yupNumber().optional(), commit_url: urlSchema.optional(), - error: yupString().optional(), + error: configAgentSafeErrorMessageSchema.optional(), // Vercel Sandbox id of the in-flight run, recorded while `status === "running"` or `"awaiting_review"` // so a cancel request (a different invocation) can hard-stop the sandbox. // Cleared/ignored once the run reaches a terminal status. @@ -972,3 +980,32 @@ export const branchConfigSourceSchema = yupUnion( type: yupString().oneOf(["unlinked"]).defined(), }), ); + +import.meta.vitest?.test("branchConfigSourceSchema only allows safe config-agent error messages", async ({ expect }) => { + const source = { + type: "pushed-from-github", + owner: "hexclave", + repo: "hexclave", + branch: "dev", + commit_hash: "abc123", + config_file_path: "hexclave.config.ts", + }; + + expect(await branchConfigSourceSchema.isValid({ + ...source, + agent_run: { + status: "error", + started_at: 1, + error: "The config agent failed to apply the change.", + }, + })).toMatchInlineSnapshot(`true`); + + expect(await branchConfigSourceSchema.isValid({ + ...source, + agent_run: { + status: "error", + started_at: 1, + error: "ENOENT: tokenized internal failure", + }, + })).toMatchInlineSnapshot(`false`); +});