From 42bc12587cc0b5d0100862ec2d111efa706d0e82 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 19 Mar 2026 15:31:17 -0700 Subject: [PATCH] Enhance setBranchConfigOverride for local emulator file handling - Updated the `setBranchConfigOverride` function to write branch configuration directly to a file when the local emulator is enabled, ensuring the file serves as the single source of truth. - Added tests to verify that configuration writes to the local emulator file are handled correctly and that database operations are skipped when writing to the file. - Improved error handling to surface file write failures before attempting database updates. These changes enhance the local emulator's configuration management and improve reliability for developers working with branch configurations. --- apps/backend/src/lib/config.tsx | 86 +++++++++++++++++++-- packages/stack-cli/src/commands/emulator.ts | 18 +++-- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 995829cbd..5e58e4bb7 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -279,6 +279,17 @@ export async function setBranchConfigOverride(options: { if (overrideErrors.status === "error") { captureError("setBranchConfigOverride", new StackAssertionError(`Config override is invalid — at a place where it should have already been validated! ${overrideErrors.error}`, { projectId: options.projectId, branchId: options.branchId })); } + + if (isLocalEmulatorEnabled()) { + const filePath = await getLocalEmulatorFilePath(options.projectId); + if (filePath != null) { + // Local emulator projects read branch config directly from the host config file, + // so the file stays the single source of truth to avoid split DB/file state. + await writeConfigToFile(filePath, newConfig); + return; + } + } + await globalPrismaClient.branchConfigOverride.upsert({ where: { projectId_branchId: { @@ -295,14 +306,6 @@ export async function setBranchConfigOverride(options: { config: newConfig, }, }); - - // In the local emulator, write config changes back to the config file - if (isLocalEmulatorEnabled()) { - const filePath = await getLocalEmulatorFilePath(options.projectId); - if (filePath != null) { - await writeConfigToFile(filePath, newConfig); - } - } } /** @@ -1104,6 +1107,73 @@ import.meta.vitest?.test('setEnvironmentConfigOverride blocks writes in local em } }); +import.meta.vitest?.test('setBranchConfigOverride writes local emulator config to the file instead of the DB', async ({ expect }) => { + const vi = import.meta.vitest?.vi; + if (!vi) { + throw new StackAssertionError("Vitest context is required for in-source tests."); + } + + const localEmulator = await import("./local-emulator"); + const isLocalEmulatorEnabledSpy = vi.spyOn(localEmulator, "isLocalEmulatorEnabled").mockReturnValue(true); + const getLocalEmulatorFilePathSpy = vi.spyOn(localEmulator, "getLocalEmulatorFilePath").mockResolvedValue("/Users/foo/project/stack.config.ts"); + const writeConfigToFileSpy = vi.spyOn(localEmulator, "writeConfigToFile").mockResolvedValue(undefined); + const upsertSpy = vi.spyOn(globalPrismaClient.branchConfigOverride, "upsert").mockImplementation(() => { + throw new StackAssertionError("DB upsert should not run for local emulator branch config writes."); + }); + + try { + await setBranchConfigOverride({ + projectId: "project-id", + branchId: "branch-id", + branchConfigOverride: { + "teams.allowClientTeamCreation": true, + }, + }); + + expect(writeConfigToFileSpy).toHaveBeenCalledWith("/Users/foo/project/stack.config.ts", { + "teams.allowClientTeamCreation": true, + }); + expect(upsertSpy).not.toHaveBeenCalled(); + } finally { + upsertSpy.mockRestore(); + writeConfigToFileSpy.mockRestore(); + getLocalEmulatorFilePathSpy.mockRestore(); + isLocalEmulatorEnabledSpy.mockRestore(); + } +}); + +import.meta.vitest?.test('setBranchConfigOverride surfaces local emulator file write failures before touching the DB', async ({ expect }) => { + const vi = import.meta.vitest?.vi; + if (!vi) { + throw new StackAssertionError("Vitest context is required for in-source tests."); + } + + const localEmulator = await import("./local-emulator"); + const isLocalEmulatorEnabledSpy = vi.spyOn(localEmulator, "isLocalEmulatorEnabled").mockReturnValue(true); + const getLocalEmulatorFilePathSpy = vi.spyOn(localEmulator, "getLocalEmulatorFilePath").mockResolvedValue("/Users/foo/project/stack.config.ts"); + const writeConfigToFileSpy = vi.spyOn(localEmulator, "writeConfigToFile").mockRejectedValue(new Error("virtio-9p timeout")); + const upsertSpy = vi.spyOn(globalPrismaClient.branchConfigOverride, "upsert").mockImplementation(() => { + throw new StackAssertionError("DB upsert should not run when the local emulator file write fails."); + }); + + try { + await expect(setBranchConfigOverride({ + projectId: "project-id", + branchId: "branch-id", + branchConfigOverride: { + "teams.allowClientTeamCreation": true, + }, + })).rejects.toThrow("virtio-9p timeout"); + + expect(upsertSpy).not.toHaveBeenCalled(); + } finally { + upsertSpy.mockRestore(); + writeConfigToFileSpy.mockRestore(); + getLocalEmulatorFilePathSpy.mockRestore(); + isLocalEmulatorEnabledSpy.mockRestore(); + } +}); + // --------------------------------------------------------------------------------------------------------------------- // Conversions // --------------------------------------------------------------------------------------------------------------------- diff --git a/packages/stack-cli/src/commands/emulator.ts b/packages/stack-cli/src/commands/emulator.ts index 65b9a161a..1bd74b80f 100644 --- a/packages/stack-cli/src/commands/emulator.ts +++ b/packages/stack-cli/src/commands/emulator.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { execFileSync, execSync, spawn } from "child_process"; +import { execFileSync, spawn } from "child_process"; import { existsSync, mkdirSync, renameSync, unlinkSync } from "fs"; import { join, resolve } from "path"; import { CliError } from "../lib/errors.js"; @@ -103,10 +103,18 @@ export function registerEmulatorCommand(program: Command) { console.log(`Pulling image for ${arch} from release ${tag}...`); try { - execSync( - `gh release download ${JSON.stringify(tag)} --repo ${JSON.stringify(repo)} --pattern ${JSON.stringify(asset)} --output ${JSON.stringify(tmpDest)} --clobber`, - { stdio: "inherit" } - ); + execFileSync("gh", [ + "release", + "download", + tag, + "--repo", + repo, + "--pattern", + asset, + "--output", + tmpDest, + "--clobber", + ], { stdio: "inherit" }); } catch (err) { if (existsSync(tmpDest)) unlinkSync(tmpDest); const reason = err instanceof Error