mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Replace writeConfigObject with AI-aware updateConfigObject
Apply RDE config updates in place instead of overwriting the whole file. Plain static configs keep the deterministic render (fast path, no AI). Configs with custom structure (imports, helper wrappers, external text refs) are edited by a headless Claude agent so user-authored structure is preserved and externally-referenced files are updated rather than inlined. Every edit is validated (semantic when the config is evaluable, structural fallback otherwise) and hard-fails on mismatch. Co-Authored-By: mantra <mantra@stack-auth.com>
This commit is contained in:
parent
a2a14833ee
commit
767fa77dd0
@ -21,6 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.72",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.73",
|
||||
"@assistant-ui/react": "^0.10.24",
|
||||
"@assistant-ui/react-ai-sdk": "^0.10.14",
|
||||
"@assistant-ui/react-markdown": "^0.10.5",
|
||||
|
||||
@ -2,19 +2,37 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Lets each AI-path test inject what the (otherwise network-backed) agent does
|
||||
// to the files on disk, so we can exercise the orchestration and validation
|
||||
// around `updateConfigObject` without calling a real model.
|
||||
let mockAgentImpl: ((options: { prompt: string, cwd: string }) => void | Promise<void>) | null = null;
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("./config-update-agent", () => ({
|
||||
runConfigUpdateAgent: async (options: { prompt: string, cwd: string }) => {
|
||||
if (mockAgentImpl == null) {
|
||||
throw new Error("runConfigUpdateAgent was called but no mock implementation was set for this test.");
|
||||
}
|
||||
await mockAgentImpl(options);
|
||||
},
|
||||
}));
|
||||
|
||||
let tempDir: string | undefined;
|
||||
|
||||
function writeTempConfig(content: string): string {
|
||||
function writeTempFile(name: string, content: string): string {
|
||||
tempDir ??= mkdtempSync(join(process.cwd(), ".stack-rde-config-test-"));
|
||||
const configPath = join(tempDir, "stack.config.ts");
|
||||
writeFileSync(configPath, content, "utf-8");
|
||||
return configPath;
|
||||
const filePath = join(tempDir, name);
|
||||
writeFileSync(filePath, content, "utf-8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function writeTempConfig(content: string): string {
|
||||
return writeTempFile("stack.config.ts", content);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
mockAgentImpl = null;
|
||||
if (tempDir != null) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = undefined;
|
||||
@ -165,7 +183,7 @@ describe("remote development environment config file", () => {
|
||||
await expect(readConfigFile(configPath)).rejects.toThrow(`Invalid config in ${configPath}.`);
|
||||
});
|
||||
|
||||
it("can rewrite a dynamic config into the rendered static format", async () => {
|
||||
it("applies updates to a plain static config deterministically, without the agent", async () => {
|
||||
const configPath = writeTempConfig(`
|
||||
export const config = {
|
||||
auth: {
|
||||
@ -173,11 +191,11 @@ describe("remote development environment config file", () => {
|
||||
},
|
||||
};
|
||||
`);
|
||||
const { readConfigFile, writeConfigObject } = await import("./config-file");
|
||||
const current = await readConfigFile(configPath);
|
||||
const { readConfigFile, updateConfigObject } = await import("./config-file");
|
||||
|
||||
writeConfigObject(configPath, {
|
||||
...current.config,
|
||||
// No mock impl is set: if this path tried to invoke the agent, the mock
|
||||
// would throw. A plain static literal must take the deterministic fast path.
|
||||
await updateConfigObject(configPath, {
|
||||
"payments.testMode": true,
|
||||
});
|
||||
|
||||
@ -208,4 +226,83 @@ describe("remote development environment config file", () => {
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("updates the externally-referenced file instead of inlining or overwriting the config", async () => {
|
||||
const templatePath = writeTempFile("welcome-email.tsx", "export default <div>Old email</div>;\n");
|
||||
const configSource = `import welcomeEmail from "./welcome-email.tsx" with { type: "text" };\n\nexport const config = {\n emails: { templates: { welcome: welcomeEmail } },\n};\n`;
|
||||
const configPath = writeTempConfig(configSource);
|
||||
|
||||
const { updateConfigObject } = await import("./config-file");
|
||||
|
||||
// Simulate the agent: write the new value into the referenced file and leave
|
||||
// the config file untouched.
|
||||
mockAgentImpl = () => {
|
||||
writeFileSync(templatePath, "export default <div>New email</div>;\n", "utf-8");
|
||||
};
|
||||
|
||||
await updateConfigObject(configPath, {
|
||||
"emails.templates.welcome": "export default <div>New email</div>;\n",
|
||||
});
|
||||
|
||||
// The external file is updated and the config file keeps its import + shape.
|
||||
expect(readFileSync(templatePath, "utf-8")).toBe("export default <div>New email</div>;\n");
|
||||
expect(readFileSync(configPath, "utf-8")).toBe(configSource);
|
||||
});
|
||||
|
||||
it("validates the result semantically when the config is evaluable", async () => {
|
||||
const configPath = writeTempConfig(`
|
||||
import { defineStackConfig } from "@hexclave/shared/config";
|
||||
export const config = defineStackConfig({
|
||||
auth: { allowSignUp: true },
|
||||
});
|
||||
`);
|
||||
const { readConfigFile, updateConfigObject } = await import("./config-file");
|
||||
|
||||
mockAgentImpl = () => {
|
||||
writeFileSync(configPath, `
|
||||
import { defineStackConfig } from "@hexclave/shared/config";
|
||||
export const config = defineStackConfig({
|
||||
auth: { allowSignUp: false },
|
||||
});
|
||||
`, "utf-8");
|
||||
};
|
||||
|
||||
await updateConfigObject(configPath, { "auth.allowSignUp": false });
|
||||
|
||||
// The defineStackConfig wrapper is preserved and the value is updated.
|
||||
expect(readFileSync(configPath, "utf-8")).toContain("defineStackConfig");
|
||||
await expect(readConfigFile(configPath)).resolves.toMatchInlineSnapshot(`
|
||||
{
|
||||
"config": {
|
||||
"auth": {
|
||||
"allowSignUp": false,
|
||||
},
|
||||
},
|
||||
"showOnboarding": false,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("throws when the agent produces a config that does not match the requested update", async () => {
|
||||
const configPath = writeTempConfig(`
|
||||
import { defineStackConfig } from "@hexclave/shared/config";
|
||||
export const config = defineStackConfig({
|
||||
auth: { allowSignUp: true },
|
||||
});
|
||||
`);
|
||||
const { updateConfigObject } = await import("./config-file");
|
||||
|
||||
// The agent writes the wrong value (allowSignUp stays true).
|
||||
mockAgentImpl = () => {
|
||||
writeFileSync(configPath, `
|
||||
import { defineStackConfig } from "@hexclave/shared/config";
|
||||
export const config = defineStackConfig({
|
||||
auth: { allowSignUp: true },
|
||||
});
|
||||
`, "utf-8");
|
||||
};
|
||||
|
||||
await expect(updateConfigObject(configPath, { "auth.allowSignUp": false }))
|
||||
.rejects.toThrow(/validation failed/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
import "server-only";
|
||||
|
||||
import { showOnboardingStackConfigValue } from "@hexclave/shared/dist/config-authoring";
|
||||
import { Config, isValidConfig } from "@hexclave/shared/dist/config/format";
|
||||
import { Config, ConfigValue, NormalizedConfig, isValidConfig, normalize, override } from "@hexclave/shared/dist/config/format";
|
||||
import { detectImportPackageFromDir, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
|
||||
import { stackConfigFileExportsConfig, tryParseStackConfigFileContent } from "@hexclave/shared/dist/stack-config-file";
|
||||
import { createHash } from "crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
||||
import { createJiti } from "jiti";
|
||||
import path from "path";
|
||||
import { runConfigUpdateAgent } from "./config-update-agent";
|
||||
|
||||
const jiti = createJiti(import.meta.url, { moduleCache: false });
|
||||
|
||||
const LOG_PREFIX = "[Stack RDE]";
|
||||
|
||||
type ConfigModule = {
|
||||
config?: unknown,
|
||||
};
|
||||
@ -45,7 +49,7 @@ export function resolveConfigFilePath(inputPath: string): string {
|
||||
export function ensureConfigFileExists(configFilePath: string): void {
|
||||
if (existsSync(configFilePath)) return;
|
||||
mkdirSync(path.dirname(configFilePath), { recursive: true });
|
||||
writeConfigObject(configFilePath, {});
|
||||
renderConfigObjectToFile(configFilePath, {});
|
||||
}
|
||||
|
||||
export async function readConfigObject(configFilePath: string): Promise<Config> {
|
||||
@ -83,7 +87,15 @@ export async function readConfigFile(configFilePath: string): Promise<{ config:
|
||||
};
|
||||
}
|
||||
|
||||
export function writeConfigObject(configFilePath: string, config: Config): void {
|
||||
/**
|
||||
* Deterministically renders a config object into the file, overwriting whatever
|
||||
* was there. This is the canonical, lossy representation (a single
|
||||
* `export const config = { ...JSON... }`); it does not preserve imports, helper
|
||||
* wrappers, comments, or external file references. Only use it when there is no
|
||||
* existing structure to preserve (a brand-new/empty file, or a file that is
|
||||
* already a plain static literal). Otherwise use {@link updateConfigObject}.
|
||||
*/
|
||||
function renderConfigObjectToFile(configFilePath: string, config: Config): void {
|
||||
const dir = path.dirname(configFilePath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const importPackage = detectImportPackageFromDir(dir);
|
||||
@ -92,3 +104,184 @@ export function writeConfigObject(configFilePath: string, config: Config): void
|
||||
writeFileSync(tempPath, content, "utf-8");
|
||||
renameSync(tempPath, configFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a config update to the file at `configFilePath`, merging `configUpdate`
|
||||
* (a partial config, possibly using dot-notation keys) over the current config.
|
||||
*
|
||||
* Unlike a plain overwrite, this preserves the way the user authored their
|
||||
* config file. If the file is already a plain static literal (no imports,
|
||||
* wrappers, or computed values), the update is applied deterministically. If the
|
||||
* file has custom structure — most importantly when a config value is sourced
|
||||
* from an external file via `import x from "./file.txt" with { type: "text" }` —
|
||||
* an AI agent applies the change in place, editing the referenced external files
|
||||
* instead of inlining their contents back into the config.
|
||||
*
|
||||
* The result is validated before returning: when the config is evaluable we
|
||||
* assert it deep-equals the intended merge; otherwise we fall back to a
|
||||
* structural check. On any failure we throw rather than silently leaving the
|
||||
* file in an unexpected state.
|
||||
*/
|
||||
export async function updateConfigObject(configFilePath: string, configUpdate: Config): Promise<void> {
|
||||
ensureConfigFileExists(configFilePath);
|
||||
const content = readFileSync(configFilePath, "utf-8");
|
||||
|
||||
// Fast path: a plain static literal config has no structure to preserve, so we
|
||||
// can regenerate it deterministically without spending an AI call.
|
||||
const staticConfig = tryParseStackConfigFileContent(content, configFilePath);
|
||||
if (staticConfig != null) {
|
||||
let current: Config;
|
||||
if (staticConfig === showOnboardingStackConfigValue) {
|
||||
current = {};
|
||||
} else if (isValidConfig(staticConfig)) {
|
||||
current = staticConfig;
|
||||
} else {
|
||||
throw new Error(`Config in ${configFilePath} parsed to a static literal that is not a valid config object.`);
|
||||
}
|
||||
const target = override(current, configUpdate);
|
||||
renderConfigObjectToFile(configFilePath, target);
|
||||
const written = tryParseStackConfigFileContent(readFileSync(configFilePath, "utf-8"), configFilePath);
|
||||
if (written == null || written === showOnboardingStackConfigValue || !isValidConfig(written) || !configsEqual(canonicalizeConfig(written), canonicalizeConfig(target))) {
|
||||
throw new Error(`Config update validation failed for ${configFilePath}: the regenerated file does not match the expected configuration.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Custom structure: capture an evaluable baseline if we can (so we can do a
|
||||
// full semantic check afterwards), then let the agent apply the change.
|
||||
const baselineConfig = await tryReadConfigForValidation(configFilePath);
|
||||
|
||||
await runConfigUpdateAgent({
|
||||
prompt: buildConfigUpdatePrompt(path.basename(configFilePath), configUpdate),
|
||||
cwd: path.dirname(configFilePath),
|
||||
});
|
||||
|
||||
await validateAgentUpdate(configFilePath, baselineConfig, configUpdate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and evaluates the config for use as a validation baseline, returning
|
||||
* `null` if the file references values our loader can't evaluate (e.g. text
|
||||
* imports). A `null` result is expected for the exact files this feature
|
||||
* targets, so we degrade to a structural check rather than failing.
|
||||
*/
|
||||
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): 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;
|
||||
}
|
||||
|
||||
// The config couldn't be evaluated (e.g. it imports external text files), so a
|
||||
// full semantic comparison isn't possible. Ensure at least that the agent left
|
||||
// a syntactically valid file that still exports `config`.
|
||||
const content = readFileSync(configFilePath, "utf-8");
|
||||
if (!stackConfigFileExportsConfig(content, configFilePath)) {
|
||||
throw new Error(`Config update validation failed for ${configFilePath}: the updated file no longer exports a valid \`config\`.`);
|
||||
}
|
||||
}
|
||||
|
||||
type ConfigChange = { path: string, value: ConfigValue | undefined };
|
||||
|
||||
/**
|
||||
* Flattens a (possibly dot-notation) config update into individual leaf changes,
|
||||
* so the agent gets an explicit list of which config paths to change. Arrays and
|
||||
* primitives are leaves; nested plain objects are walked. A `value` of
|
||||
* `undefined` denotes a field that should be removed.
|
||||
*/
|
||||
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 !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||
walk(fullPath, value);
|
||||
} else {
|
||||
changes.push({ path: fullPath, value });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk("", update);
|
||||
return changes;
|
||||
}
|
||||
|
||||
function buildConfigUpdatePrompt(configFileName: string, configUpdate: Config): string {
|
||||
const changes = flattenConfigUpdate(configUpdate);
|
||||
const changeLines = changes.map(({ path: configPath, value }) => {
|
||||
if (value === undefined) {
|
||||
return `- \`${configPath}\`: (remove this field)`;
|
||||
}
|
||||
return `- \`${configPath}\`: set to ${JSON.stringify(value)}`;
|
||||
}).join("\n");
|
||||
|
||||
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: \`${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}
|
||||
|
||||
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.
|
||||
- For a path marked "(remove this field)", delete that field from the config.
|
||||
- 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.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a config (which may contain dot-notation keys) into its canonical
|
||||
* nested form, using the same normalization options as `renderConfigFileContent`
|
||||
* so comparisons line up with how configs are actually written to disk. Throws
|
||||
* if the config has conflicting keys that would be dropped.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
import "server-only";
|
||||
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/**
|
||||
* Headless agent runner used to apply config updates in place (see
|
||||
* `updateConfigObject`). Mirrors the CLI's `runClaudeAgent`
|
||||
* (`packages/stack-cli/src/lib/claude-agent.ts`) but without the interactive
|
||||
* spinner UI, since this runs inside the local dashboard server rather than a
|
||||
* terminal.
|
||||
*
|
||||
* Requests are routed through the Hexclave AI proxy, so no Anthropic API key is
|
||||
* required on the user's machine. The proxy URL can be overridden with
|
||||
* `STACK_CLAUDE_PROXY_URL` (the same env var the CLI reads, so both share one
|
||||
* configuration point).
|
||||
*/
|
||||
const DEFAULT_PROXY_URL = "https://api.hexclave.com/api/v1/integrations/ai-proxy";
|
||||
const ANTHROPIC_PROXY_BASE_URL: string = process.env.STACK_CLAUDE_PROXY_URL ?? DEFAULT_PROXY_URL;
|
||||
|
||||
const LOG_PREFIX = "[Stack RDE]";
|
||||
|
||||
function stripClaudeCodeEnv(): Record<string, string> {
|
||||
const env = { ...process.env };
|
||||
// Removing CLAUDECODE prevents the SDK from detecting a nested agent. The
|
||||
// ANTHROPIC_API_KEY must be non-empty or users without Claude Code installed
|
||||
// hit a login error (it is ignored by the proxy).
|
||||
delete env.CLAUDECODE;
|
||||
return env as Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the coding agent with the given prompt in `cwd` and resolves once it
|
||||
* finishes. Throws if the agent reports an error result or the SDK stream
|
||||
* itself fails — callers must additionally validate the resulting files, since
|
||||
* a "success" result does not guarantee the edits are semantically correct.
|
||||
*/
|
||||
export async function runConfigUpdateAgent(options: {
|
||||
prompt: string,
|
||||
cwd: string,
|
||||
}): Promise<void> {
|
||||
for await (const message of query({
|
||||
prompt: options.prompt,
|
||||
options: {
|
||||
// Bash is intentionally omitted: applying a config delta only needs file
|
||||
// inspection and editing, and withholding shell access reduces the blast
|
||||
// radius of running an agent against the user's project.
|
||||
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep"],
|
||||
permissionMode: "dontAsk",
|
||||
cwd: options.cwd,
|
||||
env: {
|
||||
...stripClaudeCodeEnv(),
|
||||
ANTHROPIC_BASE_URL: ANTHROPIC_PROXY_BASE_URL,
|
||||
ANTHROPIC_API_KEY: "stack-auth-proxy",
|
||||
},
|
||||
stderr: (data: string) => { console.warn(`${LOG_PREFIX} [agent] ${data}`); },
|
||||
},
|
||||
})) {
|
||||
if (message.type === "result" && (message.is_error || message.subtype !== "success")) {
|
||||
throw new Error(`Config update agent failed (${message.subtype}). It was unable to apply the config changes to the file.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,7 @@ import "server-only";
|
||||
import { getPublicEnvVar } from "@/lib/env";
|
||||
import { stackAppInternalsSymbol } from "@/lib/stack-app-internals";
|
||||
import { AdminOwnedProject, StackClientApp } from "@hexclave/next";
|
||||
import { Config, override } from "@hexclave/shared/dist/config/format";
|
||||
import { Config } from "@hexclave/shared/dist/config/format";
|
||||
import { ProjectOnboardingStatus } from "@hexclave/shared/dist/schema-fields";
|
||||
import { AccessToken } from "@hexclave/shared/dist/sessions";
|
||||
import { errorToNiceString } from "@hexclave/shared/dist/utils/errors";
|
||||
@ -16,7 +16,7 @@ import {
|
||||
readConfigFile,
|
||||
resolveConfigFilePath,
|
||||
sha256String,
|
||||
writeConfigObject,
|
||||
updateConfigObject,
|
||||
} from "./config-file";
|
||||
import { assertRemoteDevelopmentEnvironmentEnabled } from "./env";
|
||||
import {
|
||||
@ -646,19 +646,26 @@ export async function applyRemoteDevelopmentEnvironmentConfigUpdate(options: {
|
||||
projectId: options.projectId,
|
||||
configFilePath,
|
||||
});
|
||||
const currentConfig = (await readConfigFile(configFilePath)).config;
|
||||
if (options.waitForSync === false) {
|
||||
writeConfigObject(configFilePath, override(currentConfig, options.configUpdate));
|
||||
scheduleSync(configFilePath);
|
||||
} else {
|
||||
state.synchronouslyUpdatingConfigFiles.add(configFilePath);
|
||||
try {
|
||||
writeConfigObject(configFilePath, override(currentConfig, options.configUpdate));
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
state.synchronouslyUpdatingConfigFiles.delete(configFilePath);
|
||||
}, SYNC_DEBOUNCE_MS).unref();
|
||||
}
|
||||
// Suppress watcher-driven syncs for the whole (potentially slow, AI-driven,
|
||||
// multi-file) update so we never sync a partially-edited intermediate state.
|
||||
// The membership is held until the update resolves and then cleared after a
|
||||
// debounce so the file-change events our own edits produce are ignored too.
|
||||
state.synchronouslyUpdatingConfigFiles.add(configFilePath);
|
||||
try {
|
||||
await updateConfigObject(configFilePath, options.configUpdate);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
state.synchronouslyUpdatingConfigFiles.delete(configFilePath);
|
||||
// For fire-and-forget updates the sync is scheduled only after the
|
||||
// suppression window clears, otherwise scheduleSync would be swallowed
|
||||
// by its own guard while the path is still marked as synchronously
|
||||
// updating.
|
||||
if (options.waitForSync === false) {
|
||||
scheduleSync(configFilePath);
|
||||
}
|
||||
}, SYNC_DEBOUNCE_MS).unref();
|
||||
}
|
||||
if (options.waitForSync !== false) {
|
||||
await syncConfigToRemoteNow(configFilePath);
|
||||
}
|
||||
logRemoteDevelopmentEnvironment("Applied config update from local dashboard", {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { parseStackConfigFileContent, renderConfigFileContent } from "./stack-config-file";
|
||||
export { parseStackConfigFileContent, renderConfigFileContent };
|
||||
import { parseStackConfigFileContent, renderConfigFileContent, stackConfigFileExportsConfig, tryParseStackConfigFileContent } from "./stack-config-file";
|
||||
export { parseStackConfigFileContent, renderConfigFileContent, stackConfigFileExportsConfig, tryParseStackConfigFileContent };
|
||||
|
||||
/**
|
||||
* Packages that export the `StackConfig` type, in priority order.
|
||||
@ -104,6 +104,28 @@ import.meta.vitest?.test("parseStackConfigFileContent rejects dynamic config exp
|
||||
expect(() => parseStackConfigFileContent("export const config = makeConfig();", "stack.config.ts")).toThrow(/Unsupported config expression/);
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("tryParseStackConfigFileContent returns the config for static exports", ({ expect }) => {
|
||||
expect(tryParseStackConfigFileContent("export const config = { auth: { allowSignUp: true } };", "stack.config.ts")).toEqual({
|
||||
auth: { allowSignUp: true },
|
||||
});
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("tryParseStackConfigFileContent returns null for non-static exports", ({ expect }) => {
|
||||
// Wrapped in a helper call (e.g. defineStackConfig) -> not a plain literal.
|
||||
expect(tryParseStackConfigFileContent("export const config = makeConfig();", "stack.config.ts")).toBeNull();
|
||||
// References an imported value -> has structure to preserve.
|
||||
expect(tryParseStackConfigFileContent('import x from "./x.txt" with { type: "text" };\nexport const config = { a: x };', "stack.config.ts")).toBeNull();
|
||||
// Syntax error.
|
||||
expect(tryParseStackConfigFileContent("export const config = {", "stack.config.ts")).toBeNull();
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("stackConfigFileExportsConfig detects a config export", ({ expect }) => {
|
||||
expect(stackConfigFileExportsConfig("export const config = { a: 1 };", "stack.config.ts")).toBe(true);
|
||||
expect(stackConfigFileExportsConfig('import x from "./x.txt" with { type: "text" };\nexport const config = { a: x };', "stack.config.ts")).toBe(true);
|
||||
expect(stackConfigFileExportsConfig("export const notConfig = { a: 1 };", "stack.config.ts")).toBe(false);
|
||||
expect(stackConfigFileExportsConfig("export const config = {", "stack.config.ts")).toBe(false);
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("renderConfigFileContent rejects conflicting dotted keys", ({ expect }) => {
|
||||
expect(() => renderConfigFileContent({
|
||||
"a.b": 1,
|
||||
|
||||
@ -94,6 +94,50 @@ function evaluateStaticConfigExpression(expression: t.Expression): unknown {
|
||||
throw new Error(`Unsupported config expression: ${unwrapped.type}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link parseStackConfigFileContent}, but returns `null` instead of
|
||||
* throwing when the file is not a plain static config (e.g. it wraps the config
|
||||
* in a helper call, references imported values, or has a syntax error). Useful
|
||||
* for deciding whether a config file can be safely regenerated deterministically
|
||||
* or whether it has custom structure that must be preserved.
|
||||
*/
|
||||
export function tryParseStackConfigFileContent(content: string, filePath: string): ParsedStackConfig | null {
|
||||
try {
|
||||
return parseStackConfigFileContent(content, filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether `content` parses as a module that exports a `config` binding.
|
||||
* Used as a lightweight structural sanity check after editing config files whose
|
||||
* values can't be evaluated by our loader (e.g. they import external text
|
||||
* files), where a full semantic comparison isn't possible.
|
||||
*/
|
||||
export function stackConfigFileExportsConfig(content: string, filePath: string): boolean {
|
||||
let ast: parser.ParseResult<t.File>;
|
||||
try {
|
||||
ast = parser.parse(content, {
|
||||
sourceType: "module",
|
||||
plugins: ["typescript", "importAttributes"],
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const statement of ast.program.body) {
|
||||
if (!t.isExportNamedDeclaration(statement) || !t.isVariableDeclaration(statement.declaration)) {
|
||||
continue;
|
||||
}
|
||||
for (const declaration of statement.declaration.declarations) {
|
||||
if (t.isIdentifier(declaration.id) && declaration.id.name === "config" && declaration.init != null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function parseStackConfigFileContent(content: string, filePath: string): ParsedStackConfig {
|
||||
if (content.trim() === "") return {};
|
||||
const ast = parser.parse(content, {
|
||||
|
||||
4055
pnpm-lock.yaml
4055
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user