stack/packages/stack-shared/src/config-rendering.ts
Mantra 37a69b0f0a
make config typesafe (#1254)
## Summary
- add a public `defineStackConfig` helper and `StackConfig` type for
nested config authoring
- emit helper-based nested config files from the CLI and local emulator
- update type coverage and e2e expectations for the new `stack.config`
format

## Testing
- pnpm --filter ./packages/stack-shared typecheck
- pnpm --filter ./packages/stack-cli typecheck
- pnpm --filter ./apps/backend typecheck
- pnpm --filter ./apps/e2e typecheck

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Type-safe configuration API with compile-time validation
  * New config rendering utility for producing typed config files
* Public local-emulator settings and a public helper to detect emulator
mode
  * Added --overwrite flag for config pull

* **Improvements**
* Stronger validation and clearer errors for invalid or conflicting
config shapes
  * Config output now includes explicit TypeScript typing

* **Tests**
* Added and strengthened tests for config authoring, rendering, CLI
behavior, and emulator flows
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-06 18:31:55 +00:00

125 lines
4.5 KiB
TypeScript

import { existsSync, readFileSync } from "fs";
import path from "path";
import { isValidConfig, normalize } from "./config/format";
/**
* Packages that export the `StackConfig` type, in priority order.
* The first match found in a project's dependencies wins.
*/
const STACKFRAME_CONFIG_PACKAGES = [
"@stackframe/stack",
"@stackframe/react",
"@stackframe/js",
"@stackframe/template",
] as const;
const DEFAULT_CONFIG_IMPORT_PACKAGE = "@stackframe/js";
/**
* Given a list of dependency names (from package.json), returns the
* `@stackframe/*` package that should be used for the `StackConfig` import,
* or `undefined` if none of the known packages are installed.
*/
export function detectStackframeImportPackage(dependencies: string[]): string | undefined {
for (const pkg of STACKFRAME_CONFIG_PACKAGES) {
if (dependencies.includes(pkg)) {
return pkg;
}
}
return undefined;
}
/**
* Walks up from `dir` to find the nearest `package.json` and returns the
* best `@stackframe/*` package to use for the `StackConfig` type import.
*/
export function detectImportPackageFromDir(dir: string): string | undefined {
let current = dir;
while (true) {
const pkgPath = path.join(current, "package.json");
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
const deps = [
...Object.keys(pkg.dependencies ?? {}),
...Object.keys(pkg.devDependencies ?? {}),
];
return detectStackframeImportPackage(deps);
} catch {
return undefined;
}
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return undefined;
}
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 importLine = `import type { StackConfig } from "${pkg}";`;
return `${importLine}\n\nexport const config: StackConfig = ${JSON.stringify(normalizedConfig, null, 2)};\n`;
}
import.meta.vitest?.test("renderConfigFileContent normalizes config exports", ({ expect }) => {
expect(renderConfigFileContent({
"payments.items.todos.displayName": "Todo Slots",
"payments.items.todos.customerType": "user",
})).toContain(`export const config: StackConfig = {
"payments": {
"items": {
"todos": {
"displayName": "Todo Slots",
"customerType": "user"
}
}
}
};`);
});
import.meta.vitest?.test("renderConfigFileContent rejects conflicting dotted keys", ({ expect }) => {
expect(() => renderConfigFileContent({
"a.b": 1,
"a.b.c": 2,
})).toThrowError(/conflicting keys.*"a\.b\.c"/);
});
import.meta.vitest?.test("renderConfigFileContent rejects invalid config exports", ({ expect }) => {
expect(() => renderConfigFileContent(null)).toThrowErrorMatchingInlineSnapshot(
`[Error: Invalid config: expected a plain object.]`,
);
});
import.meta.vitest?.test("renderConfigFileContent uses custom import package", ({ expect }) => {
const content = renderConfigFileContent({}, "@stackframe/stack");
expect(content).toContain('import type { StackConfig } from "@stackframe/stack";');
});
import.meta.vitest?.test("renderConfigFileContent defaults to @stackframe/js", ({ expect }) => {
const content = renderConfigFileContent({});
expect(content).toContain('import type { StackConfig } from "@stackframe/js";');
});
import.meta.vitest?.test("detectStackframeImportPackage picks first matching package by priority", ({ expect }) => {
expect(detectStackframeImportPackage(["@stackframe/stack", "@stackframe/js"])).toBe("@stackframe/stack");
expect(detectStackframeImportPackage(["@stackframe/react", "@stackframe/js"])).toBe("@stackframe/react");
expect(detectStackframeImportPackage(["@stackframe/js"])).toBe("@stackframe/js");
expect(detectStackframeImportPackage(["@stackframe/template"])).toBe("@stackframe/template");
expect(detectStackframeImportPackage(["lodash", "express"])).toBeUndefined();
expect(detectStackframeImportPackage([])).toBeUndefined();
});