refactor: migrate config parsing from Babel AST to jiti

Replace parseHexclaveConfigFileContent and evaluateStaticConfigExpression
with jiti-based evalConfigFileContent. Move renderConfigFileContent from
hexclave-config-file.ts to config-rendering.ts alongside the new eval
function.

Removed functions:
- parseHexclaveConfigFileContent (Babel AST walker)
- tryParseHexclaveConfigFileContent
- evaluateStaticConfigExpression
- unwrapStaticConfigExpression

Added jiti dep to @hexclave/shared since config-rendering.ts now uses
jiti.evalModule for runtime evaluation of config file content strings.

Co-Authored-By: mantra <mantra@stack-auth.com>
This commit is contained in:
Devin AI 2026-06-23 23:38:10 +00:00
parent a6c46db72c
commit df52acb94f
8 changed files with 1486 additions and 1464 deletions

View File

@ -1,7 +1,6 @@
import { globalPrismaClient } from "@/prisma-client";
import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/config-authoring";
import { detectImportPackageFromDir, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
import { parseHexclaveConfigFileContent } from "@hexclave/shared/dist/hexclave-config-file";
import { detectImportPackageFromDir, evalConfigFileContent, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
import { isValidConfig } from "@hexclave/shared/dist/config/format";
import { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD } from "@hexclave/shared/dist/local-emulator";
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
@ -76,7 +75,7 @@ async function readConfigContent(filePath: string): Promise<string> {
async function readConfigValueFromFile(filePath: string): Promise<LocalEmulatorConfigValue> {
const content = await readConfigContent(filePath);
try {
return parseHexclaveConfigFileContent(content, filePath);
return evalConfigFileContent(content, filePath);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
throw new StatusError(StatusError.BadRequest, `Error evaluating config in ${filePath}: ${message}`);

View File

@ -12,7 +12,8 @@
import type { PushedConfigSource } from "@hexclave/next";
import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema";
import { isValidConfig, override } from "@hexclave/shared/dist/config/format";
import { parseHexclaveConfigFileContent, renderConfigFileContent, showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/hexclave-config-file";
import { evalConfigFileContent, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/hexclave-config-file";
import {
commitFile,
@ -51,7 +52,7 @@ export function buildUpdatedConfigFileContent(
currentFileContent: string,
configUpdate: EnvironmentConfigOverrideOverride,
): string {
const parsed = parseHexclaveConfigFileContent(currentFileContent, "stack.config.ts");
const parsed = evalConfigFileContent(currentFileContent, "stack.config.ts");
if (parsed === showOnboardingHexclaveConfigValue) {
throw new Error(
"The config file currently exports the onboarding placeholder. Finish setting up Hexclave in your repo before pushing dashboard changes."

View File

@ -57,7 +57,7 @@ afterEach(() => {
}
});
// Config with an import triggers the agent path (tryParseHexclaveConfigFileContent returns null)
// Config with an import triggers the agent path (tryParseConfigFileContent returns null)
const CUSTOM_CONFIG = `import emailHtml from "./emails/welcome.html" with { type: "text" };
export const config = { auth: { allowSignUp: true }, emails: { welcomeHtml: emailHtml } };
`;

View File

@ -1,5 +1,5 @@
import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/config-authoring";
import { detectImportPackageFromDir, parseHexclaveConfigFileContent, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
import { detectImportPackageFromDir, evalConfigFileContent, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
import type { Config, ConfigValue, NormalizedConfig } from "@hexclave/shared/dist/config/format";
import { isValidConfig, normalize, override } from "@hexclave/shared/dist/config/format";
import { captureError } from "@hexclave/shared/dist/utils/errors";
@ -132,7 +132,7 @@ export async function updateConfigObject(configFilePath: string, configUpdate: C
// Fast path: if the config is a plain static literal (no imports, no helpers),
// apply the update deterministically without invoking the AI agent.
const staticConfig = tryParseStaticConfigFileContent(content, configFilePath);
const staticConfig = tryParseConfigFileContent(content, configFilePath);
if (staticConfig != null && isValidConfig(staticConfig)) {
const merged = override(staticConfig, configUpdate);
if (!isValidConfig(merged)) {
@ -304,9 +304,9 @@ async function validateAgentUpdate(configFilePath: string, baselineConfig: Confi
}
}
function tryParseStaticConfigFileContent(content: string, configFilePath: string): Config | null {
function tryParseConfigFileContent(content: string, configFilePath: string): Config | null {
try {
const parsed = parseHexclaveConfigFileContent(content, configFilePath);
const parsed = evalConfigFileContent(content, configFilePath);
return isValidConfig(parsed) ? parsed : null;
} catch {
return null;
@ -315,12 +315,9 @@ function tryParseStaticConfigFileContent(content: string, configFilePath: string
function configFileExportsConfig(content: string, configFilePath: string): boolean {
try {
parseHexclaveConfigFileContent(content, configFilePath);
evalConfigFileContent(content, configFilePath);
return true;
} catch {
// Dynamic configs can be valid even when the static parser cannot evaluate
// them. For the structural fallback we only need to know that a runtime
// config binding still exists after the agent edited the file.
return /\bexport\s+const\s+config\b/.test(content);
}
}

View File

@ -78,6 +78,7 @@
"elliptic": "^6.5.7",
"esbuild-wasm": "^0.20.2",
"ip-regex": "^5.0.0",
"jiti": "^2.4.2",
"jose": "^6.1.3",
"oauth4webapi": "^3.8.3",
"semver": "^7.6.3"

View File

@ -1,7 +1,13 @@
import { existsSync, readFileSync } from "fs";
import { createJiti } from "jiti";
import path from "path";
import { hexclaveConfigFileExportsConfig, parseHexclaveConfigFileContent, renderConfigFileContent, tryParseHexclaveConfigFileContent } from "./hexclave-config-file";
export { hexclaveConfigFileExportsConfig, parseHexclaveConfigFileContent, renderConfigFileContent, tryParseHexclaveConfigFileContent };
import { isValidConfig, normalize } from "./config/format";
import { hexclaveConfigFileExportsConfig } from "./hexclave-config-file";
export { hexclaveConfigFileExportsConfig };
const DEFAULT_CONFIG_IMPORT_PACKAGE = "@hexclave/js";
const jiti = createJiti(import.meta.url, { moduleCache: false });
/**
* Packages that export the `HexclaveConfig` type, in priority order.
@ -63,6 +69,62 @@ export function detectImportPackageFromDir(dir: string): string | undefined {
return undefined;
}
/**
* Renders a config object into the source text of a `stack.config.ts` file.
*/
export function renderConfigFileContent(config: unknown, importPackage?: string): string {
if (!isValidConfig(config)) {
throw new Error("Invalid config: expected a plain object.");
}
const droppedKeys: string[] = [];
const normalizedConfig = normalize(config, {
onDotIntoNonObject: "ignore",
onDotIntoNull: "empty-object",
droppedKeys,
});
if (droppedKeys.length > 0) {
throw new Error(`Config has conflicting keys that would be dropped during normalization: ${droppedKeys.map(k => JSON.stringify(k)).join(", ")}`);
}
const pkg = importPackage ?? DEFAULT_CONFIG_IMPORT_PACKAGE;
const importSpecifier = pkg.startsWith("@hexclave/") ? `${pkg}/config` : pkg;
const importLine = `import type { HexclaveConfig } from "${importSpecifier}";`;
return `${importLine}\n\nexport const config: HexclaveConfig = ${JSON.stringify(normalizedConfig, null, 2)};\n`;
}
type ParsedConfigValue = Record<string, unknown> | string;
/**
* Evaluates config file content using jiti and returns the exported `config`
* value. Replaces the old Babel AST-based `parseHexclaveConfigFileContent`.
*/
export function evalConfigFileContent(content: string, filePath: string): ParsedConfigValue {
if (content.trim() === "") return {};
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
const mod = jiti.evalModule(content, { filename: resolvedPath }) as Record<string, unknown>;
const config = mod.config;
if (config === undefined) {
throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
}
if (typeof config === "string") return config;
if (config !== null && typeof config === "object" && !Array.isArray(config)) {
return config as Record<string, unknown>;
}
throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
}
/**
* Like {@link evalConfigFileContent}, but returns `null` instead of throwing
* when the content cannot be evaluated.
*/
export function tryEvalConfigFileContent(content: string, filePath: string): ParsedConfigValue | null {
try {
return evalConfigFileContent(content, filePath);
} catch {
return null;
}
}
import.meta.vitest?.test("renderConfigFileContent normalizes config exports", ({ expect }) => {
expect(renderConfigFileContent({
"payments.items.todos.displayName": "Todo Slots",
@ -79,8 +141,8 @@ import.meta.vitest?.test("renderConfigFileContent normalizes config exports", ({
};`);
});
import.meta.vitest?.test("parseHexclaveConfigFileContent parses static config exports", ({ expect }) => {
expect(parseHexclaveConfigFileContent(`
import.meta.vitest?.test("evalConfigFileContent parses static config exports", ({ expect }) => {
expect(evalConfigFileContent(`
import type { StackConfig } from "@hexclave/js";
export const config: StackConfig = {
auth: { allowSignUp: true },
@ -98,27 +160,22 @@ import.meta.vitest?.test("parseHexclaveConfigFileContent parses static config ex
`);
});
import.meta.vitest?.test("parseHexclaveConfigFileContent parses show-onboarding", ({ expect }) => {
expect(parseHexclaveConfigFileContent('export const config = "show-onboarding";', "stack.config.ts")).toBe("show-onboarding");
import.meta.vitest?.test("evalConfigFileContent parses show-onboarding", ({ expect }) => {
expect(evalConfigFileContent('export const config = "show-onboarding";', "stack.config.ts")).toBe("show-onboarding");
});
import.meta.vitest?.test("parseHexclaveConfigFileContent rejects dynamic config exports", ({ expect }) => {
expect(() => parseHexclaveConfigFileContent("export const config = makeConfig();", "stack.config.ts")).toThrow(/Unsupported config expression/);
import.meta.vitest?.test("evalConfigFileContent rejects content without config export", ({ expect }) => {
expect(() => evalConfigFileContent("export const other = {};", "stack.config.ts")).toThrow(/must export/);
});
import.meta.vitest?.test("tryParseHexclaveConfigFileContent returns the config for static exports", ({ expect }) => {
expect(tryParseHexclaveConfigFileContent("export const config = { auth: { allowSignUp: true } };", "stack.config.ts")).toEqual({
import.meta.vitest?.test("tryEvalConfigFileContent returns the config for valid exports", ({ expect }) => {
expect(tryEvalConfigFileContent("export const config = { auth: { allowSignUp: true } };", "stack.config.ts")).toEqual({
auth: { allowSignUp: true },
});
});
import.meta.vitest?.test("tryParseHexclaveConfigFileContent returns null for non-static exports", ({ expect }) => {
// Wrapped in a helper call (e.g. defineStackConfig) -> not a plain literal.
expect(tryParseHexclaveConfigFileContent("export const config = makeConfig();", "stack.config.ts")).toBeNull();
// References an imported value -> has structure to preserve.
expect(tryParseHexclaveConfigFileContent('import x from "./x.txt" with { type: "text" };\nexport const config = { a: x };', "stack.config.ts")).toBeNull();
// Syntax error.
expect(tryParseHexclaveConfigFileContent("export const config = {", "stack.config.ts")).toBeNull();
import.meta.vitest?.test("tryEvalConfigFileContent returns null on failure", ({ expect }) => {
expect(tryEvalConfigFileContent("export const config = {", "stack.config.ts")).toBeNull();
});
import.meta.vitest?.test("hexclaveConfigFileExportsConfig detects a config export", ({ expect }) => {

View File

@ -1,120 +1,8 @@
import * as parser from "@babel/parser";
import * as t from "@babel/types";
import { isValidConfig, normalize } from "./config/format";
export const showOnboardingHexclaveConfigValue = "show-onboarding";
const DEFAULT_CONFIG_IMPORT_PACKAGE = "@hexclave/js";
/**
* Renders a config object into the source text of a `stack.config.ts` file.
*
* Browser-safe: kept here (next to `parseHexclaveConfigFileContent`) instead of in
* `config-rendering.ts` so dashboard client code can render config files
* without pulling in `fs` / `path`.
*/
export function renderConfigFileContent(config: unknown, importPackage?: string): string {
if (!isValidConfig(config)) {
throw new Error("Invalid config: expected a plain object.");
}
const droppedKeys: string[] = [];
const normalizedConfig = normalize(config, {
onDotIntoNonObject: "ignore",
onDotIntoNull: "empty-object",
droppedKeys,
});
if (droppedKeys.length > 0) {
throw new Error(`Config has conflicting keys that would be dropped during normalization: ${droppedKeys.map(k => JSON.stringify(k)).join(", ")}`);
}
const pkg = importPackage ?? DEFAULT_CONFIG_IMPORT_PACKAGE;
// Import the `HexclaveConfig` type from the package's lightweight `/config`
// entrypoint, which is free of framework runtime code and therefore safe for
// tooling (e.g. the local dashboard) to load in a plain Node context. Only the
// Hexclave-branded packages expose this subpath; legacy `@stackframe/*`
// releases predate it, so fall back to their package root.
const importSpecifier = pkg.startsWith("@hexclave/") ? `${pkg}/config` : pkg;
const importLine = `import type { HexclaveConfig } from "${importSpecifier}";`;
return `${importLine}\n\nexport const config: HexclaveConfig = ${JSON.stringify(normalizedConfig, null, 2)};\n`;
}
type ParsedStackConfig = Record<string, unknown> | typeof showOnboardingHexclaveConfigValue;
function unwrapStaticConfigExpression(expression: t.Expression): t.Expression {
if (
t.isTSAsExpression(expression)
|| t.isTSSatisfiesExpression(expression)
|| t.isTSTypeAssertion(expression)
|| t.isTSNonNullExpression(expression)
) {
return unwrapStaticConfigExpression(expression.expression);
}
return expression;
}
function evaluateStaticConfigExpression(expression: t.Expression): unknown {
const unwrapped = unwrapStaticConfigExpression(expression);
if (t.isStringLiteral(unwrapped)) return unwrapped.value;
if (t.isBooleanLiteral(unwrapped)) return unwrapped.value;
if (t.isNumericLiteral(unwrapped)) return unwrapped.value;
if (t.isNullLiteral(unwrapped)) return null;
if (t.isIdentifier(unwrapped) && unwrapped.name === "undefined") return undefined;
if (t.isUnaryExpression(unwrapped) && unwrapped.operator === "-" && t.isNumericLiteral(unwrapped.argument)) {
return -unwrapped.argument.value;
}
if (t.isArrayExpression(unwrapped)) {
return unwrapped.elements.map((element) => {
if (element == null || t.isSpreadElement(element)) {
throw new Error("Config arrays cannot contain holes or spreads.");
}
return evaluateStaticConfigExpression(element);
});
}
if (t.isObjectExpression(unwrapped)) {
const result: Record<string, unknown> = {};
for (const property of unwrapped.properties) {
if (t.isSpreadElement(property)) {
throw new Error("Config objects cannot contain spreads.");
}
if (property.computed) {
throw new Error("Config object keys cannot be computed.");
}
const key = t.isIdentifier(property.key)
? property.key.name
: t.isStringLiteral(property.key) || t.isNumericLiteral(property.key)
? String(property.key.value)
: null;
if (key == null) {
throw new Error("Unsupported config object key.");
}
if (t.isObjectMethod(property)) {
throw new Error("Config objects cannot contain methods.");
}
if (!t.isExpression(property.value)) {
throw new Error("Unsupported config object value.");
}
result[key] = evaluateStaticConfigExpression(property.value);
}
return result;
}
throw new Error(`Unsupported config expression: ${unwrapped.type}`);
}
/**
* Like {@link parseHexclaveConfigFileContent}, but returns `null` instead of
* throwing when the file is not a plain static config (e.g. it wraps the config
* in a helper call, references imported values, or has a syntax error). Useful
* for deciding whether a config file can be safely regenerated deterministically
* or whether it has custom structure that must be preserved.
*/
export function tryParseHexclaveConfigFileContent(content: string, filePath: string): ParsedStackConfig | null {
try {
return parseHexclaveConfigFileContent(content, filePath);
} catch {
return null;
}
}
/**
* Returns whether `content` parses as a module that exports a `config` binding.
* Used as a lightweight structural sanity check after editing config files whose
@ -189,27 +77,4 @@ export function getRelativeImportSpecifiers(content: string): string[] {
return sources;
}
export function parseHexclaveConfigFileContent(content: string, filePath: string): ParsedStackConfig {
if (content.trim() === "") return {};
const ast = parser.parse(content, {
sourceType: "module",
plugins: ["typescript", "importAttributes"],
});
for (const statement of ast.program.body) {
if (!t.isExportNamedDeclaration(statement) || !t.isVariableDeclaration(statement.declaration)) {
continue;
}
for (const declaration of statement.declaration.declarations) {
if (!t.isIdentifier(declaration.id) || declaration.id.name !== "config") {
continue;
}
if (declaration.init == null || !t.isExpression(declaration.init)) {
throw new Error(`Config export in ${filePath} must have an initializer.`);
}
return evaluateStaticConfigExpression(declaration.init) as ParsedStackConfig;
}
}
throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
}

File diff suppressed because it is too large Load Diff