diff --git a/apps/cli-e2e/src/tests/telemetry.e2e.test.ts b/apps/cli-e2e/src/tests/telemetry.e2e.test.ts index 83a2e43a68..5d428a2e93 100644 --- a/apps/cli-e2e/src/tests/telemetry.e2e.test.ts +++ b/apps/cli-e2e/src/tests/telemetry.e2e.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, writeFileSync } from "node:fs"; +import { chmodSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect } from "vitest"; import { testBehaviour, testParity } from "./test-context.ts"; @@ -58,9 +58,12 @@ describe("telemetry", () => { }); testBehaviour("handles corrupted config gracefully", async ({ run, workspace }) => { - writeFileSync(join(workspace.path, "telemetry.json"), "{{not valid json}}"); + const telemetryPath = join(workspace.path, "telemetry.json"); + writeFileSync(telemetryPath, "{{not valid json}}"); const result = await run(["telemetry", "status"]); - expect(result.exitCode).not.toBe(0); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/Telemetry is (enabled|disabled)\./); + expect(() => JSON.parse(readFileSync(telemetryPath, "utf8"))).not.toThrow(); }); testParity(["telemetry", "status"]); diff --git a/apps/cli/src/shared/telemetry/consent.ts b/apps/cli/src/shared/telemetry/consent.ts index b3114ae489..3efe7f4582 100644 --- a/apps/cli/src/shared/telemetry/consent.ts +++ b/apps/cli/src/shared/telemetry/consent.ts @@ -4,6 +4,14 @@ import type { ConsentState, TelemetryConfig } from "./types.ts"; export const getConfigDir = CliConfig.useSync((cliConfig) => cliConfig.supabaseHome); +function parseTelemetryConfig(content: string): TelemetryConfig | null { + try { + return JSON.parse(content) as TelemetryConfig; + } catch { + return null; + } +} + export const readTelemetryConfig = Effect.fnUntraced( function* (configDir: string) { const fs = yield* FileSystem.FileSystem; @@ -12,7 +20,7 @@ export const readTelemetryConfig = Effect.fnUntraced( const exists = yield* fs.exists(configPath); if (!exists) return null; const content = yield* fs.readFileString(configPath); - return JSON.parse(content) as TelemetryConfig; + return parseTelemetryConfig(content); }, (effect) => Effect.orElseSucceed(effect, () => null), ); @@ -24,11 +32,10 @@ export const writeTelemetryConfig = Effect.fnUntraced(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fs.makeDirectory(configDir, { recursive: true, mode: 0o700 }); - yield* fs.writeFileString( - path.join(configDir, "telemetry.json"), - JSON.stringify(config, null, 2), - { mode: 0o600 }, - ); + const configPath = path.join(configDir, "telemetry.json"); + const tmpPath = `${configPath}.tmp.${Date.now()}`; + yield* fs.writeFileString(tmpPath, JSON.stringify(config, null, 2), { mode: 0o600 }); + yield* fs.rename(tmpPath, configPath); }, Effect.orDie); export const getEffectiveConsent = Effect.fnUntraced(function* (config: TelemetryConfig | null) { diff --git a/apps/cli/src/shared/telemetry/consent.unit.test.ts b/apps/cli/src/shared/telemetry/consent.unit.test.ts index bf0391678a..1ab9dbe5b3 100644 --- a/apps/cli/src/shared/telemetry/consent.unit.test.ts +++ b/apps/cli/src/shared/telemetry/consent.unit.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { Effect, Layer } from "effect"; import { cliConfigLayer } from "../../next/config/cli-config.layer.ts"; import { @@ -6,7 +10,7 @@ import { mockRuntimeInfo, processEnvLayer, } from "../../../tests/helpers/mocks.ts"; -import { getEffectiveConsent } from "./consent.ts"; +import { getEffectiveConsent, readTelemetryConfig } from "./consent.ts"; import type { TelemetryConfig } from "./types.ts"; function makeConfig(consent: TelemetryConfig["consent"]): TelemetryConfig { @@ -40,6 +44,14 @@ function emptyEnv() { ); } +function makeTempDir(): string { + return mkdtempSync(path.join(tmpdir(), "supabase-consent-test-")); +} + +function writeTelemetryFile(dir: string, content: string): void { + writeFileSync(path.join(dir, "telemetry.json"), content); +} + describe("getEffectiveConsent", () => { it.live("returns denied when DO_NOT_TRACK=1", () => Effect.gen(function* () { @@ -90,3 +102,18 @@ describe("getEffectiveConsent", () => { }).pipe(Effect.provide(emptyEnv())), ); }); + +describe("readTelemetryConfig", () => { + it.live("returns null for malformed JSON instead of throwing", () => { + const dir = makeTempDir(); + writeTelemetryFile(dir, ""); + + return Effect.gen(function* () { + const config = yield* readTelemetryConfig(dir); + expect(config).toBeNull(); + }).pipe( + Effect.provide(BunServices.layer), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ); + }); +}); diff --git a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts index c5fa963685..1e69610853 100644 --- a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { Effect, Layer } from "effect"; @@ -79,4 +79,20 @@ describe("telemetryRuntimeLayer", () => { Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), ); }); + + it.live("treats a malformed telemetry.json as a fresh first run instead of crashing", () => { + const homeDir = makeTempDir(); + const configPath = path.join(homeDir, "telemetry.json"); + writeFileSync(configPath, ""); + + return Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("granted"); + expect(runtime.isFirstRun).toBe(true); + expect(existsSync(configPath)).toBe(true); + }).pipe( + Effect.provide(buildLayer({ homeDir })), + Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), + ); + }); });