Skip to content

Commit 50908ba

Browse files
7ttpColy010
andauthored
fix(cli): telemetry json parse crash (#5405)
fixes `supabase start` crashing with `JSON Parse error: Unexpected EOF` from a malformed `telemetry.json` by recovering and regenerating unreadable telemetry state - closes #5395 --------- Co-authored-by: Colum Ferry <cferry09@gmail.com>
1 parent 94882bc commit 50908ba

4 files changed

Lines changed: 64 additions & 11 deletions

File tree

apps/cli-e2e/src/tests/telemetry.e2e.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { chmodSync, writeFileSync } from "node:fs";
1+
import { chmodSync, readFileSync, writeFileSync } from "node:fs";
22
import { join } from "node:path";
33
import { describe, expect } from "vitest";
44
import { testBehaviour, testParity } from "./test-context.ts";
@@ -58,9 +58,12 @@ describe("telemetry", () => {
5858
});
5959

6060
testBehaviour("handles corrupted config gracefully", async ({ run, workspace }) => {
61-
writeFileSync(join(workspace.path, "telemetry.json"), "{{not valid json}}");
61+
const telemetryPath = join(workspace.path, "telemetry.json");
62+
writeFileSync(telemetryPath, "{{not valid json}}");
6263
const result = await run(["telemetry", "status"]);
63-
expect(result.exitCode).not.toBe(0);
64+
expect(result.exitCode).toBe(0);
65+
expect(result.stdout).toMatch(/Telemetry is (enabled|disabled)\./);
66+
expect(() => JSON.parse(readFileSync(telemetryPath, "utf8"))).not.toThrow();
6467
});
6568

6669
testParity(["telemetry", "status"]);

apps/cli/src/shared/telemetry/consent.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ import type { ConsentState, TelemetryConfig } from "./types.ts";
44

55
export const getConfigDir = CliConfig.useSync((cliConfig) => cliConfig.supabaseHome);
66

7+
function parseTelemetryConfig(content: string): TelemetryConfig | null {
8+
try {
9+
return JSON.parse(content) as TelemetryConfig;
10+
} catch {
11+
return null;
12+
}
13+
}
14+
715
export const readTelemetryConfig = Effect.fnUntraced(
816
function* (configDir: string) {
917
const fs = yield* FileSystem.FileSystem;
@@ -12,7 +20,7 @@ export const readTelemetryConfig = Effect.fnUntraced(
1220
const exists = yield* fs.exists(configPath);
1321
if (!exists) return null;
1422
const content = yield* fs.readFileString(configPath);
15-
return JSON.parse(content) as TelemetryConfig;
23+
return parseTelemetryConfig(content);
1624
},
1725
(effect) => Effect.orElseSucceed(effect, () => null),
1826
);
@@ -24,11 +32,10 @@ export const writeTelemetryConfig = Effect.fnUntraced(function* (
2432
const fs = yield* FileSystem.FileSystem;
2533
const path = yield* Path.Path;
2634
yield* fs.makeDirectory(configDir, { recursive: true, mode: 0o700 });
27-
yield* fs.writeFileString(
28-
path.join(configDir, "telemetry.json"),
29-
JSON.stringify(config, null, 2),
30-
{ mode: 0o600 },
31-
);
35+
const configPath = path.join(configDir, "telemetry.json");
36+
const tmpPath = `${configPath}.tmp.${Date.now()}`;
37+
yield* fs.writeFileString(tmpPath, JSON.stringify(config, null, 2), { mode: 0o600 });
38+
yield* fs.rename(tmpPath, configPath);
3239
}, Effect.orDie);
3340

3441
export const getEffectiveConsent = Effect.fnUntraced(function* (config: TelemetryConfig | null) {

apps/cli/src/shared/telemetry/consent.unit.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import { describe, expect, it } from "@effect/vitest";
2+
import { BunServices } from "@effect/platform-bun";
3+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import path from "node:path";
26
import { Effect, Layer } from "effect";
37
import { cliConfigLayer } from "../../next/config/cli-config.layer.ts";
48
import {
59
mockProjectContext,
610
mockRuntimeInfo,
711
processEnvLayer,
812
} from "../../../tests/helpers/mocks.ts";
9-
import { getEffectiveConsent } from "./consent.ts";
13+
import { getEffectiveConsent, readTelemetryConfig } from "./consent.ts";
1014
import type { TelemetryConfig } from "./types.ts";
1115

1216
function makeConfig(consent: TelemetryConfig["consent"]): TelemetryConfig {
@@ -40,6 +44,14 @@ function emptyEnv() {
4044
);
4145
}
4246

47+
function makeTempDir(): string {
48+
return mkdtempSync(path.join(tmpdir(), "supabase-consent-test-"));
49+
}
50+
51+
function writeTelemetryFile(dir: string, content: string): void {
52+
writeFileSync(path.join(dir, "telemetry.json"), content);
53+
}
54+
4355
describe("getEffectiveConsent", () => {
4456
it.live("returns denied when DO_NOT_TRACK=1", () =>
4557
Effect.gen(function* () {
@@ -90,3 +102,18 @@ describe("getEffectiveConsent", () => {
90102
}).pipe(Effect.provide(emptyEnv())),
91103
);
92104
});
105+
106+
describe("readTelemetryConfig", () => {
107+
it.live("returns null for malformed JSON instead of throwing", () => {
108+
const dir = makeTempDir();
109+
writeTelemetryFile(dir, "");
110+
111+
return Effect.gen(function* () {
112+
const config = yield* readTelemetryConfig(dir);
113+
expect(config).toBeNull();
114+
}).pipe(
115+
Effect.provide(BunServices.layer),
116+
Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))),
117+
);
118+
});
119+
});

apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "@effect/vitest";
22
import { BunServices } from "@effect/platform-bun";
3-
import { existsSync, mkdtempSync, rmSync } from "node:fs";
3+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import path from "node:path";
66
import { Effect, Layer } from "effect";
@@ -79,4 +79,20 @@ describe("telemetryRuntimeLayer", () => {
7979
Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))),
8080
);
8181
});
82+
83+
it.live("treats a malformed telemetry.json as a fresh first run instead of crashing", () => {
84+
const homeDir = makeTempDir();
85+
const configPath = path.join(homeDir, "telemetry.json");
86+
writeFileSync(configPath, "");
87+
88+
return Effect.gen(function* () {
89+
const runtime = yield* TelemetryRuntime;
90+
expect(runtime.consent).toBe("granted");
91+
expect(runtime.isFirstRun).toBe(true);
92+
expect(existsSync(configPath)).toBe(true);
93+
}).pipe(
94+
Effect.provide(buildLayer({ homeDir })),
95+
Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))),
96+
);
97+
});
8298
});

0 commit comments

Comments
 (0)