mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
- index/commit route: gate commit_hash advance on committedRef identity so a mid-run repo re-link can't stamp a foreign commit SHA (cross-repo TOCTOU) - github-push-dialog: cancel handler now settles the dialog itself instead of relying on a poll loop that has already exited at awaiting_review - progress-content: useElapsedSeconds reacts to startedAt changes (fresh anchor) so a post-mount start time no longer freezes a stale offset - schema-fields: configAgentRunSchema.id uses .uuid() to match the @db.Uuid column - tests: cover the SyntaxError config-eval path and the re-link commit-hash case
138 lines
5.3 KiB
TypeScript
138 lines
5.3 KiB
TypeScript
import { existsSync, readFileSync } from "fs";
|
|
import { createJiti } from "jiti";
|
|
import path from "path";
|
|
import { showOnboardingHexclaveConfigValue } from "./config-authoring";
|
|
import { detectConfigImportPackage } from "./config-rendering";
|
|
|
|
const jiti = createJiti(import.meta.url, { moduleCache: false });
|
|
|
|
/**
|
|
* Thrown when a config file evaluates successfully but its exported `config`
|
|
* isn't a usable shape (missing, or not an object / "show-onboarding" string).
|
|
* Distinct from the underlying loader errors jiti throws, so callers can tell a
|
|
* malformed config apart from a file that simply failed to load.
|
|
*/
|
|
export class ConfigFileEvalError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "ConfigFileEvalError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Walks up from `dir` to find the nearest `package.json` and returns the
|
|
* best SDK package to use for the `HexclaveConfig` 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 detectConfigImportPackage(deps);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
const parent = path.dirname(current);
|
|
if (parent === current) break;
|
|
current = parent;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
/** A config object, or the `"show-onboarding"` sentinel that stands in for one. */
|
|
export type ParsedConfigValue = Record<string, unknown> | typeof showOnboardingHexclaveConfigValue;
|
|
|
|
function invalidConfigShape(filePath: string): ConfigFileEvalError {
|
|
return new ConfigFileEvalError(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "${showOnboardingHexclaveConfigValue}".`);
|
|
}
|
|
|
|
/**
|
|
* Evaluates config file content using jiti and returns the exported `config`
|
|
* value.
|
|
*
|
|
* WARNING: This executes arbitrary code via `jiti.evalModule` — only use on
|
|
* content that is fully operator-controlled (local filesystem). Never call
|
|
* this on untrusted input (e.g. content fetched from a remote repository).
|
|
*/
|
|
export function evalConfigFileContent(content: string, filePath: string): ParsedConfigValue {
|
|
if (content.trim() === "") return {};
|
|
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
|
const mod: unknown = jiti.evalModule(content, { filename: resolvedPath });
|
|
if (!isRecord(mod)) {
|
|
throw invalidConfigShape(filePath);
|
|
}
|
|
const config = mod.config;
|
|
if (config === undefined) {
|
|
throw invalidConfigShape(filePath);
|
|
}
|
|
if (typeof config === "string") {
|
|
if (config !== showOnboardingHexclaveConfigValue) {
|
|
throw new ConfigFileEvalError(`Invalid config in ${filePath}. String config values must be "${showOnboardingHexclaveConfigValue}", got "${config}".`);
|
|
}
|
|
return config;
|
|
}
|
|
if (isRecord(config)) return config;
|
|
throw invalidConfigShape(filePath);
|
|
}
|
|
|
|
// --- inline vitest tests ---
|
|
|
|
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 },
|
|
payments: { testMode: false },
|
|
};
|
|
`, "stack.config.ts")).toMatchInlineSnapshot(`
|
|
{
|
|
"auth": {
|
|
"allowSignUp": true,
|
|
},
|
|
"payments": {
|
|
"testMode": false,
|
|
},
|
|
}
|
|
`);
|
|
});
|
|
|
|
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("evalConfigFileContent rejects content without config export", ({ expect }) => {
|
|
expect(() => evalConfigFileContent("export const other = {};", "stack.config.ts")).toThrow(/must export/);
|
|
});
|
|
|
|
import.meta.vitest?.test("evalConfigFileContent rejects arbitrary string config values", ({ expect }) => {
|
|
expect(() => evalConfigFileContent('export const config = "arbitrary-string";', "stack.config.ts")).toThrow(/must be "show-onboarding"/);
|
|
});
|
|
|
|
import.meta.vitest?.test("evalConfigFileContent rejects unresolvable config factories", ({ expect }) => {
|
|
expect(() => evalConfigFileContent("export const config = makeConfig();", "stack.config.ts")).toThrow();
|
|
});
|
|
|
|
import.meta.vitest?.test("evalConfigFileContent rejects missing config import targets", ({ expect }) => {
|
|
expect(() => evalConfigFileContent(`
|
|
import missingConfigPart from "./missing-config-part";
|
|
export const config = { auth: missingConfigPart };
|
|
`, "/tmp/hexclave-missing-import-config.ts")).toThrow();
|
|
});
|
|
|
|
import.meta.vitest?.test("evalConfigFileContent rejects syntactically invalid content", ({ expect }) => {
|
|
// jiti surfaces a ParseError (not a ConfigFileEvalError), so callers route this
|
|
// to "Failed to load config file" rather than "Invalid config".
|
|
expect(() => evalConfigFileContent("export const config = {", "stack.config.ts")).toThrow();
|
|
});
|