mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
fix: parseStaticConfigLiteral to handle JS object syntax via Babel parser
Replace JSON.parse with Babel parser + static AST evaluation in parseStaticConfigLiteral. This correctly handles JavaScript object literal syntax (unquoted keys, trailing commas) that real config files use. Also differentiate error cases in buildUpdatedConfigFileContent: - null → invalid/missing config export - string → show-onboarding placeholder (different error message) - object → valid config to merge into Co-Authored-By: mantra <mantra@stack-auth.com>
This commit is contained in:
parent
97b1a2f960
commit
d9a44b51c7
@ -54,7 +54,12 @@ export function buildUpdatedConfigFileContent(
|
||||
const parsed = parseStaticConfigLiteral(currentFileContent);
|
||||
if (parsed == null) {
|
||||
throw new Error(
|
||||
"Could not parse the existing config file. The file must be a static JSON config object (as produced by the dashboard). Config files with dynamic expressions or imports are not supported for dashboard-driven updates."
|
||||
"Invalid config in stack.config.ts. The file must export a plain `config` object or \"show-onboarding\"."
|
||||
);
|
||||
}
|
||||
if (typeof parsed === "string") {
|
||||
throw new Error(
|
||||
"The config file currently exports the onboarding placeholder. Finish setting up Hexclave in your repo before pushing dashboard changes."
|
||||
);
|
||||
}
|
||||
if (!isValidConfig(parsed)) {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import * as babelParser from "@babel/parser";
|
||||
import * as t from "@babel/types";
|
||||
import { isValidConfig, normalize } from "./config/format";
|
||||
|
||||
const DEFAULT_CONFIG_IMPORT_PACKAGE = "@hexclave/js";
|
||||
@ -58,34 +60,89 @@ export function renderConfigFileContent(config: unknown, importPackage?: string)
|
||||
return `${importLine}\n\nexport const config: HexclaveConfig = ${JSON.stringify(normalizedConfig, null, 2)};\n`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
/**
|
||||
* Statically evaluates a Babel AST node representing a JSON-like literal
|
||||
* (objects, arrays, strings, numbers, booleans, null). Returns `undefined`
|
||||
* for nodes that can't be resolved without execution (function calls,
|
||||
* identifiers, template literals, etc.).
|
||||
*/
|
||||
function evaluateLiteralNode(node: t.Node): unknown {
|
||||
if (t.isObjectExpression(node)) {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const prop of node.properties) {
|
||||
if (t.isSpreadElement(prop) || !t.isObjectProperty(prop)) return undefined;
|
||||
const key = t.isIdentifier(prop.key)
|
||||
? prop.key.name
|
||||
: t.isStringLiteral(prop.key)
|
||||
? prop.key.value
|
||||
: t.isNumericLiteral(prop.key)
|
||||
? String(prop.key.value)
|
||||
: undefined;
|
||||
if (key === undefined) return undefined;
|
||||
const value = evaluateLiteralNode(prop.value as t.Expression);
|
||||
if (value === undefined) return undefined;
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (t.isArrayExpression(node)) {
|
||||
const result: unknown[] = [];
|
||||
for (const element of node.elements) {
|
||||
if (element == null || t.isSpreadElement(element)) return undefined;
|
||||
const value = evaluateLiteralNode(element);
|
||||
if (value === undefined) return undefined;
|
||||
result.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (t.isStringLiteral(node)) return node.value;
|
||||
if (t.isNumericLiteral(node)) return node.value;
|
||||
if (t.isBooleanLiteral(node)) return node.value;
|
||||
if (t.isNullLiteral(node)) return null;
|
||||
if (t.isUnaryExpression(node) && node.operator === "-" && t.isNumericLiteral(node.argument)) {
|
||||
return -node.argument.value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a config file that was produced by {@link renderConfigFileContent}.
|
||||
* Extracts the JSON object literal after the `export const config` assignment
|
||||
* and parses it with `JSON.parse`. This is intentionally limited to the exact
|
||||
* format we emit — it never executes code, so it is safe for untrusted input
|
||||
* such as content fetched from a remote repository.
|
||||
* Parses a config file and extracts the exported `config` value by statically
|
||||
* evaluating the AST. Handles both JSON-formatted and JavaScript object literal
|
||||
* syntax (unquoted keys, trailing commas). Never executes code, so it is safe
|
||||
* for untrusted input such as content fetched from a remote repository.
|
||||
*
|
||||
* Returns `null` when the content does not match the expected static format.
|
||||
* Returns:
|
||||
* - `Record<string, unknown>` when the config exports an object literal
|
||||
* - `string` when the config exports a string literal (e.g. "show-onboarding")
|
||||
* - `null` when no `config` export is found or the expression can't be
|
||||
* statically resolved
|
||||
*/
|
||||
export function parseStaticConfigLiteral(content: string): Record<string, unknown> | null {
|
||||
// Match `export const config ... = <json>;` — the type annotation is optional.
|
||||
const match = content.match(/export\s+const\s+config(?:\s*:\s*\w+)?\s*=\s*([\s\S]+);?\s*$/m);
|
||||
if (match == null) return null;
|
||||
|
||||
// Trim any trailing semicolons and whitespace from the captured JSON body.
|
||||
const jsonBody = match[1].replace(/;\s*$/, "").trim();
|
||||
|
||||
export function parseStaticConfigLiteral(content: string): Record<string, unknown> | string | null {
|
||||
let ast: babelParser.ParseResult<t.File>;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(jsonBody);
|
||||
if (isRecord(parsed)) return parsed;
|
||||
return null;
|
||||
ast = babelParser.parse(content, {
|
||||
sourceType: "module",
|
||||
plugins: ["typescript", "importAttributes"],
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const statement of ast.program.body) {
|
||||
if (!t.isExportNamedDeclaration(statement)) continue;
|
||||
if (!t.isVariableDeclaration(statement.declaration)) continue;
|
||||
for (const decl of statement.declaration.declarations) {
|
||||
if (!t.isIdentifier(decl.id) || decl.id.name !== "config" || decl.init == null) continue;
|
||||
const value = evaluateLiteralNode(decl.init);
|
||||
if (value === undefined) return null;
|
||||
if (typeof value === "string") return value;
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- inline vitest tests ---
|
||||
@ -157,6 +214,14 @@ import.meta.vitest?.test("parseStaticConfigLiteral returns null for non-static c
|
||||
expect(parseStaticConfigLiteral("")).toBeNull();
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("parseStaticConfigLiteral returns null for show-onboarding string", ({ expect }) => {
|
||||
expect(parseStaticConfigLiteral('export const config = "show-onboarding";')).toBeNull();
|
||||
import.meta.vitest?.test("parseStaticConfigLiteral returns the string for show-onboarding export", ({ expect }) => {
|
||||
expect(parseStaticConfigLiteral('export const config = "show-onboarding";')).toBe("show-onboarding");
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("parseStaticConfigLiteral handles JS object syntax (unquoted keys, trailing commas)", ({ expect }) => {
|
||||
const content = `import type { HexclaveConfig } from "@hexclave/next";
|
||||
export const config: HexclaveConfig = {
|
||||
teams: { allowClientTeamCreation: false },
|
||||
};`;
|
||||
expect(parseStaticConfigLiteral(content)).toEqual({ teams: { allowClientTeamCreation: false } });
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user