Config errors are now more obvious

This commit is contained in:
Konstantin Wohlwend 2026-06-22 23:42:14 -07:00
parent 5b9fd1695f
commit 0908170e52
25 changed files with 1489 additions and 48 deletions

View File

@ -0,0 +1,2 @@
ALTER TABLE "BranchConfigOverride"
ADD COLUMN "pushedConfigError" JSONB;

View File

@ -135,6 +135,7 @@ model BranchConfigOverride {
config Json
source Json?
pushedConfigError Json?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)

View File

@ -0,0 +1,79 @@
import { clearBranchConfigPushedError, setBranchConfigPushedError } from "@/lib/config";
import { isDevelopmentEnvironmentProject } from "@/lib/development-environment";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
import { StatusError } from "@hexclave/shared/dist/utils/errors";
const pushedConfigErrorMessageSchema = yupString().max(1_000).defined();
async function assertRdeProject(projectId: string): Promise<void> {
if (!(await isDevelopmentEnvironmentProject(projectId))) {
throw new StatusError(StatusError.Forbidden, "Pushed config errors can only be set for development-environment projects.");
}
}
export const PUT = createSmartRouteHandler({
metadata: {
hidden: true,
summary: "Set pushed config error",
description: "Attach the latest pushed config error to the current development-environment branch config override.",
tags: ["Config"],
},
request: yupObject({
auth: yupObject({
type: serverOrHigherAuthTypeSchema,
tenancy: adaptSchema,
}).defined(),
body: yupObject({
error_message: pushedConfigErrorMessageSchema,
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["success"]).defined(),
}),
handler: async (req) => {
await assertRdeProject(req.auth.tenancy.project.id);
await setBranchConfigPushedError({
projectId: req.auth.tenancy.project.id,
branchId: req.auth.tenancy.branchId,
error: {
message: req.body.error_message,
},
});
return {
statusCode: 200,
bodyType: "success",
};
},
});
export const DELETE = createSmartRouteHandler({
metadata: {
hidden: true,
summary: "Clear pushed config error",
description: "Clear the latest pushed config error on the current development-environment branch config override.",
tags: ["Config"],
},
request: yupObject({
auth: yupObject({
type: serverOrHigherAuthTypeSchema,
tenancy: adaptSchema,
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["success"]).defined(),
}),
handler: async (req) => {
await assertRdeProject(req.auth.tenancy.project.id);
await clearBranchConfigPushedError({
projectId: req.auth.tenancy.project.id,
branchId: req.auth.tenancy.branchId,
});
return {
statusCode: 200,
bodyType: "success",
};
},
});

View File

@ -1,4 +1,4 @@
import { renderedOrganizationConfigToProjectCrud } from "@/lib/config";
import { getBranchConfigPushedError, getDevelopmentEnvironmentConfigWarnings, renderedOrganizationConfigToProjectCrud } from "@/lib/config";
import { createOrUpdateProjectWithLegacyConfig } from "@/lib/projects";
import { getTenancy } from "@/lib/tenancies";
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
@ -26,12 +26,30 @@ export const projectsCrudHandlers = createLazyProxy(() => createCrudHandlers(pro
const tenancy = await getTenancy(auth.tenancy.id) ?? throwErr("Tenancy not found after project update?"); // since we updated the project, we need to re-fetch the new tenancy config
return {
...project,
pushed_config_error: await getBranchConfigPushedError({
projectId: auth.project.id,
branchId: auth.tenancy.branchId,
}),
config_warnings: await getDevelopmentEnvironmentConfigWarnings({
projectId: auth.project.id,
branchId: auth.tenancy.branchId,
organizationId: auth.tenancy.organization?.id ?? null,
}),
config: renderedOrganizationConfigToProjectCrud(tenancy.config),
};
},
onRead: async ({ auth }) => {
return {
...auth.project,
pushed_config_error: await getBranchConfigPushedError({
projectId: auth.project.id,
branchId: auth.tenancy.branchId,
}),
config_warnings: await getDevelopmentEnvironmentConfigWarnings({
projectId: auth.project.id,
branchId: auth.tenancy.branchId,
organizationId: auth.tenancy.organization?.id ?? null,
}),
config: renderedOrganizationConfigToProjectCrud(auth.tenancy.config),
};
},

View File

@ -1,4 +1,4 @@
import { renderedOrganizationConfigToProjectCrud } from "@/lib/config";
import { getBranchConfigPushedError, getDevelopmentEnvironmentConfigWarnings, renderedOrganizationConfigToProjectCrud } from "@/lib/config";
import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { clientProjectsCrud } from "@hexclave/shared/dist/interface/crud/projects";
import { yupObject } from "@hexclave/shared/dist/schema-fields";
@ -9,6 +9,15 @@ export const clientProjectsCrudHandlers = createLazyProxy(() => createCrudHandle
onRead: async ({ auth }) => {
return {
...auth.project,
pushed_config_error: await getBranchConfigPushedError({
projectId: auth.project.id,
branchId: auth.tenancy.branchId,
}),
config_warnings: await getDevelopmentEnvironmentConfigWarnings({
projectId: auth.project.id,
branchId: auth.tenancy.branchId,
organizationId: auth.tenancy.organization?.id ?? null,
}),
config: renderedOrganizationConfigToProjectCrud(auth.tenancy.config),
};
},

View File

@ -11,11 +11,17 @@ import { Result } from "@hexclave/shared/dist/utils/results";
import { deindent, stringCompare } from "@hexclave/shared/dist/utils/strings";
import * as yup from "yup";
import { RawQuery, globalPrismaClient, rawQuery } from "../prisma-client";
import { DEVELOPMENT_ENVIRONMENT_ENV_CONFIG_BLOCKED_MESSAGE, getEnvironmentConfigWriteBlockReason } from "./development-environment";
import { DEVELOPMENT_ENVIRONMENT_ENV_CONFIG_BLOCKED_MESSAGE, getEnvironmentConfigWriteBlockReason, isDevelopmentEnvironmentProject } from "./development-environment";
import { getLocalEmulatorFilePath, isLocalEmulatorEnabled, isLocalEmulatorProject, readConfigFromFile, writeConfigToFile } from "./local-emulator";
import { listPermissionDefinitionsFromConfig } from "./permissions";
type BranchConfigSourceApi = yup.InferType<typeof branchConfigSourceSchema>;
export type BranchConfigPushedError = {
message: string,
};
export type ConfigWarning = {
message: string,
};
type ProjectOptions = { projectId: string };
type BranchOptions = ProjectOptions & { branchId: string };
@ -316,6 +322,88 @@ export async function setBranchConfigOverride(options: {
config: newConfig,
},
});
await clearBranchConfigPushedError({
projectId: options.projectId,
branchId: options.branchId,
});
}
function isBranchConfigPushedError(value: unknown): value is BranchConfigPushedError {
return (
value != null &&
typeof value === "object" &&
!Array.isArray(value) &&
"message" in value &&
typeof value.message === "string"
);
}
export async function getBranchConfigPushedError(options: {
projectId: string,
branchId: string,
}): Promise<BranchConfigPushedError | null> {
const rows = await globalPrismaClient.$replica().$queryRaw<Array<{ pushedConfigError: unknown }>>(Prisma.sql`
SELECT "pushedConfigError"
FROM "BranchConfigOverride"
WHERE "projectId" = ${options.projectId}
AND "branchId" = ${options.branchId}
LIMIT 1
`);
const error = rows[0]?.pushedConfigError;
return isBranchConfigPushedError(error) ? error : null;
}
export async function setBranchConfigPushedError(options: {
projectId: string,
branchId: string,
error: BranchConfigPushedError,
}): Promise<void> {
const errorJson = JSON.stringify(options.error);
await globalPrismaClient.$executeRaw(Prisma.sql`
INSERT INTO "BranchConfigOverride" ("projectId", "branchId", "config", "pushedConfigError", "updatedAt")
VALUES (${options.projectId}, ${options.branchId}, '{}'::jsonb, ${errorJson}::jsonb, NOW())
ON CONFLICT ("projectId", "branchId") DO UPDATE SET
"pushedConfigError" = EXCLUDED."pushedConfigError",
"updatedAt" = NOW()
`);
}
export async function clearBranchConfigPushedError(options: {
projectId: string,
branchId: string,
}): Promise<void> {
await globalPrismaClient.$executeRaw(Prisma.sql`
UPDATE "BranchConfigOverride"
SET "pushedConfigError" = NULL,
"updatedAt" = NOW()
WHERE "projectId" = ${options.projectId}
AND "branchId" = ${options.branchId}
AND "pushedConfigError" IS NOT NULL
`);
}
export async function getDevelopmentEnvironmentConfigWarnings(options: {
projectId: string,
branchId: string,
organizationId: string | null,
}): Promise<ConfigWarning[]> {
if (!(await isDevelopmentEnvironmentProject(options.projectId))) {
return [];
}
const incompleteConfig = await rawQuery(globalPrismaClient, getIncompleteOrganizationConfigQuery({
projectId: options.projectId,
branchId: options.branchId,
organizationId: options.organizationId,
}));
const warnings = await getIncompleteConfigWarnings(organizationConfigSchema, incompleteConfig);
if (warnings.status === "ok") {
return [];
}
return warnings.error
.split("\n")
.filter((message) => message.length > 0)
.map((message) => ({ message }));
}
/**

View File

@ -76,6 +76,8 @@ export function getProjectQuery(projectId: string): RawQuery<Promise<Omit<Projec
owner_team_id: row.ownerTeamId,
onboarding_status: row.onboardingStatus,
onboarding_state: onboardingState ?? undefined,
pushed_config_error: null,
config_warnings: [],
};
},
};

View File

@ -9,7 +9,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ ses
if (securityResponse != null) return securityResponse;
const { sessionId } = await params;
if (!heartbeatRemoteDevelopmentEnvironmentSession(sessionId)) {
const heartbeat = heartbeatRemoteDevelopmentEnvironmentSession(sessionId);
if (heartbeat == null) {
return NextResponse.json({ error: "Unknown remote development environment session." }, { status: 404 });
}
const confirmationCode = getPendingRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode();
@ -17,5 +18,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ ses
ok: true,
browser_secret_confirmation_code: confirmationCode?.code,
browser_secret_confirmation_code_expires_at_millis: confirmationCode?.expiresAtMillis,
config_sync_events: heartbeat.configSyncEvents.map((event) => event.status === "success"
? {
config_file_path: event.configFilePath,
status: "success",
created_at_millis: event.createdAtMillis,
}
: {
config_file_path: event.configFilePath,
status: "error",
error_message: event.errorMessage,
created_at_millis: event.createdAtMillis,
}),
});
}

View File

@ -211,7 +211,7 @@ describe("remote development environment config file", () => {
const { readConfigFile } = await import("./config-file");
await expect(readConfigFile(configPath)).rejects.toThrow(
`Failed to load config file ${configPath}. If your config imports a value (e.g. defineHexclaveConfig) from a framework package such as "@hexclave/next", import it from that package's lightweight "/config" entrypoint instead`
`Failed to load config file ${configPath}.`
);
});

View File

@ -0,0 +1,45 @@
import { errorToNiceString } from "@hexclave/shared/dist/utils/errors";
const CONFIG_SYNC_CLI_ERROR_MAX_LENGTH = 500;
function stripAnsiEscapeSequences(value: string): string {
return value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
}
function normalizeSingleLineErrorMessage(value: string): string {
return stripAnsiEscapeSequences(value)
.replace(/\s+/g, " ")
.trim()
.replace(/^Error:\s+/, "");
}
function truncateConfigSyncCliErrorMessage(value: string): string {
if (value.length <= CONFIG_SYNC_CLI_ERROR_MAX_LENGTH) {
return value;
}
return `${value.slice(0, CONFIG_SYNC_CLI_ERROR_MAX_LENGTH - 1)}`;
}
function extractConfigSyncErrorSummary(error: unknown): string {
const rawMessage = error instanceof Error && error.message.length > 0
? error.message
: errorToNiceString(error);
const messageBeforeStack = rawMessage
.split(/\n\s*Stack:/)[0]
.split(/\n\s*Cause:/)[0];
const failedRequestMatch = /Failed to send request to \S+:\s*(\d{3})\s+([\s\S]+)/.exec(messageBeforeStack);
const summary = failedRequestMatch == null
? messageBeforeStack
: failedRequestMatch[2];
const normalizedSummary = normalizeSingleLineErrorMessage(summary);
if (normalizedSummary.length === 0) {
return "The config file could not be synced.";
}
return truncateConfigSyncCliErrorMessage(normalizedSummary);
}
export function formatConfigSyncErrorForCli(configFilePath: string, error: unknown): string {
const summary = extractConfigSyncErrorSummary(error);
return `Config file error: ${summary} Please check your config file at ${configFilePath}.`;
}

View File

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { formatConfigSyncErrorForCli } from "./config-sync-error-format";
describe("formatConfigSyncErrorForCli", () => {
it("extracts the backend config validation message without stack details", () => {
const message = formatConfigSyncErrorForCli(
"/Users/example/app/hexclave.config.ts",
new Error(`Failed to send request to http://127.0.0.1:9202/api/v1/internal/config/override/branch: 400 The key "abcd" is not valid (nested object not found in schema: "abcd").
Stack:
at sendClientRequestInner (/internal/chunk.js:1:1)
Cause:
Response { "status": 400, "headers": Headers {} }`),
);
expect(message).toBe(`Config file error: The key "abcd" is not valid (nested object not found in schema: "abcd"). Please check your config file at /Users/example/app/hexclave.config.ts.`);
});
it("keeps generic sync failures concise and actionable", () => {
const message = formatConfigSyncErrorForCli(
"/Users/example/app/hexclave.config.ts",
new Error(`Unexpected token '}' in JSON
Stack:
at readConfigFile (/internal/chunk.js:1:1)`),
);
expect(message).toBe("Config file error: Unexpected token '}' in JSON Please check your config file at /Users/example/app/hexclave.config.ts.");
expect(message).not.toContain("Stack:");
});
});

View File

@ -12,6 +12,7 @@ import { randomUUID } from "crypto";
import { watch, type FSWatcher } from "fs";
import { basename, dirname } from "path";
import { peekRemoteDevelopmentEnvironmentBrowserSecretConfirmationCodeForCli } from "./browser-secret";
import { formatConfigSyncErrorForCli } from "./config-sync-error-format";
import {
ensureConfigFileExists,
readConfigFile,
@ -33,6 +34,7 @@ const STARTUP_EMPTY_SESSION_GRACE_MS = 20_000;
const SYNC_DEBOUNCE_MS = 500;
const CONFIG_SYNC_FORMAT_VERSION = 2;
const LOG_PREFIX = "[Stack RDE]";
const CONFIG_SYNC_DUPLICATE_EVENT_SUPPRESSION_MS = 2_000;
export class RemoteDevelopmentEnvironmentApiUnavailableError extends Error {
constructor(apiBaseUrl: string, cause: unknown) {
@ -45,8 +47,34 @@ type ActiveSession = {
configFilePath: string,
lastHeartbeatMs: number,
receivedFirstHeartbeat: boolean,
lastDeliveredConfigSyncEventId: number,
};
type ConfigSyncEventBase = {
id: number,
configFilePath: string,
createdAtMillis: number,
};
type ConfigSyncEvent = ConfigSyncEventBase & ({
status: "success",
} | {
status: "error",
errorMessage: string,
});
type RemoteDevelopmentEnvironmentConfigSyncEventBase = {
configFilePath: string,
createdAtMillis: number,
};
export type RemoteDevelopmentEnvironmentConfigSyncEvent = RemoteDevelopmentEnvironmentConfigSyncEventBase & ({
status: "success",
} | {
status: "error",
errorMessage: string,
});
type RemoteDevelopmentEnvironmentDebugSession = {
sessionId: string,
configFilePath: string,
@ -65,6 +93,13 @@ type RemoteDevelopmentEnvironmentDebugSnapshot = {
watchedConfigFiles: string[],
pendingSyncConfigFiles: string[],
syncErrors: { configFilePath: string, error: string }[],
pendingConfigSyncEvents: {
id: number,
configFilePath: string,
status: "success" | "error",
errorMessage?: string,
createdAgoMs: number,
}[],
synchronouslyUpdatingConfigFiles: string[],
localDashboards: {
port: number,
@ -92,7 +127,11 @@ type RemoteDevelopmentEnvironmentGlobals = {
sessions: Map<string, ActiveSession>,
watchers: Map<string, FSWatcher>,
syncTimers: Map<string, NodeJS.Timeout>,
activeConfigSyncs: Map<string, Promise<void>>,
syncErrors: Map<string, Error>,
configSyncEvents: ConfigSyncEvent[],
lastConfigSyncEventByConfigFile: Map<string, ConfigSyncEvent>,
nextConfigSyncEventId: number,
synchronouslyUpdatingConfigFiles: Set<string>,
shutdownTimerStarted: boolean,
startedAtMs: number,
@ -114,7 +153,11 @@ function getGlobals(): RemoteDevelopmentEnvironmentGlobals {
sessions: new Map(),
watchers: new Map(),
syncTimers: new Map(),
activeConfigSyncs: new Map(),
syncErrors: new Map(),
configSyncEvents: [],
lastConfigSyncEventByConfigFile: new Map(),
nextConfigSyncEventId: 1,
synchronouslyUpdatingConfigFiles: new Set(),
shutdownTimerStarted: false,
startedAtMs: performance.now(),
@ -125,19 +168,89 @@ function getGlobals(): RemoteDevelopmentEnvironmentGlobals {
}
function logRemoteDevelopmentEnvironment(message: string, details?: Record<string, unknown>): void {
const prefix = `[${new Date().toISOString()}] ${LOG_PREFIX}`;
if (details == null) {
console.log(`${LOG_PREFIX} ${message}`);
console.log(`${prefix} ${message}`);
return;
}
console.log(`${LOG_PREFIX} ${message}`, details);
console.log(`${prefix} ${message}`, details);
}
function warnRemoteDevelopmentEnvironment(message: string, details?: Record<string, unknown>): void {
const prefix = `[${new Date().toISOString()}] ${LOG_PREFIX}`;
if (details == null) {
console.warn(`${LOG_PREFIX} ${message}`);
console.warn(`${prefix} ${message}`);
return;
}
console.warn(`${LOG_PREFIX} ${message}`, details);
console.warn(`${prefix} ${message}`, details);
}
function configSyncEventsMatchForCliDeduplication(a: ConfigSyncEvent, b: { status: "success" } | { status: "error", errorMessage: string }): boolean {
if (a.status !== b.status) {
return false;
}
if (a.status === "success") {
return true;
}
if (b.status !== "error") {
return false;
}
return a.errorMessage === b.errorMessage;
}
function recordConfigSyncEvent(configFilePath: string, event: { status: "success" } | { status: "error", errorMessage: string }): void {
const state = getGlobals();
const createdAtMillis = Date.now();
const lastEvent = state.lastConfigSyncEventByConfigFile.get(configFilePath);
const isDuplicate = lastEvent != null &&
configSyncEventsMatchForCliDeduplication(lastEvent, event) &&
createdAtMillis - lastEvent.createdAtMillis < CONFIG_SYNC_DUPLICATE_EVENT_SUPPRESSION_MS;
if (isDuplicate) {
logRemoteDevelopmentEnvironment("Suppressing duplicate config sync CLI notification", {
configFilePath,
status: event.status,
duplicateWindowMs: CONFIG_SYNC_DUPLICATE_EVENT_SUPPRESSION_MS,
});
return;
}
const baseEvent = {
id: state.nextConfigSyncEventId,
configFilePath,
createdAtMillis,
};
const configSyncEvent: ConfigSyncEvent = event.status === "success"
? { ...baseEvent, status: "success" }
: { ...baseEvent, status: "error", errorMessage: event.errorMessage };
state.nextConfigSyncEventId += 1;
state.configSyncEvents.push(configSyncEvent);
state.lastConfigSyncEventByConfigFile.set(configFilePath, configSyncEvent);
if (state.configSyncEvents.length > 100) {
state.configSyncEvents.splice(0, state.configSyncEvents.length - 100);
}
}
function drainConfigSyncEventsForSession(session: ActiveSession): RemoteDevelopmentEnvironmentConfigSyncEvent[] {
const state = getGlobals();
const pendingEvents = state.configSyncEvents.filter((event) => (
event.configFilePath === session.configFilePath &&
event.id > session.lastDeliveredConfigSyncEventId
));
if (pendingEvents.length === 0) {
return [];
}
session.lastDeliveredConfigSyncEventId = pendingEvents[pendingEvents.length - 1].id;
return pendingEvents.map((event) => event.status === "success"
? {
configFilePath: event.configFilePath,
status: "success",
createdAtMillis: event.createdAtMillis,
}
: {
configFilePath: event.configFilePath,
status: "error",
errorMessage: event.errorMessage,
createdAtMillis: event.createdAtMillis,
});
}
function errorLooksLikeApiConnectionFailure(error: unknown): boolean {
@ -401,7 +514,99 @@ async function syncRemoteDevelopmentEnvironmentOnboardingStatus(
return onboardingStatus;
}
async function syncConfigToRemote(configFilePath: string): Promise<ProjectOnboardingStatus | undefined> {
async function updateRemoteDevelopmentEnvironmentPushedConfigError(configFilePath: string, errorMessage: string | null): Promise<void> {
const state = readRemoteDevelopmentEnvironmentState();
const project = state.projectsByConfigPath[configFilePath];
if (project == null || state.anonymousRefreshToken == null) {
warnRemoteDevelopmentEnvironment("Skipping pushed config error update because local state is incomplete", {
configFilePath,
hasProject: project != null,
hasAnonymousRefreshToken: state.anonymousRefreshToken != null,
});
return;
}
const app = createInternalApp(project.apiBaseUrl, state.anonymousRefreshToken);
const user = await app.getUser({ or: "anonymous" });
const ownedProject = (await user.listOwnedProjects()).find((p) => p.id === project.projectId);
if (ownedProject == null) {
warnRemoteDevelopmentEnvironment("Skipping pushed config error update because the project is not owned by the anonymous user", {
projectId: project.projectId,
configFilePath,
});
return;
}
logRemoteDevelopmentEnvironment(errorMessage == null ? "Clearing pushed config error on development-environment project" : "Setting pushed config error on development-environment project", {
projectId: project.projectId,
configFilePath,
});
const response = await getStackAppRequestInternals(ownedProject.app).sendRequest(
"/internal/config/pushed-error",
errorMessage == null
? { method: "DELETE" }
: {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
error_message: errorMessage,
}),
},
"admin",
);
if (!response.ok) {
throw new Error(`Failed to ${errorMessage == null ? "clear" : "set"} development-environment pushed config error (${response.status}): ${await response.text()}`);
}
}
type ConfigSyncResult = {
onboardingStatus: ProjectOnboardingStatus | undefined,
pushedConfig: boolean,
};
async function runConfigSyncSerialized<T>(configFilePath: string, operationName: string, operation: () => Promise<T>): Promise<T> {
const state = getGlobals();
while (true) {
const activeSync = state.activeConfigSyncs.get(configFilePath);
if (activeSync == null) {
break;
}
logRemoteDevelopmentEnvironment("Waiting for active config sync before starting another one", {
configFilePath,
operationName,
});
await activeSync;
}
let releaseActiveSync: () => void = () => {};
const activeSync = new Promise<void>((resolvePromise) => {
releaseActiveSync = resolvePromise;
});
state.activeConfigSyncs.set(configFilePath, activeSync);
try {
logRemoteDevelopmentEnvironment("Starting serialized config sync operation", {
configFilePath,
operationName,
});
return await operation();
} finally {
if (state.activeConfigSyncs.get(configFilePath) === activeSync) {
state.activeConfigSyncs.delete(configFilePath);
}
releaseActiveSync();
logRemoteDevelopmentEnvironment("Finished serialized config sync operation", {
configFilePath,
operationName,
});
}
}
async function syncConfigToRemote(configFilePath: string): Promise<ConfigSyncResult> {
logRemoteDevelopmentEnvironment("Starting config sync", {
configFilePath,
});
const state = readRemoteDevelopmentEnvironmentState();
const project = state.projectsByConfigPath[configFilePath];
if (project == null || state.anonymousRefreshToken == null) {
@ -410,31 +615,56 @@ async function syncConfigToRemote(configFilePath: string): Promise<ProjectOnboar
hasProject: project != null,
hasAnonymousRefreshToken: state.anonymousRefreshToken != null,
});
return undefined;
return { onboardingStatus: undefined, pushedConfig: false };
}
logRemoteDevelopmentEnvironment("Loading config file for sync", {
configFilePath,
});
const { config, showOnboarding } = await readConfigFile(configFilePath);
const configHash = sha256String(JSON.stringify({ config, showOnboarding, syncFormatVersion: CONFIG_SYNC_FORMAT_VERSION }));
logRemoteDevelopmentEnvironment("Loaded config file for sync", {
configFilePath,
configHash,
showOnboarding,
});
const app = createInternalApp(project.apiBaseUrl, state.anonymousRefreshToken);
const user = await app.getUser({ or: "anonymous" });
logRemoteDevelopmentEnvironment("Ensuring development-environment project ownership for config sync", {
projectId: project.projectId,
configFilePath,
});
const ownedProject = (await user.listOwnedProjects()).find((p) => p.id === project.projectId);
if (ownedProject == null) {
warnRemoteDevelopmentEnvironment("Skipping config sync because the project is not owned by the anonymous user", {
projectId: project.projectId,
configFilePath,
});
return undefined;
return { onboardingStatus: undefined, pushedConfig: false };
}
const onboardingStatus = await syncRemoteDevelopmentEnvironmentOnboardingStatus(ownedProject, showOnboarding);
if (project.lastSyncedConfigHash === configHash) {
return onboardingStatus;
}
logRemoteDevelopmentEnvironment("Syncing config to development-environment project", {
logRemoteDevelopmentEnvironment("Syncing onboarding status before config push", {
projectId: project.projectId,
configFilePath,
showOnboarding,
});
const onboardingStatus = await syncRemoteDevelopmentEnvironmentOnboardingStatus(ownedProject, showOnboarding);
if (project.lastSyncedConfigHash === configHash) {
logRemoteDevelopmentEnvironment("Skipping config push because remote config hash is current", {
projectId: project.projectId,
configFilePath,
configHash,
onboardingStatus,
});
await updateRemoteDevelopmentEnvironmentPushedConfigError(configFilePath, null);
return { onboardingStatus, pushedConfig: false };
}
logRemoteDevelopmentEnvironment("Sending config push request to development-environment project", {
projectId: project.projectId,
configFilePath,
configHash,
showOnboarding,
});
await ownedProject.replaceConfigOverride("branch", config);
updateRemoteDevelopmentEnvironmentState((current) => ({
@ -451,10 +681,11 @@ async function syncConfigToRemote(configFilePath: string): Promise<ProjectOnboar
logRemoteDevelopmentEnvironment("Synced config to development-environment project", {
projectId: project.projectId,
configFilePath,
configHash,
showOnboarding,
onboardingStatus,
});
return onboardingStatus;
return { onboardingStatus, pushedConfig: true };
}
function scheduleSync(configFilePath: string): void {
@ -466,26 +697,51 @@ function scheduleSync(configFilePath: string): void {
return;
}
const existing = state.syncTimers.get(configFilePath);
if (existing != null) clearTimeout(existing);
logRemoteDevelopmentEnvironment("Scheduling config sync after local file change", {
if (existing != null) {
clearTimeout(existing);
}
logRemoteDevelopmentEnvironment(existing == null ? "Scheduling config sync after local file change" : "Rescheduling config sync after another local file change", {
configFilePath,
debounceMs: SYNC_DEBOUNCE_MS,
hasActiveSync: state.activeConfigSyncs.has(configFilePath),
});
const timer = setTimeout(() => {
state.syncTimers.delete(configFilePath);
runAsynchronously(
async () => {
await syncConfigToRemote(configFilePath);
state.syncErrors.delete(configFilePath);
},
{
onError: (error) => {
try {
const result = await runConfigSyncSerialized(
configFilePath,
"debounced local file change",
() => syncConfigToRemote(configFilePath),
);
state.syncErrors.delete(configFilePath);
if (result.pushedConfig) {
recordConfigSyncEvent(configFilePath, { status: "success" });
}
} catch (error) {
const errorMessage = errorToNiceString(error);
const cliErrorMessage = formatConfigSyncErrorForCli(configFilePath, error);
warnRemoteDevelopmentEnvironment("Config sync failed", {
configFilePath,
error: errorToNiceString(error),
error: errorMessage,
});
state.syncErrors.set(configFilePath, error);
},
state.syncErrors.set(configFilePath, error instanceof Error ? error : new Error(errorMessage));
await updateRemoteDevelopmentEnvironmentPushedConfigError(configFilePath, cliErrorMessage)
.catch((pushedConfigErrorUpdateError: unknown) => {
warnRemoteDevelopmentEnvironment("Failed to update pushed config error after config sync failure", {
configFilePath,
error: errorToNiceString(pushedConfigErrorUpdateError),
});
});
recordConfigSyncEvent(configFilePath, {
status: "error",
errorMessage: cliErrorMessage,
});
}
},
{
noErrorLogging: true,
},
);
}, SYNC_DEBOUNCE_MS);
@ -493,16 +749,23 @@ function scheduleSync(configFilePath: string): void {
state.syncTimers.set(configFilePath, timer);
}
async function syncConfigToRemoteNow(configFilePath: string): Promise<ProjectOnboardingStatus | undefined> {
async function syncConfigToRemoteNow(configFilePath: string): Promise<ConfigSyncResult> {
const state = getGlobals();
const pendingTimer = state.syncTimers.get(configFilePath);
if (pendingTimer != null) {
clearTimeout(pendingTimer);
state.syncTimers.delete(configFilePath);
logRemoteDevelopmentEnvironment("Canceled pending debounced config sync before immediate sync", {
configFilePath,
});
}
const onboardingStatus = await syncConfigToRemote(configFilePath);
const result = await runConfigSyncSerialized(
configFilePath,
"immediate sync",
() => syncConfigToRemote(configFilePath),
);
state.syncErrors.delete(configFilePath);
return onboardingStatus;
return result;
}
function ensureWatcher(configFilePath: string): void {
@ -545,6 +808,7 @@ function ensureShutdownTimer(): void {
uptimeMs: Math.round(now - state.startedAtMs),
watchedConfigFiles: state.watchers.size,
pendingSyncs: state.syncTimers.size,
activeConfigSyncs: state.activeConfigSyncs.size,
syncErrors: state.syncErrors.size,
activeOperations: state.activeOperations,
hasClosedSession: state.hasClosedSession,
@ -587,14 +851,43 @@ export async function registerRemoteDevelopmentEnvironmentSession(options: {
anonymousRefreshToken: state.anonymousRefreshToken,
}).catch((error: unknown) => throwApiUnavailableIfConnectionFailure(options.apiBaseUrl, error));
ensureWatcher(configFilePath);
const onboardingStatus = await syncConfigToRemoteNow(configFilePath)
.catch((error: unknown) => throwApiUnavailableIfConnectionFailure(options.apiBaseUrl, error));
const sessionId = randomUUID();
getGlobals().sessions.set(sessionId, {
const globals = getGlobals();
globals.sessions.set(sessionId, {
configFilePath,
lastHeartbeatMs: performance.now(),
receivedFirstHeartbeat: false,
lastDeliveredConfigSyncEventId: globals.nextConfigSyncEventId - 1,
});
let syncResult: ConfigSyncResult = { onboardingStatus: undefined, pushedConfig: false };
try {
syncResult = await syncConfigToRemoteNow(configFilePath);
} catch (error) {
if (errorLooksLikeApiConnectionFailure(error)) {
globals.sessions.delete(sessionId);
throw new RemoteDevelopmentEnvironmentApiUnavailableError(options.apiBaseUrl, error);
}
const errorMessage = errorToNiceString(error);
warnRemoteDevelopmentEnvironment("Initial config sync failed", {
sessionId,
projectId: project.projectId,
configFilePath,
error: errorMessage,
});
globals.syncErrors.set(configFilePath, error instanceof Error ? error : new Error(errorMessage));
const cliErrorMessage = formatConfigSyncErrorForCli(configFilePath, error);
await updateRemoteDevelopmentEnvironmentPushedConfigError(configFilePath, cliErrorMessage)
.catch((pushedConfigErrorUpdateError: unknown) => {
warnRemoteDevelopmentEnvironment("Failed to update pushed config error after initial config sync failure", {
configFilePath,
error: errorToNiceString(pushedConfigErrorUpdateError),
});
});
recordConfigSyncEvent(configFilePath, {
status: "error",
errorMessage: cliErrorMessage,
});
}
logRemoteDevelopmentEnvironment("Registered CLI session", {
sessionId,
projectId: project.projectId,
@ -605,25 +898,29 @@ export async function registerRemoteDevelopmentEnvironmentSession(options: {
sessionId,
env: envVarsForProject(project),
projectId: project.projectId,
onboardingOutstanding: onboardingStatus != null && onboardingStatus !== "completed",
onboardingOutstanding: syncResult.onboardingStatus != null && syncResult.onboardingStatus !== "completed",
};
} finally {
endOperation();
}
}
export function heartbeatRemoteDevelopmentEnvironmentSession(sessionId: string): boolean {
export function heartbeatRemoteDevelopmentEnvironmentSession(sessionId: string): {
configSyncEvents: RemoteDevelopmentEnvironmentConfigSyncEvent[],
} | null {
assertRemoteDevelopmentEnvironmentEnabled();
const session = getGlobals().sessions.get(sessionId);
if (session == null) {
warnRemoteDevelopmentEnvironment("Received heartbeat for unknown session", {
sessionId,
});
return false;
return null;
}
session.lastHeartbeatMs = performance.now();
session.receivedFirstHeartbeat = true;
return true;
return {
configSyncEvents: drainConfigSyncEventsForSession(session),
};
}
export function getPendingRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode(): { code: string, expiresAtMillis: number } | null {
@ -703,6 +1000,20 @@ export function getRemoteDevelopmentEnvironmentDebugSnapshot(): RemoteDevelopmen
configFilePath,
error: errorToNiceString(error),
})),
pendingConfigSyncEvents: globals.configSyncEvents.map((event) => event.status === "success"
? {
id: event.id,
configFilePath: event.configFilePath,
status: "success",
createdAgoMs: Math.max(0, unixNow - event.createdAtMillis),
}
: {
id: event.id,
configFilePath: event.configFilePath,
status: "error",
errorMessage: event.errorMessage,
createdAgoMs: Math.max(0, unixNow - event.createdAtMillis),
}),
synchronouslyUpdatingConfigFiles: [...globals.synchronouslyUpdatingConfigFiles],
localDashboards: Object.values(state.localDashboardsByPort ?? {})
.filter((dashboard) => dashboard != null)
@ -781,7 +1092,28 @@ export async function applyRemoteDevelopmentEnvironmentConfigUpdate(options: {
state.synchronouslyUpdatingConfigFiles.delete(configFilePath);
}, SYNC_DEBOUNCE_MS).unref();
}
await syncConfigToRemoteNow(configFilePath);
try {
const result = await syncConfigToRemoteNow(configFilePath);
if (result.pushedConfig) {
recordConfigSyncEvent(configFilePath, { status: "success" });
}
} catch (error) {
const errorMessage = errorToNiceString(error);
state.syncErrors.set(configFilePath, error instanceof Error ? error : new Error(errorMessage));
const cliErrorMessage = formatConfigSyncErrorForCli(configFilePath, error);
await updateRemoteDevelopmentEnvironmentPushedConfigError(configFilePath, cliErrorMessage)
.catch((pushedConfigErrorUpdateError: unknown) => {
warnRemoteDevelopmentEnvironment("Failed to update pushed config error after dashboard config sync failure", {
configFilePath,
error: errorToNiceString(pushedConfigErrorUpdateError),
});
});
recordConfigSyncEvent(configFilePath, {
status: "error",
errorMessage: cliErrorMessage,
});
throw error;
}
}
logRemoteDevelopmentEnvironment("Applied config update from local dashboard", {
sessionId: options.sessionId,

View File

@ -1312,6 +1312,106 @@ describe("pushConfig and updateConfig behavior", () => {
});
});
describe("pushed config errors", () => {
it("sets pushed config errors only for development-environment projects and clears them after a successful branch config push", async ({ expect }) => {
await Project.createAndSwitch();
const nonRdeResponse = await niceBackendFetch("/api/v1/internal/config/pushed-error", {
method: "PUT",
accessType: "server",
body: {
error_message: "Config file error: Example failure. Please check your config file.",
},
});
expect(nonRdeResponse.status).toBe(403);
await Project.createAndSwitch({
is_development_environment: true,
});
const setErrorResponse = await niceBackendFetch("/api/v1/internal/config/pushed-error", {
method: "PUT",
accessType: "server",
body: {
error_message: "Config file error: The key \"abcd\" is not valid. Please check your config file.",
},
});
expect(setErrorResponse.status).toBe(200);
const projectWithErrorResponse = await niceBackendFetch("/api/v1/projects/current", {
accessType: "client",
});
expect(projectWithErrorResponse.status).toBe(200);
expect(projectWithErrorResponse.body.pushed_config_error).toEqual({
message: "Config file error: The key \"abcd\" is not valid. Please check your config file.",
});
const pushConfigResponse = await niceBackendFetch("/api/v1/internal/config/override/branch", {
method: "PUT",
accessType: "server",
body: {
config_string: JSON.stringify({
"auth.allowSignUp": true,
}),
source: { type: "pushed-from-unknown" },
},
});
expect(pushConfigResponse.status).toBe(200);
const projectAfterPushResponse = await niceBackendFetch("/api/v1/projects/current", {
accessType: "client",
});
expect(projectAfterPushResponse.status).toBe(200);
expect(projectAfterPushResponse.body.pushed_config_error).toBeNull();
});
});
describe("config warnings", () => {
it("only exposes config warnings on development-environment project responses", async ({ expect }) => {
const warningConfig = {
"auth.oauth.providers.google.type": "google",
};
await Project.createAndSwitch();
const nonRdePushResponse = await niceBackendFetch("/api/v1/internal/config/override/branch", {
method: "PUT",
accessType: "server",
body: {
config_string: JSON.stringify(warningConfig),
source: { type: "pushed-from-unknown" },
},
});
expect(nonRdePushResponse.status).toBe(200);
const nonRdeProjectResponse = await niceBackendFetch("/api/v1/projects/current", {
accessType: "client",
});
expect(nonRdeProjectResponse.status).toBe(200);
expect(nonRdeProjectResponse.body.config_warnings).toEqual([]);
await Project.createAndSwitch({
is_development_environment: true,
});
const rdePushResponse = await niceBackendFetch("/api/v1/internal/config/override/branch", {
method: "PUT",
accessType: "server",
body: {
config_string: JSON.stringify(warningConfig),
source: { type: "pushed-from-unknown" },
},
});
expect(rdePushResponse.status).toBe(200);
const rdeProjectResponse = await niceBackendFetch("/api/v1/projects/current", {
accessType: "client",
});
expect(rdeProjectResponse.status).toBe(200);
expect(rdeProjectResponse.body.config_warnings).toEqual([
{
message: "Dot-notation key \"auth.oauth.providers.google.type\" will be silently ignored because it references non-existent parent \"auth.oauth.providers.google\". Instead of dot notation, use nested object notation like this: { \"auth.oauth.providers.google\": { \"type\": ... } }",
},
]);
});
});
describe("test helpers", () => {
it("Project.updateConfig helper sets environment level config", async ({ expect }) => {

View File

@ -6425,6 +6425,17 @@
"type": "string",
"example": "MyMusic",
"description": "Human-readable project display name. This is not a unique identifier."
},
"pushed_config_error": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": [
"message"
]
}
},
"required": [

View File

@ -4411,6 +4411,17 @@
"type": "string",
"example": "MyMusic",
"description": "Human-readable project display name. This is not a unique identifier."
},
"pushed_config_error": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": [
"message"
]
}
},
"required": [

View File

@ -5728,6 +5728,17 @@
"type": "string",
"example": "MyMusic",
"description": "Human-readable project display name. This is not a unique identifier."
},
"pushed_config_error": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": [
"message"
]
}
},
"required": [

View File

@ -3,7 +3,7 @@ import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { recordLocalDashboardProcess } from "../lib/dev-env-state.js";
import { devDashboardCommandFromEnv, isVersionNewer, killLocalDashboard, processExists, shouldRestartDashboard } from "./dev.js";
import { configErrorLogPrefix, devDashboardCommandFromEnv, isHeartbeatResponse, isVersionNewer, killLocalDashboard, processExists, shouldRestartDashboard } from "./dev.js";
describe("isVersionNewer", () => {
it("compares core versions numerically", () => {
@ -88,6 +88,60 @@ describe("devDashboardCommandFromEnv", () => {
});
});
describe("configErrorLogPrefix", () => {
it("highlights the config error badge when color is supported", () => {
expect(configErrorLogPrefix(true)).toBe("[Hexclave] \x1b[41;37;1m[CONFIG ERROR]\x1b[0m ");
});
it("keeps a readable plain-text badge without color support", () => {
expect(configErrorLogPrefix(false)).toBe("[Hexclave] [CONFIG ERROR] ");
});
});
describe("isHeartbeatResponse", () => {
it("accepts config sync events from the dashboard heartbeat", () => {
expect(isHeartbeatResponse({
ok: true,
config_sync_events: [
{
config_file_path: "/app/hexclave.config.ts",
status: "success",
created_at_millis: 1_718_000_000_000,
},
{
config_file_path: "/app/hexclave.config.ts",
status: "error",
error_message: "Could not reach the API.",
created_at_millis: 1_718_000_000_001,
},
],
})).toBe(true);
});
it("rejects malformed config sync events", () => {
expect(isHeartbeatResponse({
ok: true,
config_sync_events: [
{
config_file_path: "/app/hexclave.config.ts",
status: "pending",
created_at_millis: 1_718_000_000_000,
},
],
})).toBe(false);
expect(isHeartbeatResponse({
ok: true,
config_sync_events: [
{
config_file_path: "/app/hexclave.config.ts",
status: "error",
created_at_millis: 1_718_000_000_000,
},
],
})).toBe(false);
});
});
describe("killLocalDashboard", () => {
let tempDir: string;

View File

@ -28,10 +28,23 @@ type SessionResponse = {
onboarding_outstanding: boolean,
};
type ConfigSyncEventBase = {
config_file_path: string,
created_at_millis: number,
};
type ConfigSyncEvent = ConfigSyncEventBase & ({
status: "success",
} | {
status: "error",
error_message: string,
});
type HeartbeatResponse = {
ok: true,
browser_secret_confirmation_code?: string,
browser_secret_confirmation_code_expires_at_millis?: number,
config_sync_events?: ConfigSyncEvent[],
};
const HEARTBEAT_INTERVAL_MS = 5_000;
@ -128,6 +141,19 @@ function logDev(message: string): void {
console.warn(`${LOG_PREFIX}${message}`);
}
function stderrSupportsAnsiColor(): boolean {
return process.stderr.isTTY && process.env.NO_COLOR == null && process.env.TERM !== "dumb";
}
export function configErrorLogPrefix(supportsColor = stderrSupportsAnsiColor()): string {
const label = supportsColor ? "\x1b[41;37;1m[CONFIG ERROR]\x1b[0m" : "[CONFIG ERROR]";
return `${LOG_PREFIX}${label} `;
}
function logDevConfigError(message: string): void {
console.warn(`${configErrorLogPrefix()}${message}`);
}
function openUrlInBrowser(url: string): boolean {
try {
if (process.platform === "darwin") {
@ -610,7 +636,28 @@ function isSessionResponse(value: unknown): value is SessionResponse {
);
}
function isHeartbeatResponse(value: unknown): value is HeartbeatResponse {
function isConfigSyncEvent(value: unknown): value is ConfigSyncEvent {
if (
!isRecord(value) ||
!("config_file_path" in value) ||
typeof value.config_file_path !== "string" ||
!("status" in value) ||
!("created_at_millis" in value) ||
typeof value.created_at_millis !== "number"
) {
return false;
}
if (value.status === "success") {
return true;
}
return (
value.status === "error" &&
"error_message" in value &&
typeof value.error_message === "string"
);
}
export function isHeartbeatResponse(value: unknown): value is HeartbeatResponse {
return (
typeof value === "object" &&
value !== null &&
@ -624,6 +671,10 @@ function isHeartbeatResponse(value: unknown): value is HeartbeatResponse {
(
!("browser_secret_confirmation_code_expires_at_millis" in value) ||
typeof value.browser_secret_confirmation_code_expires_at_millis === "number"
) &&
(
!("config_sync_events" in value) ||
(Array.isArray(value.config_sync_events) && value.config_sync_events.every(isConfigSyncEvent))
)
);
}
@ -639,6 +690,16 @@ function logBrowserSecretConfirmationCode(response: HeartbeatResponse): void {
: `Dashboard browser confirmation code: ${response.browser_secret_confirmation_code} (expires in ${expiresInSeconds}s)`);
}
function logConfigSyncEvents(response: HeartbeatResponse): void {
for (const event of response.config_sync_events ?? []) {
if (event.status === "success") {
logDev(`Config synced to development environment project: ${event.config_file_path}`);
} else {
logDevConfigError(`Config sync failed for ${event.config_file_path}: ${event.error_message}`);
}
}
}
function pendingBrowserSecretConfirmationCodeFromState(port: number): HeartbeatResponse | null {
const pending = readDevEnvState().pendingBrowserSecretConfirmationCodesByPort?.[String(port)];
if (pending == null || pending.expiresAtMillis <= Date.now()) {
@ -928,6 +989,7 @@ async function heartbeatUntilStopped(sessionState: DashboardSessionState, option
logBrowserSecretConfirmationCode(heartbeatBody);
lastLoggedConfirmationCode = heartbeatBody.browser_secret_confirmation_code;
}
logConfigSyncEvents(heartbeatBody);
}
}

View File

@ -76,7 +76,7 @@ export async function readConfigFile(configFilePath: string): Promise<{ config:
// user-facing message we're deliberately replacing.
captureError("shared-backend/readConfigFile", error);
throw new Error(
`Failed to load config file ${configFilePath}. If your config imports a value (e.g. defineHexclaveConfig) from a framework package such as "@hexclave/next", import it from that package's lightweight "/config" entrypoint instead, which doesn't load the framework runtime:\n\n import { defineHexclaveConfig } from "@hexclave/next/config";\n`,
`Failed to load config file ${configFilePath}.`,
);
}
if (!isConfigModule(configModule)) {

View File

@ -27,11 +27,11 @@ type StrictStackConfig<T extends StackConfig> =
: T;
/** @deprecated Use `defineHexclaveConfig` from the `@hexclave/*` package instead — same symbol, new brand name. See https://docs.hexclave.com/migration. */
export function defineStackConfig<const T extends StackConfig>(config: StrictStackConfig<T>): T {
export function defineStackConfig(config: StrictStackConfig<StackConfig>): StackConfig {
return config;
}
// Hexclave alias — separate function so it does not inherit the deprecation tag.
export function defineHexclaveConfig<const T extends HexclaveConfig>(config: StrictStackConfig<T>): T {
export function defineHexclaveConfig(config: StrictStackConfig<HexclaveConfig>): HexclaveConfig {
return config;
}

View File

@ -32,6 +32,14 @@ const enabledOAuthProviderSchema = yupObject({
id: schemaFields.oauthIdSchema.defined(),
});
const pushedConfigErrorSchema = yupObject({
message: yupString().defined(),
}).defined();
const configWarningSchema = yupObject({
message: yupString().defined(),
}).defined();
const onboardingConfigChoiceValues = ["create-new", "link-existing"] as const;
const onboardingSignInMethodValues = ["credential", "magicLink", "passkey", "google", "github", "microsoft"] as const;
const onboardingPaymentsCountryValues = ["US", "OTHER"] as const;
@ -89,6 +97,8 @@ export const projectsCrudAdminReadSchema = yupObject({
owner_team_id: schemaFields.yupString().nullable().defined(),
onboarding_status: schemaFields.projectOnboardingStatusSchema.defined(),
onboarding_state: projectOnboardingStateSchema.nullable().optional(),
pushed_config_error: pushedConfigErrorSchema.nullable().defined(),
config_warnings: yupArray(configWarningSchema).defined(),
/** @deprecated */
config: yupObject({
allow_localhost: schemaFields.projectAllowLocalhostSchema.defined(),
@ -117,6 +127,8 @@ export const projectsCrudAdminReadSchema = yupObject({
export const projectsCrudClientReadSchema = yupObject({
id: schemaFields.projectIdSchema.defined(),
display_name: schemaFields.projectDisplayNameSchema.defined(),
pushed_config_error: pushedConfigErrorSchema.nullable().defined(),
config_warnings: yupArray(configWarningSchema).defined(),
config: yupObject({
sign_up_enabled: schemaFields.projectSignUpEnabledSchema.defined(),
credential_enabled: schemaFields.projectCredentialEnabledSchema.defined(),

View File

@ -196,6 +196,12 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
logoFullUrl: data.logo_full_url,
logoDarkModeUrl: data.logo_dark_mode_url,
logoFullDarkModeUrl: data.logo_full_dark_mode_url,
pushedConfigError: data.pushed_config_error == null ? null : {
message: data.pushed_config_error.message,
},
configWarnings: data.config_warnings.map((warning) => ({
message: warning.message,
})),
config: {
signUpEnabled: data.config.sign_up_enabled,
credentialEnabled: data.config.credential_enabled,

View File

@ -1,5 +1,4 @@
import { WebAuthnError, startAuthentication, startRegistration } from "@simplewebauthn/browser";
import { KnownError, KnownErrors, HexclaveClientInterface } from "@hexclave/shared";
import { HexclaveClientInterface, KnownError, KnownErrors } from "@hexclave/shared";
import type { RequestListener } from "@hexclave/shared/dist/interface/client-interface";
import { ContactChannelsCrud } from "@hexclave/shared/dist/interface/crud/contact-channels";
import { CurrentUserCrud } from "@hexclave/shared/dist/interface/crud/current-user";
@ -38,15 +37,16 @@ import { BotChallengeExecutionFailedError, BotChallengeUserCancelledError, withB
import { createUrlIfValid, getRelativePart, isRelative } from "@hexclave/shared/dist/utils/urls";
import { generateUuid } from "@hexclave/shared/dist/utils/uuids";
import * as tanstackStartServerContext from "@hexclave/tanstack-start/tanstack-start-server-context"; // THIS_LINE_PLATFORM tanstack-start
import { WebAuthnError, startAuthentication, startRegistration } from "@simplewebauthn/browser";
import * as TanStackRouter from "@tanstack/react-router"; // THIS_LINE_PLATFORM tanstack-start
import * as cookie from "cookie";
import * as NextNavigationUnscrambled from "next/navigation"; // import the entire module to get around some static compiler warnings emitted by Next.js in some cases | THIS_LINE_PLATFORM next
import React, { useCallback, useMemo } from "react"; // THIS_LINE_PLATFORM react-like
import type * as yup from "yup";
import { envVars } from "../../../../generated/env";
import { constructRedirectUrl } from "../../../../utils/url";
import { callOAuthCallback, getNewOAuthProviderOrScopeUrl } from "../../../auth";
import { CookieHelper, createBrowserCookieHelper, createCookieHelper, createPlaceholderCookieHelper, deleteCookie, deleteCookieClient, getCookieClient, isSecure as isSecureCookieContext, saveVerifierAndState, setOrDeleteCookie, setOrDeleteCookieClient } from "../../../cookie";
import { envVars } from "../../../../generated/env";
import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptionsToCrud } from "../../api-keys";
import { ConvexCtx, GetCurrentPartialUserOptions, GetCurrentUserOptions, HandlerUrlOptions, HandlerUrls, OAuthScopesOnSignIn, RedirectMethod, RedirectToOptions, RequestLike, ResolvedHandlerUrls, TokenStoreInit, hexclaveAppInternalsSymbol } from "../../common";
import { DeprecatedOAuthConnection, OAuthConnection } from "../../connected-accounts";
@ -73,6 +73,7 @@ import { useAsyncCache } from "./common";
// IF_PLATFORM js-like
import { mountClickmapOverlay } from "../../../../clickmap";
import { mountDevTool } from "../../../../dev-tool";
import { mountPushedConfigErrorOverlay } from "../../../../pushed-config-error-overlay";
// END_PLATFORM
let isReactServer = false;
@ -770,6 +771,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
// when a dashboard-minted token is handed over, so the listener is
// mounted unconditionally (the heavy UI is lazy-loaded on demand).
mountClickmapOverlay(this as any);
mountPushedConfigErrorOverlay(this as any);
}
// END_PLATFORM
}
@ -1675,6 +1677,12 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
return {
id: crud.id,
displayName: crud.display_name,
pushedConfigError: crud.pushed_config_error == null ? null : {
message: crud.pushed_config_error.message,
},
configWarnings: crud.config_warnings.map((warning) => ({
message: warning.message,
})),
config: {
signUpEnabled: crud.config.sign_up_enabled,
credentialEnabled: crud.config.credential_enabled,

View File

@ -26,6 +26,8 @@ export type PushConfigOptions = {
export type Project = {
readonly id: string,
readonly displayName: string,
readonly pushedConfigError: { message: string } | null,
readonly configWarnings: { message: string }[],
readonly config: ProjectConfig,
};

View File

@ -0,0 +1,546 @@
// IF_PLATFORM js-like
import { captureError } from "@hexclave/shared/dist/utils/errors";
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
import { isLocalhost } from "@hexclave/shared/dist/utils/urls";
import { envVars } from "../generated/env";
import { getInPageUiBaseCSS } from "../in-page-ui/base-styles";
import { canMountIntoDom, getGlobalUiInstance, h, setGlobalUiInstance, setHtml } from "../in-page-ui/dom";
import type { StackClientApp } from "../lib/hexclave-app";
const GLOBAL_INSTANCE_KEY = "__hexclave-pushed-config-error-overlay";
const MINIMIZED_STORAGE_KEY = "hexclave-pushed-config-error-minimized-key";
const REFRESH_INTERVAL_MS = 5_000;
const HEXCLAVE_LOGO_SVG = '<svg width="16" height="16" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linejoin="miter"><path d="M 24 4 L 41.32 14 L 41.32 34 L 24 44 L 6.68 34 L 6.68 14 Z"/><path d="M 11 16.87 L 14 15.13 L 14 32.87 L 11 31.13 Z" fill="currentColor" stroke="none"/><path d="M 11 16.87 L 14 15.13 L 14 32.87 L 11 31.13 Z" fill="currentColor" stroke="none" transform="rotate(120 24 24)"/><path d="M 11 16.87 L 14 15.13 L 14 32.87 L 11 31.13 Z" fill="currentColor" stroke="none" transform="rotate(240 24 24)"/></svg>';
const COPY_ICON_SVG = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
type ConfigIssue = {
kind: "error" | "warning",
messages: string[],
};
const css = getInPageUiBaseCSS(".hexclave-config-error-overlay") + `
.hexclave-config-error-overlay .hce-backdrop {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.46);
backdrop-filter: blur(6px);
overflow: auto;
}
.hexclave-config-error-overlay .hce-card {
--hce-status: var(--sdt-error);
width: min(720px, calc(100vw - 32px));
max-height: min(640px, calc(100dvh - 48px));
border: 1px solid color-mix(in srgb, var(--hce-status) 35%, var(--sdt-border));
border-radius: 18px;
background: var(--sdt-overlay-bg);
box-shadow: var(--sdt-shadow);
backdrop-filter: blur(18px);
display: flex;
overflow: hidden;
}
.hexclave-config-error-overlay .hce-card-warning {
--hce-status: var(--sdt-warning);
}
.hexclave-config-error-overlay .hce-card-inner {
padding: 18px;
width: 100%;
overflow: auto;
}
.hexclave-config-error-overlay .hce-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.hexclave-config-error-overlay .hce-title-row {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
.hexclave-config-error-overlay .hce-logo {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--hce-status);
color: white;
box-shadow: 0 10px 30px color-mix(in srgb, var(--hce-status) 32%, transparent);
}
.hexclave-config-error-overlay .hce-badge {
display: inline-flex;
flex-shrink: 0;
padding: 2px 6px;
border-radius: 999px;
background: var(--hce-status);
color: white;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.hexclave-config-error-overlay .hce-title {
color: var(--sdt-text);
margin-top: 4px;
font-size: 18px;
font-weight: 700;
line-height: 1.25;
}
.hexclave-config-error-overlay .hce-actions {
display: flex;
gap: 4px;
}
.hexclave-config-error-overlay .hce-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--sdt-border);
border-radius: 8px;
background: var(--sdt-bg-elevated);
color: var(--sdt-text-secondary);
cursor: pointer;
font: inherit;
line-height: 1;
vertical-align: top;
}
.hexclave-config-error-overlay .hce-icon-btn svg {
display: block;
flex-shrink: 0;
}
.hexclave-config-error-overlay .hce-text-btn {
align-items: center;
gap: 6px;
min-height: 28px;
padding: 0 10px;
width: auto;
font-size: 12px;
line-height: 1;
}
.hexclave-config-error-overlay .hce-icon-btn:hover {
background: var(--sdt-bg-hover);
color: var(--sdt-text);
}
.hexclave-config-error-overlay .hce-body {
color: var(--sdt-text-secondary);
font-size: 14px;
line-height: 1.5;
}
.hexclave-config-error-overlay .hce-message-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 14px;
margin-bottom: 8px;
}
.hexclave-config-error-overlay .hce-message-label {
color: var(--sdt-text);
font-size: 12px;
font-weight: 650;
}
.hexclave-config-error-overlay .hce-message {
padding: 12px;
max-height: min(260px, max(96px, 30dvh));
overflow: auto;
border: 1px solid var(--sdt-border-subtle);
border-radius: 10px;
background: var(--sdt-bg-subtle);
color: var(--sdt-text);
font-family: var(--sdt-font-mono);
font-size: 12px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.hexclave-config-error-overlay .hce-footer {
margin-top: 10px;
color: var(--sdt-text-tertiary);
font-size: 12px;
}
.hexclave-config-error-overlay .hce-pill {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 2147483647;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px 8px 8px;
--hce-status: var(--sdt-error);
border: 1px solid color-mix(in srgb, var(--hce-status) 35%, var(--sdt-border));
border-radius: 999px;
background: var(--sdt-overlay-bg);
box-shadow: var(--sdt-trigger-shadow);
color: var(--sdt-text);
cursor: pointer;
font: inherit;
backdrop-filter: blur(18px);
}
.hexclave-config-error-overlay .hce-pill-warning {
--hce-status: var(--sdt-warning);
}
.hexclave-config-error-overlay .hce-pill-logo {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 999px;
background: var(--hce-status);
color: white;
}
@media (max-height: 520px) {
.hexclave-config-error-overlay .hce-backdrop {
align-items: flex-start;
padding: 12px;
}
.hexclave-config-error-overlay .hce-card {
width: min(720px, calc(100vw - 24px));
max-height: calc(100dvh - 24px);
}
.hexclave-config-error-overlay .hce-card-inner {
padding: 12px;
}
.hexclave-config-error-overlay .hce-header {
margin-bottom: 8px;
}
.hexclave-config-error-overlay .hce-title {
font-size: 16px;
}
.hexclave-config-error-overlay .hce-body {
font-size: 13px;
}
.hexclave-config-error-overlay .hce-message {
max-height: max(80px, 24dvh);
}
}
`;
function storageGet(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function storageSet(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
// Storage may be unavailable in private or embedded contexts.
}
}
function storageRemove(key: string): void {
try {
localStorage.removeItem(key);
} catch {
// Storage may be unavailable in private or embedded contexts.
}
}
function shouldMount(): boolean {
if (!canMountIntoDom()) {
return false;
}
const nodeEnv = envVars.NODE_ENV;
if (nodeEnv !== undefined) {
return nodeEnv === "development";
}
try {
const url = new URL(window.location.href);
if (url.protocol === "file:") {
return true;
}
} catch {
return false;
}
return isLocalhost(window.location.href);
}
async function copyTextToClipboard(text: string): Promise<void> {
const clipboard: unknown = Reflect.get(navigator, "clipboard");
const writeText = clipboard != null && typeof clipboard === "object"
? Reflect.get(clipboard, "writeText")
: null;
if (typeof writeText === "function") {
await writeText.call(clipboard, text);
return;
}
const textarea = h("textarea", {
style: {
position: "fixed",
left: "-9999px",
top: "0",
opacity: "0",
},
readonly: "true",
}) as HTMLTextAreaElement;
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand("copy");
textarea.remove();
if (!copied) {
throw new Error("Browser refused to copy the config error message.");
}
}
function buildConfigIssueAiPrompt(issue: ConfigIssue): string {
const issueLabel = issue.kind === "error" ? "error" : "warning";
return [
`Help me fix this Hexclave config ${issueLabel}.`,
"",
"Hexclave reminder:",
`This ${issueLabel} comes from a pushed Hexclave config file. The app may keep running with the synced Hexclave config, but I need to fix the config file mentioned in the message and save it again so Hexclave can sync the config successfully and clear the dialog. Use your web fetch tool to read https://skill.hexclave.com to learn more about Hexclave.`,
"",
issue.kind === "error" ? "Error message:" : "Warning message:",
issue.messages.join("\n"),
"",
"Please explain the likely cause, identify the config key or value I should change, and suggest the smallest safe fix.",
].join("\n");
}
export function mountPushedConfigErrorOverlay(app: StackClientApp<true>): () => void {
if (!shouldMount()) {
return () => {};
}
getGlobalUiInstance(GLOBAL_INSTANCE_KEY)?.cleanup();
const root = h("div", { className: "hexclave-config-error-overlay" });
const style = h("style", null, css);
root.appendChild(style);
document.body.appendChild(root);
let disposed = false;
let lastErrorKey: string | null = null;
let lastConsoleErrorKey: string | null = null;
let minimized = false;
const render = (issue: ConfigIssue | null) => {
root.replaceChildren(style);
if (issue == null) {
lastErrorKey = null;
minimized = false;
return;
}
const issueMessage = issue.messages.join("\n");
const issueKey = `${app.projectId}:${issue.kind}:${issueMessage}`;
const issueLabel = issue.kind === "error" ? "error" : "warning";
const issueTitle = issue.kind === "error"
? "Your Hexclave config has been saved, but contains errors"
: "Your Hexclave config has been saved, but has warnings";
const bodyText = issue.kind === "error"
? "Your app can keep running, but Hexclave is still using the last valid config until this is fixed."
: "Your app can keep running, but part of your Hexclave config may not behave the way you expect until this is fixed.";
const footerText = issue.kind === "error"
? "Fix the config file mentioned above and save it again. This message will disappear after the config sync succeeds."
: "Fix the config file mentioned above and save it again. This warning will disappear after Hexclave syncs a config without warnings.";
if (issueKey !== lastConsoleErrorKey) {
lastConsoleErrorKey = issueKey;
const consoleMessage = `[Hexclave] Config ${issueLabel}: ${issueMessage}`;
if (issue.kind === "error") {
console.error(consoleMessage);
} else {
console.warn(consoleMessage);
}
}
if (issueKey !== lastErrorKey) {
lastErrorKey = issueKey;
minimized = storageGet(MINIMIZED_STORAGE_KEY) === issueKey;
}
if (minimized) {
const logoSpan = h("span", { className: "hce-pill-logo" });
setHtml(logoSpan, HEXCLAVE_LOGO_SVG);
root.appendChild(h("button", {
className: issue.kind === "error" ? "hce-pill" : "hce-pill hce-pill-warning",
type: "button",
onClick: () => {
minimized = false;
storageRemove(MINIMIZED_STORAGE_KEY);
render(issue);
},
},
logoSpan,
h("span", null, issue.kind === "error" ? "Config error" : "Config warning")));
return;
}
const logoSpan = h("span", { className: "hce-logo" });
setHtml(logoSpan, HEXCLAVE_LOGO_SVG);
const copyButton = h("button", {
className: "hce-icon-btn hce-text-btn",
type: "button",
title: issue.kind === "error" ? "Copy error message" : "Copy warning message",
"aria-label": issue.kind === "error" ? "Copy config error message" : "Copy config warning message",
onClick: () => {
runAsynchronously(async () => {
await copyTextToClipboard(issueMessage);
copyButton.textContent = "Copied";
setTimeout(() => {
setHtml(copyButton, `${COPY_ICON_SVG}Copy`);
}, 1500);
}, {
noErrorLogging: true,
onError: (copyError) => {
captureError("pushed-config-error-overlay-copy", copyError);
copyButton.textContent = "Copy failed";
setTimeout(() => {
setHtml(copyButton, `${COPY_ICON_SVG}Copy`);
}, 1500);
},
});
},
});
setHtml(copyButton, `${COPY_ICON_SVG}Copy`);
const aiPromptCopyButton = h("button", {
className: "hce-icon-btn",
type: "button",
title: "Copy AI prompt",
"aria-label": issue.kind === "error" ? "Copy AI prompt for config error" : "Copy AI prompt for config warning",
onClick: () => {
runAsynchronously(async () => {
await copyTextToClipboard(buildConfigIssueAiPrompt(issue));
aiPromptCopyButton.textContent = "✓";
setTimeout(() => {
setHtml(aiPromptCopyButton, COPY_ICON_SVG);
}, 1500);
}, {
noErrorLogging: true,
onError: (copyError) => {
captureError("pushed-config-error-overlay-copy-ai-prompt", copyError);
aiPromptCopyButton.textContent = "!";
setTimeout(() => {
setHtml(aiPromptCopyButton, COPY_ICON_SVG);
}, 1500);
},
});
},
});
setHtml(aiPromptCopyButton, COPY_ICON_SVG);
root.appendChild(h("div", { className: "hce-backdrop" },
h("div", { className: issue.kind === "error" ? "hce-card" : "hce-card hce-card-warning", role: "alertdialog", "aria-modal": "true", "aria-label": `Hexclave config ${issueLabel}` },
h("div", { className: "hce-card-inner" },
h("div", { className: "hce-header" },
h("div", { className: "hce-title-row" },
logoSpan,
h("div", null,
h("span", { className: "hce-badge" }, `Config ${issueLabel}`),
h("div", { className: "hce-title" }, issueTitle),
),
),
h("div", { className: "hce-actions" },
aiPromptCopyButton,
h("button", {
className: "hce-icon-btn",
type: "button",
title: "Minimize",
"aria-label": issue.kind === "error" ? "Minimize config error" : "Minimize config warning",
onClick: () => {
minimized = true;
storageSet(MINIMIZED_STORAGE_KEY, issueKey);
render(issue);
},
}, ""),
),
),
h("div", { className: "hce-body" },
bodyText,
h("div", { className: "hce-message-header" },
h("div", { className: "hce-message-label" }, issue.kind === "error" ? "Error message" : "Warning message"),
copyButton,
),
h("div", { className: "hce-message" }, issueMessage),
h("div", { className: "hce-footer" }, footerText),
),
),
)));
};
const refresh = () => {
if (disposed || !canMountIntoDom()) {
return;
}
runAsynchronously(async () => {
const project = await app.getProject();
if (disposed) {
return;
}
render(project.pushedConfigError == null
? project.configWarnings.length === 0
? null
: { kind: "warning", messages: project.configWarnings.map((warning) => warning.message) }
: { kind: "error", messages: [project.pushedConfigError.message] });
}, {
noErrorLogging: true,
onError: (error) => {
captureError("pushed-config-error-overlay-refresh", error);
},
});
};
refresh();
const interval = setInterval(refresh, REFRESH_INTERVAL_MS);
const cleanup = () => {
disposed = true;
clearInterval(interval);
root.remove();
if (getGlobalUiInstance(GLOBAL_INSTANCE_KEY)?.cleanup === cleanup) {
setGlobalUiInstance(GLOBAL_INSTANCE_KEY, null);
}
};
setGlobalUiInstance(GLOBAL_INSTANCE_KEY, { cleanup });
return cleanup;
}
// END_PLATFORM