diff --git a/packages/shared/src/config-eval.ts b/packages/shared/src/config-eval.ts index 6f9e73cc0..6a7fe2655 100644 --- a/packages/shared/src/config-eval.ts +++ b/packages/shared/src/config-eval.ts @@ -1,30 +1,12 @@ import { existsSync, readFileSync } from "fs"; import { createJiti } from "jiti"; import path from "path"; +import { detectConfigImportPackage } from "./config-rendering"; export { hexclaveConfigFileExportsConfig } from "./hexclave-config-file"; const jiti = createJiti(import.meta.url, { moduleCache: false }); -/** - * Packages that export the `HexclaveConfig` type, in priority order. - * The first match found in a project's dependencies wins. Hexclave-branded - * packages come first (canonical); the legacy `@stackframe/*` names remain - * so projects pinned to the last legacy release still render a config file - * that compiles against their installed SDK. - */ -const CONFIG_IMPORT_PACKAGES = [ - "@hexclave/next", - "@hexclave/react", - "@hexclave/tanstack-start", - "@hexclave/js", - "@hexclave/template", - "@stackframe/stack", - "@stackframe/react", - "@stackframe/js", - "@stackframe/template", -] as const; - /** * Walks up from `dir` to find the nearest `package.json` and returns the * best SDK package to use for the `HexclaveConfig` type import. @@ -40,12 +22,7 @@ export function detectImportPackageFromDir(dir: string): string | undefined { ...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {}), ]; - for (const known of CONFIG_IMPORT_PACKAGES) { - if (deps.includes(known)) { - return known; - } - } - return undefined; + return detectConfigImportPackage(deps); } catch { return undefined; } diff --git a/packages/shared/src/config-rendering.ts b/packages/shared/src/config-rendering.ts index 4803b60a5..8234ec33a 100644 --- a/packages/shared/src/config-rendering.ts +++ b/packages/shared/src/config-rendering.ts @@ -60,6 +60,10 @@ 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` @@ -67,10 +71,15 @@ export function renderConfigFileContent(config: unknown, importPackage?: string) * identifiers, template literals, etc.). */ function evaluateLiteralNode(node: t.Node): unknown { + // Unwrap TS type assertions so `{ ... } satisfies T` / `{ ... } as const` resolve. + if (t.isTSAsExpression(node) || t.isTSSatisfiesExpression(node)) { + return evaluateLiteralNode(node.expression); + } if (t.isObjectExpression(node)) { const result: Record = {}; for (const prop of node.properties) { if (t.isSpreadElement(prop) || !t.isObjectProperty(prop)) return undefined; + if (prop.computed) return undefined; const key = t.isIdentifier(prop.key) ? prop.key.name : t.isStringLiteral(prop.key) @@ -79,7 +88,7 @@ function evaluateLiteralNode(node: t.Node): unknown { ? String(prop.key.value) : undefined; if (key === undefined) return undefined; - const value = evaluateLiteralNode(prop.value as t.Expression); + const value = evaluateLiteralNode(prop.value); if (value === undefined) return undefined; result[key] = value; } @@ -136,9 +145,7 @@ export function parseStaticConfigLiteral(content: string): Record; - } + if (isRecord(value)) return value; return null; } } @@ -225,3 +232,17 @@ export const config: HexclaveConfig = { };`; expect(parseStaticConfigLiteral(content)).toEqual({ teams: { allowClientTeamCreation: false } }); }); + +import.meta.vitest?.test("parseStaticConfigLiteral rejects computed property keys", ({ expect }) => { + expect(parseStaticConfigLiteral('const key = "a"; export const config = { [key]: true };')).toBeNull(); +}); + +import.meta.vitest?.test("parseStaticConfigLiteral unwraps TS `satisfies` assertion", ({ expect }) => { + const content = `import type { HexclaveConfig } from "@hexclave/next"; +export const config = { auth: { allowSignUp: true } } satisfies HexclaveConfig;`; + expect(parseStaticConfigLiteral(content)).toEqual({ auth: { allowSignUp: true } }); +}); + +import.meta.vitest?.test("parseStaticConfigLiteral unwraps TS `as const` assertion", ({ expect }) => { + expect(parseStaticConfigLiteral('export const config = { enabled: true } as const;')).toEqual({ enabled: true }); +});