mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
<!--
Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md
-->
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Prepares `@hexclave/shared-backend` for npm publishing with a proper
build, dual ESM/CJS exports, and bundled types. Also simplifies an
import parsing helper for safer matching.
- New Features
- Configure build with `tsdown`; output CJS/ESM and `.d.ts` to `dist/`
and `dist/esm/`.
- Add export map for `.` and `./config-agent` with `require` and
`default` entries plus types.
- Update publish settings: set `main`/`types` to built files, include
only `dist`, exclude tests, and add `build`, `dev`, `clean` scripts.
- Add repository field and `tsdown.config.ts`.
- Bug Fixes
- Simplified import specifier parsing in `src/index.ts` to push matches
directly and remove unnecessary error throwing.
<sup>Written for commit 9f18bc019a.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1598?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
424 lines
18 KiB
TypeScript
424 lines
18 KiB
TypeScript
import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/config-authoring";
|
|
import { detectImportPackageFromDir, parseHexclaveConfigFileContent, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
|
|
import type { Config, ConfigValue, NormalizedConfig } from "@hexclave/shared/dist/config/format";
|
|
import { isValidConfig, normalize, override } from "@hexclave/shared/dist/config/format";
|
|
import { captureError } from "@hexclave/shared/dist/utils/errors";
|
|
import { createHash } from "crypto";
|
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
|
|
import { createJiti } from "jiti";
|
|
import path from "path";
|
|
import { ClaudeAgentFailureError, ClaudeAgentTimeoutError, getToolWriteTargetPath, isPathInsideDir, runHeadlessClaudeAgent } from "./config-agent";
|
|
|
|
const jiti = createJiti(import.meta.url, { moduleCache: false });
|
|
|
|
const LOG_PREFIX = "[Stack config updater]";
|
|
const DEFAULT_AGENT_TIMEOUT_MS = 120_000;
|
|
|
|
type ConfigModule = {
|
|
config?: unknown,
|
|
};
|
|
|
|
type ConfigFileSnapshot = { path: string, content: string | null };
|
|
type ConfigChange = { path: string, value: ConfigValue };
|
|
|
|
function isConfigModule(value: unknown): value is ConfigModule {
|
|
return value !== null && typeof value === "object";
|
|
}
|
|
|
|
export function sha256String(value: string): string {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
export function resolveConfigFilePath(inputPath: string): string {
|
|
const resolved = path.resolve(inputPath);
|
|
const looksLikeConfigFile = /\.(ts|js|mjs|cjs)$/i.test(resolved);
|
|
if (looksLikeConfigFile) {
|
|
return resolved;
|
|
}
|
|
// Prefer hexclave.config.ts, fall back to stack.config.ts, default to the new name.
|
|
const hexclaveCandidate = path.join(resolved, "hexclave.config.ts");
|
|
const legacyCandidate = path.join(resolved, "stack.config.ts");
|
|
if (existsSync(hexclaveCandidate)) {
|
|
return hexclaveCandidate;
|
|
}
|
|
if (existsSync(legacyCandidate)) {
|
|
return legacyCandidate;
|
|
}
|
|
return hexclaveCandidate;
|
|
}
|
|
|
|
export function ensureConfigFileExists(configFilePath: string): void {
|
|
if (existsSync(configFilePath)) return;
|
|
mkdirSync(path.dirname(configFilePath), { recursive: true });
|
|
renderConfigObjectToFile(configFilePath, {});
|
|
}
|
|
|
|
export async function readConfigObject(configFilePath: string): Promise<Config> {
|
|
return (await readConfigFile(configFilePath)).config;
|
|
}
|
|
|
|
export async function readConfigFile(configFilePath: string): Promise<{ config: Config, showOnboarding: boolean }> {
|
|
ensureConfigFileExists(configFilePath);
|
|
const content = readFileSync(configFilePath, "utf-8");
|
|
if (content.trim() === "") {
|
|
return {
|
|
config: {},
|
|
showOnboarding: false,
|
|
};
|
|
}
|
|
|
|
let configModule: unknown;
|
|
try {
|
|
configModule = await jiti.import<unknown>(configFilePath);
|
|
} catch (error) {
|
|
// Capture the raw jiti/framework error for diagnostics, but don't attach it as `cause` on the thrown error:
|
|
// dashboard error formatting renders causes recursively, which would leak framework internals into the
|
|
// user-facing message we're deliberately replacing.
|
|
captureError("shared-backend/readConfigFile", error);
|
|
throw new Error(
|
|
`Failed to load config file ${configFilePath}. If your config imports a value (e.g. defineHexclaveConfig) from a framework package such as "@hexclave/next", import it from that package's lightweight "/config" entrypoint instead, which doesn't load the framework runtime:\n\n import { defineHexclaveConfig } from "@hexclave/next/config";\n`,
|
|
);
|
|
}
|
|
if (!isConfigModule(configModule)) {
|
|
throw new Error(`Invalid config in ${configFilePath}. The file must export a plain \`config\` object or "show-onboarding".`);
|
|
}
|
|
|
|
const config = configModule.config;
|
|
if (config === showOnboardingHexclaveConfigValue) {
|
|
return {
|
|
config: {},
|
|
showOnboarding: true,
|
|
};
|
|
}
|
|
if (!isValidConfig(config)) {
|
|
throw new Error(`Invalid config in ${configFilePath}.`);
|
|
}
|
|
return {
|
|
config,
|
|
showOnboarding: false,
|
|
};
|
|
}
|
|
|
|
function renderConfigObjectToString(configFilePath: string, config: Config): string {
|
|
const importPackage = detectImportPackageFromDir(path.dirname(configFilePath));
|
|
return renderConfigFileContent(config, importPackage);
|
|
}
|
|
|
|
function writeFileAtomic(configFilePath: string, content: string): void {
|
|
const dir = path.dirname(configFilePath);
|
|
mkdirSync(dir, { recursive: true });
|
|
const tempPath = path.join(dir, `.stack.config.${Math.random().toString(36).slice(2)}.tmp`);
|
|
writeFileSync(tempPath, content, "utf-8");
|
|
try {
|
|
renameSync(tempPath, configFilePath);
|
|
} catch (error) {
|
|
try {
|
|
rmSync(tempPath);
|
|
} catch { /* best-effort cleanup */ }
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function renderConfigObjectToFile(configFilePath: string, config: Config): void {
|
|
writeFileAtomic(configFilePath, renderConfigObjectToString(configFilePath, config));
|
|
}
|
|
|
|
export async function updateConfigObject(configFilePath: string, configUpdate: Config): Promise<void> {
|
|
ensureConfigFileExists(configFilePath);
|
|
|
|
if (flattenConfigUpdate(configUpdate).length === 0) return;
|
|
|
|
const content = readFileSync(configFilePath, "utf-8");
|
|
|
|
// Fast path: if the config is a plain static literal (no imports, no helpers),
|
|
// apply the update deterministically without invoking the AI agent.
|
|
const staticConfig = tryParseStaticConfigFileContent(content, configFilePath);
|
|
if (staticConfig != null && isValidConfig(staticConfig)) {
|
|
const merged = override(staticConfig, configUpdate);
|
|
if (!isValidConfig(merged)) {
|
|
throw new Error(`${LOG_PREFIX} Merged config is invalid after applying update to ${configFilePath}`);
|
|
}
|
|
renderConfigObjectToFile(configFilePath, merged);
|
|
return;
|
|
}
|
|
|
|
// Agent path: config has custom structure (imports, helpers, external files)
|
|
// that must be preserved — delegate to the AI agent.
|
|
const baselineConfig = await tryReadConfigForValidation(configFilePath);
|
|
const { snapshots, seen } = snapshotConfigFiles(configFilePath, content);
|
|
try {
|
|
await runConfigUpdateAgent({
|
|
prompt: buildConfigUpdatePrompt(path.basename(configFilePath), configUpdate, baselineConfig),
|
|
cwd: path.dirname(configFilePath),
|
|
onFileWillChange: (filePath) => captureSnapshotIfAbsent(snapshots, filePath, seen),
|
|
});
|
|
await validateAgentUpdate(configFilePath, baselineConfig, configUpdate, snapshots);
|
|
} catch (error) {
|
|
try {
|
|
restoreConfigFiles(snapshots);
|
|
} catch (restoreError) {
|
|
console.error(`${LOG_PREFIX} Failed to fully roll back config files after a failed update of ${configFilePath}; some files may be left in a partially-restored state`, {
|
|
configFilePath,
|
|
restoreError: restoreError instanceof Error ? restoreError.message : String(restoreError),
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function replaceConfigObject(configFilePath: string, config: Config): Promise<void> {
|
|
renderConfigObjectToFile(configFilePath, config);
|
|
}
|
|
|
|
async function runConfigUpdateAgent(options: {
|
|
prompt: string,
|
|
cwd: string,
|
|
onFileWillChange?: (filePath: string) => void,
|
|
}): Promise<void> {
|
|
const timeoutMs = parseAgentTimeoutMs();
|
|
const deniedOutOfBoundsWrites = new Set<string>();
|
|
try {
|
|
await runHeadlessClaudeAgent({
|
|
prompt: options.prompt,
|
|
cwd: options.cwd,
|
|
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep"],
|
|
strictIsolation: true,
|
|
timeoutMs,
|
|
stderr: (data) => { console.warn(`${LOG_PREFIX} [agent] ${data}`); },
|
|
onPreToolUse: (input) => {
|
|
const target = getToolWriteTargetPath(input.tool_name, input.tool_input, options.cwd);
|
|
if (target == null) return { continue: true };
|
|
if (!isPathInsideDir(options.cwd, target)) {
|
|
deniedOutOfBoundsWrites.add(target);
|
|
return {
|
|
hookSpecificOutput: {
|
|
hookEventName: "PreToolUse",
|
|
permissionDecision: "deny",
|
|
permissionDecisionReason: `Refusing to modify ${target}: config updates may only change files inside the config directory.`,
|
|
},
|
|
};
|
|
}
|
|
options.onFileWillChange?.(target);
|
|
return { continue: true };
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof ClaudeAgentTimeoutError) {
|
|
throw new Error(`Config update agent timed out after ${timeoutMs}ms. It was unable to apply the config changes to the file.`);
|
|
}
|
|
if (error instanceof ClaudeAgentFailureError) {
|
|
throw new Error(`${error.message} It was unable to apply the config changes to the file.`);
|
|
}
|
|
throw error;
|
|
}
|
|
if (deniedOutOfBoundsWrites.size > 0) {
|
|
throw new Error(`Config update agent tried to modify ${deniedOutOfBoundsWrites.size} file(s) outside the config directory, which is not allowed: ${[...deniedOutOfBoundsWrites].join(", ")}. The config was not updated.`);
|
|
}
|
|
}
|
|
|
|
function parseAgentTimeoutMs(): number {
|
|
const raw = process.env.STACK_CONFIG_UPDATE_AGENT_TIMEOUT_MS;
|
|
if (raw == null || raw.trim() === "") return DEFAULT_AGENT_TIMEOUT_MS;
|
|
const parsed = Number(raw);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
throw new Error(`Invalid STACK_CONFIG_UPDATE_AGENT_TIMEOUT_MS: ${JSON.stringify(raw)}. Expected a positive number of milliseconds.`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function captureSnapshotIfAbsent(snapshots: ConfigFileSnapshot[], filePath: string, seen: Set<string>): void {
|
|
const resolved = path.resolve(filePath);
|
|
if (seen.has(resolved)) return;
|
|
seen.add(resolved);
|
|
snapshots.push({ path: resolved, content: existsSync(resolved) ? readFileSync(resolved, "utf-8") : null });
|
|
}
|
|
|
|
function snapshotConfigFiles(configFilePath: string, configContent: string): { snapshots: ConfigFileSnapshot[]; seen: Set<string> } {
|
|
const dir = path.dirname(configFilePath);
|
|
const resolvedConfig = path.resolve(configFilePath);
|
|
const snapshots: ConfigFileSnapshot[] = [{ path: resolvedConfig, content: configContent }];
|
|
const seen = new Set<string>([resolvedConfig]);
|
|
for (const specifier of getRelativeImportSpecifiers(configContent)) {
|
|
const resolved = path.resolve(dir, specifier);
|
|
if (!isPathInsideDir(dir, resolved)) continue;
|
|
captureSnapshotIfAbsent(snapshots, resolved, seen);
|
|
}
|
|
return { snapshots, seen };
|
|
}
|
|
|
|
function restoreConfigFiles(snapshots: ConfigFileSnapshot[]): void {
|
|
const failures: string[] = [];
|
|
for (const { path: filePath, content } of snapshots) {
|
|
try {
|
|
if (content === null) {
|
|
if (existsSync(filePath)) rmSync(filePath);
|
|
} else {
|
|
writeFileSync(filePath, content, "utf-8");
|
|
}
|
|
} catch (error) {
|
|
failures.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
if (failures.length > 0) {
|
|
throw new Error(`Failed to restore ${failures.length} file(s) during rollback: ${failures.join("; ")}`);
|
|
}
|
|
}
|
|
|
|
async function tryReadConfigForValidation(configFilePath: string): Promise<Config | null> {
|
|
try {
|
|
return (await readConfigFile(configFilePath)).config;
|
|
} catch (error) {
|
|
console.warn(`${LOG_PREFIX} Could not evaluate config for validation baseline; will fall back to a structural check`, {
|
|
configFilePath,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function validateAgentUpdate(configFilePath: string, baselineConfig: Config | null, configUpdate: Config, snapshots: ConfigFileSnapshot[]): Promise<void> {
|
|
if (baselineConfig != null) {
|
|
const target = canonicalizeConfig(override(baselineConfig, configUpdate));
|
|
const result = canonicalizeConfig((await readConfigFile(configFilePath)).config);
|
|
if (!configsEqual(result, target)) {
|
|
throw new Error(`Config update validation failed for ${configFilePath}: the updated file does not evaluate to the expected configuration.`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Structural-only fallback: when jiti can't evaluate the config (e.g. missing
|
|
// runtime dependencies in import-with attributes), we can only verify that
|
|
// (a) something changed on disk and (b) the file still exports `config`.
|
|
// This cannot catch silently mis-applied values — an accepted tradeoff vs.
|
|
// blocking updates entirely for configs we can't evaluate.
|
|
// When nothing changed on disk the update is either already applied or the
|
|
// agent couldn't figure out what to do. Treat it as a no-op rather than a
|
|
// hard failure: the structural check below still verifies the file is valid.
|
|
if (flattenConfigUpdate(configUpdate).length > 0 && !snapshotsChangedOnDisk(snapshots)) {
|
|
console.warn(`${LOG_PREFIX} Agent did not modify any file for ${configFilePath}; assuming values are already up to date.`);
|
|
}
|
|
|
|
const content = readFileSync(configFilePath, "utf-8");
|
|
if (!configFileExportsConfig(content, configFilePath)) {
|
|
throw new Error(`Config update validation failed for ${configFilePath}: the updated file no longer exports a valid \`config\`.`);
|
|
}
|
|
}
|
|
|
|
function tryParseStaticConfigFileContent(content: string, configFilePath: string): Config | null {
|
|
try {
|
|
const parsed = parseHexclaveConfigFileContent(content, configFilePath);
|
|
return isValidConfig(parsed) ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function configFileExportsConfig(content: string, configFilePath: string): boolean {
|
|
try {
|
|
parseHexclaveConfigFileContent(content, configFilePath);
|
|
return true;
|
|
} catch {
|
|
// Dynamic configs can be valid even when the static parser cannot evaluate
|
|
// them. For the structural fallback we only need to know that a runtime
|
|
// config binding still exists after the agent edited the file.
|
|
return /\bexport\s+const\s+config\b/.test(content);
|
|
}
|
|
}
|
|
|
|
function getRelativeImportSpecifiers(content: string): string[] {
|
|
const specifiers: string[] = [];
|
|
const importPattern = /\bimport\b(?:[^'"]*?\bfrom\s*)?["'](\.{1,2}\/[^"']+)["']/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = importPattern.exec(content)) !== null) {
|
|
specifiers.push(match[1]);
|
|
}
|
|
return specifiers;
|
|
}
|
|
|
|
function snapshotsChangedOnDisk(snapshots: ConfigFileSnapshot[]): boolean {
|
|
return snapshots.some(({ path: filePath, content }) => {
|
|
const current = existsSync(filePath) ? readFileSync(filePath, "utf-8") : null;
|
|
return current !== content;
|
|
});
|
|
}
|
|
|
|
function flattenConfigUpdate(update: Config): ConfigChange[] {
|
|
const changes: ConfigChange[] = [];
|
|
const walk = (prefix: string, obj: Config): void => {
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
const fullPath = prefix === "" ? key : `${prefix}.${key}`;
|
|
if (value === undefined) continue;
|
|
if (value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0) {
|
|
walk(fullPath, value);
|
|
} else {
|
|
changes.push({ path: fullPath, value });
|
|
}
|
|
}
|
|
};
|
|
walk("", update);
|
|
return changes;
|
|
}
|
|
|
|
function buildConfigUpdatePrompt(configFileName: string, configUpdate: Config, baselineConfig: Config | null): string {
|
|
const changes = flattenConfigUpdate(configUpdate);
|
|
const changeLines = changes.map(({ path: configPath, value }) => {
|
|
return `- ${JSON.stringify(configPath)}: set to ${JSON.stringify(value)}`;
|
|
}).join("\n");
|
|
const expectedConfig = baselineConfig == null ? null : canonicalizeConfig(override(baselineConfig, configUpdate));
|
|
const expectedConfigSection = expectedConfig == null ? "" : `
|
|
After the edit, evaluating the exported \`config\` must produce this exact JSON value:
|
|
|
|
${JSON.stringify(expectedConfig, null, 2)}
|
|
`;
|
|
|
|
return `You are editing a Hexclave / Stack Auth configuration file in place. Apply a set of configuration changes WITHOUT changing how the file is written.
|
|
|
|
Config file: ${JSON.stringify(configFileName)} (in the current working directory).
|
|
|
|
The file exports a \`config\` object (it may be wrapped in a helper such as \`defineStackConfig(...)\`). Some config values may be sourced from other files via imports, for example:
|
|
|
|
import welcomeEmail from "./welcome-email.tsx" with { type: "text" };
|
|
export const config = { emails: { templates: { welcome: welcomeEmail } } };
|
|
|
|
Apply EXACTLY these changes. Paths use dot notation, so \`a.b.c\` refers to \`config.a.b.c\`:
|
|
|
|
${changeLines}
|
|
${expectedConfigSection}
|
|
|
|
Rules:
|
|
- Change ONLY the config paths listed above. Leave every other part of the file byte-for-byte unchanged: imports, comments, formatting, helper wrappers, and any config fields not listed.
|
|
- If a listed path's value is currently provided by an imported external file (like the \`import ... with { type: "text" }\` example above), DO NOT inline the new value into the config file. Instead, overwrite that external file with the new value and keep the import statement intact.
|
|
- If a listed path's value is a plain inline literal, edit it inline.
|
|
- Keep the file valid: it must still export a \`config\` that, once evaluated, reflects the new values exactly.
|
|
- Do not run any shell commands and do not create files other than what is required to apply these changes.`;
|
|
}
|
|
|
|
function canonicalizeConfig(config: Config): NormalizedConfig {
|
|
const droppedKeys: string[] = [];
|
|
const normalized = normalize(config, {
|
|
onDotIntoNonObject: "ignore",
|
|
onDotIntoNull: "empty-object",
|
|
droppedKeys,
|
|
});
|
|
if (droppedKeys.length > 0) {
|
|
throw new Error(`Config update has conflicting keys that would be dropped during normalization: ${droppedKeys.map((key) => JSON.stringify(key)).join(", ")}`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function configsEqual(a: unknown, b: unknown): boolean {
|
|
if (a === b) return true;
|
|
if (a === null || b === null) return a === b;
|
|
if (Array.isArray(a) || Array.isArray(b)) {
|
|
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
return a.every((value, index) => configsEqual(value, b[index]));
|
|
}
|
|
if (typeof a === "object" && typeof b === "object") {
|
|
const aEntries = Object.entries(a);
|
|
const bMap = new Map(Object.entries(b));
|
|
if (aEntries.length !== bMap.size) return false;
|
|
return aEntries.every(([key, value]) => bMap.has(key) && configsEqual(value, bMap.get(key)));
|
|
}
|
|
return false;
|
|
}
|