From d9a44b51c73f96e14640f701c36ec60f19cca12c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:25:19 +0000 Subject: [PATCH] fix: parseStaticConfigLiteral to handle JS object syntax via Babel parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/dashboard/src/lib/github-config-push.ts | 7 +- packages/shared/src/config-rendering.ts | 107 +++++++++++++++---- 2 files changed, 92 insertions(+), 22 deletions(-) diff --git a/apps/dashboard/src/lib/github-config-push.ts b/apps/dashboard/src/lib/github-config-push.ts index d64c59eb8..78bf650d4 100644 --- a/apps/dashboard/src/lib/github-config-push.ts +++ b/apps/dashboard/src/lib/github-config-push.ts @@ -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)) { diff --git a/packages/shared/src/config-rendering.ts b/packages/shared/src/config-rendering.ts index e4bb60209..4803b60a5 100644 --- a/packages/shared/src/config-rendering.ts +++ b/packages/shared/src/config-rendering.ts @@ -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 { - 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 = {}; + 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` 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 | null { - // Match `export const config ... = ;` — 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 | null { + let ast: babelParser.ParseResult; 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; + } + 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 } }); });