From b10132380531e0adc1c31eb8d71531111ed368b0 Mon Sep 17 00:00:00 2001 From: Emma Hoggan Date: Tue, 28 Jul 2026 12:18:06 -0600 Subject: [PATCH 1/3] Gracefully recover/rewrite config when original config file corrupted. Write config to temp file first and rename rather than writing in place to prevent creating corrupted config files. --- src/config.test.ts | 28 ++++++++++++++++++++++++++++ src/config.ts | 30 +++++++++++++++++++++++------- src/loginViaBrowser.test.ts | 23 +++++++++++++++++++++++ src/loginViaBrowser.ts | 18 ++++++++++++++---- 4 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index ac3cabf..8968a4d 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -37,6 +37,18 @@ describe("resolveWebBaseUrl", () => { }); }); +describe("writeConfig", () => { + it("writes valid JSON and leaves no temp files behind", () => { + writeConfig({ apiBaseUrl: "https://example.com" }); + + const configDir = path.dirname(getConfigPath()); + const entries = fs.readdirSync(configDir); + + expect(entries).toEqual(["config.json"]); + expect(readConfig().apiBaseUrl).toBe("https://example.com"); + }); +}); + describe("readConfig", () => { it("returns {} for an empty config object on disk", () => { const configPath = getConfigPath(); @@ -46,6 +58,22 @@ describe("readConfig", () => { expect(readConfig()).toEqual({}); }); + it("returns {} instead of throwing when the config file is empty or truncated", () => { + const configPath = getConfigPath(); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, ""); + + expect(readConfig()).toEqual({}); + }); + + it("returns {} instead of throwing when the config file contains invalid JSON", () => { + const configPath = getConfigPath(); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, '{"apiBaseUrl": "https://exampl'); + + expect(readConfig()).toEqual({}); + }); + it("prefers apiBaseUrl when both apiBaseUrl and legacy baseUrl are present", () => { const configPath = getConfigPath(); fs.mkdirSync(path.dirname(configPath), { recursive: true }); diff --git a/src/config.ts b/src/config.ts index 687b358..31a6718 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,7 +9,7 @@ import type { VersionPromptSelection, } from "./types.js"; -const DEFAULT_API_BASE_URL = "https://api.getdx.com"; +export const DEFAULT_API_BASE_URL = "https://api.getdx.com"; const DEFAULT_WEB_BASE_URL = "https://app.getdx.com"; // --- Config file I/O --------------------------------------------------------- @@ -25,7 +25,16 @@ export function readConfig(): StoredConfig { } const content = fs.readFileSync(configPath, "utf8"); - const raw = JSON.parse(content) as Record; + + let raw: Record; + try { + raw = JSON.parse(content) as Record; + } catch { + // The config file can be left empty or truncated if a previous `dx` + // process was killed mid-write, or two processes wrote concurrently. + // Treat it like a missing config rather than crashing every command. + return {}; + } const stored: StoredConfig = {}; if (typeof raw.webBaseUrl === "string") { @@ -64,12 +73,19 @@ export function persistVersionPromptSelection( } export function writeConfig(config: StoredConfig): void { - fs.mkdirSync(getConfigDir(), { recursive: true }); - fs.writeFileSync( - getConfigPath(), - JSON.stringify(config, null, 2) + "\n", - "utf8", + const configDir = getConfigDir(); + fs.mkdirSync(configDir, { recursive: true }); + + const configPath = getConfigPath(); + // Write to a temp file and rename into place so a crash or a race between + // concurrent `dx` processes can't leave config.json empty or truncated — + // renames are atomic, so readers always see either the old or new content. + const tempPath = path.join( + configDir, + `.config.json.${process.pid}.${Date.now()}.tmp`, ); + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + "\n", "utf8"); + fs.renameSync(tempPath, configPath); } export function persistBaseUrls(apiBaseUrl: string, webBaseUrl: string): void { diff --git a/src/loginViaBrowser.test.ts b/src/loginViaBrowser.test.ts index 71b02a4..e42c601 100644 --- a/src/loginViaBrowser.test.ts +++ b/src/loginViaBrowser.test.ts @@ -166,6 +166,29 @@ describe("loginViaBrowser", () => { ); }); + it("throws CliError when token exchange returns an ok status with an invalid body", async () => { + let capturedState = ""; + mockOpen.mockImplementation(async (url: string) => { + capturedState = new URL(url).searchParams.get("state") ?? ""; + }); + + mockListenForCode.mockImplementation( + async (callback: (state: string, code: string) => Promise) => { + return callback(capturedState, "auth-code-xyz"); + }, + ); + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("", { status: 200 })), + ); + + const { loginViaBrowser } = await import("./loginViaBrowser.js"); + await expect(loginViaBrowser("https://app.example.com")).rejects.toThrow( + "Authentication failed: token exchange returned an invalid response", + ); + }); + it("prints a fallback URL when the browser cannot be opened", async () => { const writes: string[] = []; vi.spyOn(process.stdout, "write").mockImplementation((( diff --git a/src/loginViaBrowser.ts b/src/loginViaBrowser.ts index 73b50bb..aca83a3 100644 --- a/src/loginViaBrowser.ts +++ b/src/loginViaBrowser.ts @@ -74,10 +74,20 @@ export async function loginViaBrowser(webBaseUrl: string): Promise { }; } - const body = (await tokenExchangeResponse.json()) as { - access_token: string; - redirect_uri: string; - }; + let body: { access_token: string; redirect_uri: string }; + try { + body = (await tokenExchangeResponse.json()) as { + access_token: string; + redirect_uri: string; + }; + } catch { + return { + type: "ERROR" as const, + error: new CliError( + "Authentication failed: token exchange returned an invalid response", + ), + }; + } return { type: "SUCCESS" as const, From 32d9e9cf7d60fa2c3e4b035bb0328702847b3588 Mon Sep 17 00:00:00 2001 From: Emma Hoggan Date: Tue, 28 Jul 2026 12:21:17 -0600 Subject: [PATCH 2/3] Add changelog entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d561c2..738f1b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Updated +- Made improvements to prevent creation of corrupted config files and gracefully handle the re-creation of corrupted config files. + ### Fixed - Write the viewer_emails/editor_emails values when initializing studio reports so the values can be carried over/edited from current state on updates. From c44f99e86cfb3ced472fabd652f5d18b96c3c947 Mon Sep 17 00:00:00 2001 From: Emma Hoggan Date: Tue, 28 Jul 2026 15:01:43 -0600 Subject: [PATCH 3/3] Changelog formatting fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 738f1b2..54c1f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Updated + - Made improvements to prevent creation of corrupted config files and gracefully handle the re-creation of corrupted config files. ### Fixed