mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
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:
parent
a6c46db72c
commit
df52acb94f
@ -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}`);
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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 } };
|
||||
`;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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".`);
|
||||
}
|
||||
|
||||
2698
pnpm-lock.yaml
2698
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user