From 1de577e569987e6eef98222bb0b3fdf13bf0bc94 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:11:24 +0000 Subject: [PATCH 1/7] refactor(cli): cover `telemetry-state` and `linked-project-cache` with effect lint (CLI-2741) (#7206) ## TL;DR brings `telemetry-state` and `linked-project-cache` under the effect lint, along with the telemetry files that already pass ## whats introduced? - those files join the allow list, and `command-telemetry` follows in the next PR of this stack - `telemetry-state` draws its device and session ids from the `Crypto` service and reads the clock through `DateTime`, and the Go timestamp parser still reads years 0000 to 0099 as written - `linked-project-cache` writes the cache through a schema codec with the same bytes - the tests move to `withEnvVar`, temp dirs, schema codecs and the Effect clock, and pin a year 0001 timestamp ## ref - related: CLI-2741 --- .oxlintrc.effect.json | 5 + .../linked-project-cache.integration.test.ts | 224 ++--- .../telemetry/linked-project-cache.layer.ts | 15 +- .../src/telemetry/telemetry-state.layer.ts | 37 +- .../telemetry-state.layer.unit.test.ts | 763 ++++++++---------- 5 files changed, 516 insertions(+), 528 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index f2e7aa22cd..c2bb6e5060 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -9,6 +9,11 @@ "!packages/stack/**", "!apps/cli/src/commands/**", "!apps/cli/src/shared/**", + "!apps/cli/src/telemetry/analytics.*", + "!apps/cli/src/telemetry/command-telemetry-attributes.ts", + "!apps/cli/src/telemetry/linked-project-cache.*", + "!apps/cli/src/telemetry/telemetry-output-format.*", + "!apps/cli/src/telemetry/telemetry-state.*", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. "apps/cli/src/shared/cli/bin.ts", diff --git a/apps/cli/src/telemetry/linked-project-cache.integration.test.ts b/apps/cli/src/telemetry/linked-project-cache.integration.test.ts index 61ed4904a3..036a1ff54e 100644 --- a/apps/cli/src/telemetry/linked-project-cache.integration.test.ts +++ b/apps/cli/src/telemetry/linked-project-cache.integration.test.ts @@ -1,9 +1,6 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { mockAnalytics, mockTelemetryRuntime } from "../../tests/helpers/mocks.ts"; @@ -18,11 +15,14 @@ import { linkedProjectCache, linkedProjectCacheLayer } from "./linked-project-ca import { identityStitchLayer } from "../command-internal/identity-stitch.ts"; import { LinkedProjectCache } from "./linked-project-cache.service.ts"; +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + describe("linkedProjectCacheLayer", () => { - it.live( - "stitches session identity from the cache GET's X-Gotrue-Id (Go identityTransport)", - () => { - const workdir = mkdtempSync(join(tmpdir(), "linked-cache-")); + it.live("stitches session identity from the cache GET's X-Gotrue-Id (Go identityTransport)", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "linked-cache-" }); const analytics = mockAnalytics(); const api = mockCommandPlatformApi({ handler: (request) => @@ -61,7 +61,7 @@ describe("linkedProjectCacheLayer", () => { Layer.provide(analytics.layer), Layer.provide( mockTelemetryRuntime({ - configDir: join(workdir, ".supabase"), + configDir: path.join(workdir, ".supabase"), consent: "granted", distinctId: undefined, isCi: false, @@ -81,7 +81,7 @@ describe("linkedProjectCacheLayer", () => { Layer.provide(analytics.layer), Layer.provide(BunServices.layer), ); - return Effect.gen(function* () { + yield* Effect.gen(function* () { const cache = yield* LinkedProjectCache; yield* cache.cache(VALID_REF, workdir); expect(api.requests).toHaveLength(1); @@ -89,11 +89,12 @@ describe("linkedProjectCacheLayer", () => { activeFlags: {}, requests: api.requests, }); - expect(JSON.stringify(analytics.aliased)).toContain("gotrue-abc"); - const written: unknown = JSON.parse( - readFileSync(join(workdir, "supabase", ".temp", "linked-project.json"), "utf8"), + expect(yield* encodeJson(analytics.aliased)).toContain("gotrue-abc"); + expect( + yield* fs.readFileString(path.join(workdir, "supabase", ".temp", "linked-project.json")), + ).toBe( + `{"ref":"${VALID_REF}","name":"proj","organization_id":"org-1","organization_slug":"acme"}`, ); - expect((written as { ref: string }).ref).toBe(VALID_REF); expect(analytics.groupIdentified).toEqual([ { groupType: "organization", @@ -106,71 +107,117 @@ describe("linkedProjectCacheLayer", () => { properties: { name: "proj", organization_slug: "acme" }, }, ]); - rmSync(workdir, { recursive: true, force: true }); }).pipe(Effect.provide(layer)); - }, + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("does not re-identify groups when the linked-project cache already exists", () => { - const workdir = mkdtempSync(join(tmpdir(), "linked-cache-hit-")); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - writeFileSync( - join(workdir, "supabase", ".temp", "linked-project.json"), - JSON.stringify({ - ref: VALID_REF, - name: "proj", - organization_id: "org-1", - organization_slug: "acme", - }), - ); - const analytics = mockAnalytics(); - const api = mockCommandPlatformApi({ - handler: () => Effect.die("cache GET must not run on a cache hit"), - }); - const identityStitch = identityStitchLayer.pipe( - Layer.provide(analytics.layer), - Layer.provide( - mockTelemetryRuntime({ configDir: join(workdir, ".supabase"), consent: "granted" }), - ), - Layer.provide(BunServices.layer), - ); - const layer = linkedProjectCacheLayer.pipe( - Layer.provide(api.httpClientLayer), - Layer.provide(mockCommandSettings({ workdir })), - Layer.provide(mockCommandCredentialsLayer), - Layer.provide(identityStitch), - Layer.provide(analytics.layer), - Layer.provide(BunServices.layer), - ); - return Effect.gen(function* () { - const cache = yield* LinkedProjectCache; - yield* cache.cache(VALID_REF, workdir); - expect(api.requests).toEqual([]); - expect(analytics.groupIdentified).toEqual([]); - expect(analytics.aliased).toEqual([]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(layer)); - }); + it.live("does not re-identify groups when the linked-project cache already exists", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "linked-cache-hit-" }); + yield* fs.makeDirectory(path.join(workdir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", ".temp", "linked-project.json"), + yield* encodeJson({ + ref: VALID_REF, + name: "proj", + organization_id: "org-1", + organization_slug: "acme", + }), + ); + const analytics = mockAnalytics(); + const api = mockCommandPlatformApi({ + handler: () => Effect.die("cache GET must not run on a cache hit"), + }); + const identityStitch = identityStitchLayer.pipe( + Layer.provide(analytics.layer), + Layer.provide( + mockTelemetryRuntime({ configDir: path.join(workdir, ".supabase"), consent: "granted" }), + ), + Layer.provide(BunServices.layer), + ); + const layer = linkedProjectCacheLayer.pipe( + Layer.provide(api.httpClientLayer), + Layer.provide(mockCommandSettings({ workdir })), + Layer.provide(mockCommandCredentialsLayer), + Layer.provide(identityStitch), + Layer.provide(analytics.layer), + Layer.provide(BunServices.layer), + ); + yield* Effect.gen(function* () { + const cache = yield* LinkedProjectCache; + yield* cache.cache(VALID_REF, workdir); + expect(api.requests).toEqual([]); + expect(analytics.groupIdentified).toEqual([]); + expect(analytics.aliased).toEqual([]); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); it.live( "skips the fill when project-ref names a DIFFERENT ref: the cache must describe the linked workdir, not the resolved ref (PR #6168 review)", - () => { - // A mid-flight `link --project-ref B` failure still reaches the fill via `Effect.ensuring` - // while `project-ref` holds the old link — caching B would make the parent chain prefer a - // never-linked project. The guard runs before any token/network work, so the GET must - // never fire. - const OTHER_REF = "otherprojectrefabcde"; - const workdir = mkdtempSync(join(tmpdir(), "linked-cache-diverge-")); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(workdir, "supabase", ".temp", "project-ref"), VALID_REF); + () => + Effect.gen(function* () { + // A mid-flight `link --project-ref B` failure still reaches the fill via `Effect.ensuring` + // while `project-ref` holds the old link — caching B would make the parent chain prefer a + // never-linked project. The guard runs before any token/network work, so the GET must + // never fire. + const OTHER_REF = "otherprojectrefabcde"; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "linked-cache-diverge-" }); + yield* fs.makeDirectory(path.join(workdir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", ".temp", "project-ref"), + VALID_REF, + ); + const analytics = mockAnalytics(); + const api = mockCommandPlatformApi({ + handler: () => Effect.die("cache GET must not run when project-ref diverges"), + }); + const identityStitch = identityStitchLayer.pipe( + Layer.provide(analytics.layer), + Layer.provide( + mockTelemetryRuntime({ + configDir: path.join(workdir, ".supabase"), + consent: "granted", + }), + ), + Layer.provide(BunServices.layer), + ); + const layer = linkedProjectCacheLayer.pipe( + Layer.provide(api.httpClientLayer), + Layer.provide(mockCommandSettings({ workdir })), + Layer.provide(mockCommandCredentialsLayer), + Layer.provide(identityStitch), + Layer.provide(analytics.layer), + Layer.provide(BunServices.layer), + ); + yield* Effect.gen(function* () { + const cache = yield* LinkedProjectCache; + yield* cache.cache(OTHER_REF, workdir); + expect( + yield* fs.exists(path.join(workdir, "supabase", ".temp", "linked-project.json")), + ).toBe(false); + expect(analytics.groupIdentified).toEqual([]); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("leaves the command's result alone when the project lookup is forbidden", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "linked-cache-forbidden-" }); const analytics = mockAnalytics(); const api = mockCommandPlatformApi({ - handler: () => Effect.die("cache GET must not run when project-ref diverges"), + response: { status: 403, body: { message: "Forbidden" } }, }); const identityStitch = identityStitchLayer.pipe( Layer.provide(analytics.layer), Layer.provide( - mockTelemetryRuntime({ configDir: join(workdir, ".supabase"), consent: "granted" }), + mockTelemetryRuntime({ configDir: path.join(workdir, ".supabase"), consent: "granted" }), ), Layer.provide(BunServices.layer), ); @@ -182,44 +229,15 @@ describe("linkedProjectCacheLayer", () => { Layer.provide(analytics.layer), Layer.provide(BunServices.layer), ); - return Effect.gen(function* () { + yield* Effect.gen(function* () { const cache = yield* LinkedProjectCache; - yield* cache.cache(OTHER_REF, workdir); - expect(existsSync(join(workdir, "supabase", ".temp", "linked-project.json"))).toBe(false); + yield* cache.cache(VALID_REF, workdir); + expect(api.requests).toHaveLength(1); + expect( + yield* fs.exists(path.join(workdir, "supabase", ".temp", "linked-project.json")), + ).toBe(false); expect(analytics.groupIdentified).toEqual([]); - rmSync(workdir, { recursive: true, force: true }); }).pipe(Effect.provide(layer)); - }, + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - - it.live("leaves the command's result alone when the project lookup is forbidden", () => { - const workdir = mkdtempSync(join(tmpdir(), "linked-cache-forbidden-")); - const analytics = mockAnalytics(); - const api = mockCommandPlatformApi({ - response: { status: 403, body: { message: "Forbidden" } }, - }); - const identityStitch = identityStitchLayer.pipe( - Layer.provide(analytics.layer), - Layer.provide( - mockTelemetryRuntime({ configDir: join(workdir, ".supabase"), consent: "granted" }), - ), - Layer.provide(BunServices.layer), - ); - const layer = linkedProjectCacheLayer.pipe( - Layer.provide(api.httpClientLayer), - Layer.provide(mockCommandSettings({ workdir })), - Layer.provide(mockCommandCredentialsLayer), - Layer.provide(identityStitch), - Layer.provide(analytics.layer), - Layer.provide(BunServices.layer), - ); - return Effect.gen(function* () { - const cache = yield* LinkedProjectCache; - yield* cache.cache(VALID_REF, workdir); - expect(api.requests).toHaveLength(1); - expect(existsSync(join(workdir, "supabase", ".temp", "linked-project.json"))).toBe(false); - expect(analytics.groupIdentified).toEqual([]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(layer)); - }); }); diff --git a/apps/cli/src/telemetry/linked-project-cache.layer.ts b/apps/cli/src/telemetry/linked-project-cache.layer.ts index e86fad92e3..8b15f21922 100644 --- a/apps/cli/src/telemetry/linked-project-cache.layer.ts +++ b/apps/cli/src/telemetry/linked-project-cache.layer.ts @@ -1,5 +1,5 @@ import { makeApiClient } from "@supabase/api/effect"; -import { Effect, FileSystem, Layer, Option, Path, type Redacted } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, type Redacted, Schema } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import { CommandCredentials } from "../auth/command-credentials.service.ts"; @@ -21,6 +21,17 @@ export const linkedProjectCache = block("linked-project-cache", [ }, ]); +const encodeLinkedProjectCache = Schema.encodeEffect( + Schema.fromJsonString( + Schema.Struct({ + ref: Schema.String, + name: Schema.String, + organization_id: Schema.String, + organization_slug: Schema.String, + }), + ), +); + /** * Writes `/supabase/.temp/linked-project.json` after a `--project-ref` has been * resolved. No write if the cache already exists (`supabase link` is authoritative), and any @@ -113,7 +124,7 @@ export const linkedProjectCacheLayer = Layer.effect( }; yield* fs.makeDirectory(path.dirname(cachePath), { recursive: true }); - yield* fs.writeFileString(cachePath, JSON.stringify(linked)); + yield* fs.writeFileString(cachePath, yield* encodeLinkedProjectCache(linked)); // On the same cache miss, also publish the org/project group metadata via // `groupIdentify` (same payload shape as the link handler) before the post-run diff --git a/apps/cli/src/telemetry/telemetry-state.layer.ts b/apps/cli/src/telemetry/telemetry-state.layer.ts index 3ba8f232d3..1f17db424d 100644 --- a/apps/cli/src/telemetry/telemetry-state.layer.ts +++ b/apps/cli/src/telemetry/telemetry-state.layer.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Crypto, DateTime, Effect, FileSystem, Layer, Path } from "effect"; import { homedir } from "node:os"; import { Analytics } from "../shared/telemetry/analytics.service.ts"; @@ -93,11 +93,9 @@ function parseGoRfc3339Ms(text: string): number | undefined { if (day < 1 || day > daysInMonth(year, month)) return undefined; if (hour > 23 || minute > 59 || second > 59) return undefined; if (match[9] !== undefined && (Number(match[9]) > 24 || Number(match[10]) > 60)) return undefined; - // `setUTCFullYear` (not `Date.UTC`) so years 0000-0099 aren't remapped to - // 1900-1999; components are already range-checked, so no rollover occurs. - const date = new Date(0); - date.setUTCFullYear(year, month - 1, day); - date.setUTCHours(hour, minute, second, 0); + /* `DateTime.makeUnsafe` parts (not `Date.UTC`) so years 0000-0099 aren't remapped to + 1900-1999; components are already range-checked, so no rollover occurs. */ + const date = DateTime.makeUnsafe({ year, month, day, hour, minute, second, millisecond: 0 }); // At most 9 fractional digits (nanoseconds) are read; ms precision is exact for the 30-minute // comparison this feeds. const fractionMs = match[7] !== undefined ? Number(`0.${match[7].slice(0, 9)}`) * 1000 : 0; @@ -105,7 +103,7 @@ function parseGoRfc3339Ms(text: string): number | undefined { match[8] !== undefined ? (match[8] === "-" ? -1 : 1) * (Number(match[9]) * 3600 + Number(match[10]) * 60) * 1000 : 0; - return date.getTime() + fractionMs - offsetMs; + return DateTime.toEpochMillis(date) + fractionMs - offsetMs; } function isRecord(value: unknown): value is Record { @@ -390,16 +388,17 @@ export function readExistingState(text: string): PriorState | undefined { } export const loadOrCreateTelemetryState = Effect.fn("telemetry.loadOrCreateState")(function* ( - opts: { readonly now?: Date } = {}, + opts: { readonly now?: DateTime.Utc } = {}, ) { const fs = yield* FileSystem.FileSystem; const pathSvc = yield* Path.Path; + const crypto = yield* Crypto.Crypto; const filePath = telemetryPath(process.env, pathSvc); const exists = yield* fs.exists(filePath); const existing = exists ? yield* fs.readFileString(filePath) : undefined; const prior = existing !== undefined ? readExistingState(existing) : undefined; - const now = opts.now ?? new Date(); - const nowIso = now.toISOString(); + const now = opts.now ?? (yield* DateTime.now); + const nowIso = DateTime.formatIso(now); // The expiry comparison uses the epoch computed by `parseGoRfc3339Ms` during decode, not a // `new Date(string)` re-parse — a valid form JS can't parse (comma fraction `…00,5Z`, offsets @@ -407,13 +406,14 @@ export const loadOrCreateTelemetryState = Effect.fn("telemetry.loadOrCreateState // though the instant decoded fine and is still inside the 30-minute window. const priorActiveMs = prior?.sessionLastActiveMs; const expired = - priorActiveMs === undefined || now.getTime() - priorActiveMs > SESSION_ROTATION_MS; + priorActiveMs === undefined || + DateTime.toEpochMillis(now) - priorActiveMs > SESSION_ROTATION_MS; const state: State = { enabled: prior?.enabled ?? true, - device_id: prior?.device_id ?? crypto.randomUUID(), + device_id: prior?.device_id ?? (yield* crypto.randomUUIDv4), session_id: - !expired && prior?.session_id !== undefined ? prior.session_id : crypto.randomUUID(), + !expired && prior?.session_id !== undefined ? prior.session_id : yield* crypto.randomUUIDv4, session_last_active: nowIso, ...(prior?.distinct_id !== undefined ? { distinct_id: prior.distinct_id } : {}), // A decoded file's non-zero schema_version is kept. The numeric field is for in-memory @@ -432,7 +432,7 @@ export const loadOrCreateTelemetryState = Effect.fn("telemetry.loadOrCreateState export const setTelemetryEnabled = Effect.fn("telemetry.setEnabled")(function* ( enabled: boolean, - opts: { readonly now?: Date } = {}, + opts: { readonly now?: DateTime.Utc } = {}, ) { const state = yield* loadOrCreateTelemetryState(opts); if (state.enabled === enabled) return state; @@ -469,8 +469,9 @@ const persistIdentityReset = Effect.fn("telemetry.persistIdentityReset")(functio const base = yield* loadOrCreateTelemetryState(); const fs = yield* FileSystem.FileSystem; const pathSvc = yield* Path.Path; + const crypto = yield* Crypto.Crypto; const { distinct_id: _drop, ...rest } = base; - const nextState: State = { ...rest, device_id: crypto.randomUUID() }; + const nextState: State = { ...rest, device_id: yield* crypto.randomUUIDv4 }; const filePath = telemetryPath(process.env, pathSvc); yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); yield* fs.writeFileString(filePath, serializeTelemetryState(nextState)); @@ -494,13 +495,17 @@ export const telemetryStateLayer = Layer.effect( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const pathSvc = yield* Path.Path; + const crypto = yield* Crypto.Crypto; const analytics = yield* Analytics; const runtime = yield* TelemetryRuntime; - const provide = (effect: Effect.Effect) => + const provide = ( + effect: Effect.Effect, + ) => effect.pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, pathSvc), + Effect.provideService(Crypto.Crypto, crypto), ); return TelemetryState.of({ diff --git a/apps/cli/src/telemetry/telemetry-state.layer.unit.test.ts b/apps/cli/src/telemetry/telemetry-state.layer.unit.test.ts index 1a73b920f6..02fbfeb34c 100644 --- a/apps/cli/src/telemetry/telemetry-state.layer.unit.test.ts +++ b/apps/cli/src/telemetry/telemetry-state.layer.unit.test.ts @@ -1,12 +1,8 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import { afterEach, beforeEach } from "vitest"; +import { DateTime, Effect, FileSystem, Layer, Path, Schema } from "effect"; +import { useTempWorkdir, withEnvVar } from "../../tests/helpers/command-mocks.ts"; import { mockAnalytics } from "../../tests/helpers/mocks.ts"; import { TelemetryRuntime } from "../shared/telemetry/runtime.service.ts"; import { makeTelemetryIdentity } from "../shared/telemetry/identity.ts"; @@ -17,20 +13,11 @@ import { } from "./telemetry-state.layer.ts"; import { TelemetryState } from "./telemetry-state.service.ts"; -let tempHome: string; -let prevHome: string | undefined; +const tempHome = useTempWorkdir("supabase-telemetry-"); -beforeEach(() => { - tempHome = mkdtempSync(join(tmpdir(), "supabase-telemetry-")); - prevHome = process.env["SUPABASE_HOME"]; - process.env["SUPABASE_HOME"] = tempHome; -}); - -afterEach(() => { - if (prevHome === undefined) delete process.env["SUPABASE_HOME"]; - else process.env["SUPABASE_HOME"] = prevHome; - rmSync(tempHome, { recursive: true, force: true }); -}); +/* The state file resolves under `SUPABASE_HOME`, so point it at this test's temp dir. */ +const withTempHome = (body: Effect.Effect) => + withEnvVar("SUPABASE_HOME", tempHome.current, body); function makeRuntime(opts: { isCi?: boolean; isFirstRun?: boolean; isTty?: boolean } = {}) { const identity = makeTelemetryIdentity(undefined); @@ -61,21 +48,45 @@ function makeLayer( ); } -const telemetryPath = () => join(tempHome, "telemetry.json"); -const readState = (): Record => - JSON.parse(readFileSync(telemetryPath(), "utf8")) as Record; +const telemetryPath = Effect.gen(function* () { + const path = yield* Path.Path; + return path.join(tempHome.current, "telemetry.json"); +}); +const readTelemetryFile = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(yield* telemetryPath); +}).pipe(Effect.provide(BunServices.layer)); +const telemetryFileExists = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(yield* telemetryPath); +}).pipe(Effect.provide(BunServices.layer)); +const writeTelemetryFile = (contents: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(yield* telemetryPath, contents); + }).pipe(Effect.provide(BunServices.layer)); +const seedJson = (value: unknown) => + Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(value).pipe( + Effect.flatMap(writeTelemetryFile), + ); +/* `session_last_active` for a session that is current on the Effect clock the loader reads. */ +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); +const readState = readTelemetryFile.pipe( + Effect.flatMap( + Schema.decodeEffect(Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))), + ), +); const seedState = (distinctId?: string) => - writeFileSync( - telemetryPath(), - JSON.stringify({ + Effect.gen(function* () { + yield* seedJson({ enabled: true, device_id: "device-xyz", session_id: "session-1", - session_last_active: new Date().toISOString(), + session_last_active: yield* nowIso, ...(distinctId !== undefined ? { distinct_id: distinctId } : {}), schema_version: 1, - }), - ); + }); + }); describe("telemetryStateLayer.stitchLogin / clearDistinctId", () => { it.effect("stitchLogin in a persistent runtime aliases, persists, and stamps", () => { @@ -85,9 +96,9 @@ describe("telemetryStateLayer.stitchLogin / clearDistinctId", () => { const state = yield* TelemetryState; yield* state.stitchLogin("gotrue-1"); expect(analytics.aliased).toEqual([{ distinctId: "gotrue-1", alias: "device-xyz" }]); - expect(readState().distinct_id).toBe("gotrue-1"); + expect((yield* readState).distinct_id).toBe("gotrue-1"); expect(runtime.identity.current()).toBe("gotrue-1"); - }).pipe(Effect.provide(makeLayer(analytics, runtime))); + }).pipe(Effect.provide(makeLayer(analytics, runtime)), withTempHome); }); it.effect( @@ -99,9 +110,9 @@ describe("telemetryStateLayer.stitchLogin / clearDistinctId", () => { const state = yield* TelemetryState; yield* state.stitchLogin("gotrue-ci"); expect(analytics.aliased).toEqual([]); - expect(existsSync(telemetryPath())).toBe(false); + expect(yield* telemetryFileExists).toBe(false); expect(runtime.identity.current()).toBe("gotrue-ci"); - }).pipe(Effect.provide(makeLayer(analytics, runtime))); + }).pipe(Effect.provide(makeLayer(analytics, runtime)), withTempHome); }, ); @@ -112,63 +123,63 @@ describe("telemetryStateLayer.stitchLogin / clearDistinctId", () => { const state = yield* TelemetryState; yield* state.stitchLogin("gotrue-npx"); expect(analytics.aliased).toEqual([]); - expect(existsSync(telemetryPath())).toBe(false); + expect(yield* telemetryFileExists).toBe(false); expect(runtime.identity.current()).toBe("gotrue-npx"); - }).pipe(Effect.provide(makeLayer(analytics, runtime))); + }).pipe(Effect.provide(makeLayer(analytics, runtime)), withTempHome); }); it.effect("stitchLogin replaces a stale distinct_id (parity: stale id is replaced)", () => { - seedState("stale-id"); const analytics = mockAnalytics(); return Effect.gen(function* () { + yield* seedState("stale-id"); const state = yield* TelemetryState; yield* state.stitchLogin("fresh-id"); - expect(readState().distinct_id).toBe("fresh-id"); - }).pipe(Effect.provide(makeLayer(analytics))); + expect((yield* readState).distinct_id).toBe("fresh-id"); + }).pipe(Effect.provide(makeLayer(analytics)), withTempHome); }); it.effect("stitchLogin with an existing identity persists and stamps without re-aliasing", () => { - seedState("user-a"); const analytics = mockAnalytics(); const runtime = makeRuntime(); runtime.identity.stamp("user-a"); return Effect.gen(function* () { + yield* seedState("user-a"); const state = yield* TelemetryState; yield* state.stitchLogin("user-b"); expect(analytics.aliased).toEqual([]); - expect(readState().distinct_id).toBe("user-b"); + expect((yield* readState).distinct_id).toBe("user-b"); expect(runtime.identity.current()).toBe("user-b"); - }).pipe(Effect.provide(makeLayer(analytics, runtime))); + }).pipe(Effect.provide(makeLayer(analytics, runtime)), withTempHome); }); it.effect("resetIdentity rotates the device id and forgets the user", () => { - seedState("user-a"); const analytics = mockAnalytics(); const runtime = makeRuntime(); runtime.identity.stamp("user-a"); return Effect.gen(function* () { + yield* seedState("user-a"); const state = yield* TelemetryState; yield* state.resetIdentity; - const next = readState(); + const next = yield* readState; expect(next.distinct_id).toBeUndefined(); expect(next.device_id).not.toBe("device-xyz"); expect(runtime.identity.current()).toBeUndefined(); - }).pipe(Effect.provide(makeLayer(analytics, runtime))); + }).pipe(Effect.provide(makeLayer(analytics, runtime)), withTempHome); }); it.effect( "clearDistinctId removes the persisted distinct_id and empties the in-process identity", () => { - seedState("to-clear"); const analytics = mockAnalytics(); const runtime = makeRuntime(); runtime.identity.stamp("to-clear"); return Effect.gen(function* () { + yield* seedState("to-clear"); const state = yield* TelemetryState; yield* state.clearDistinctId; - expect(readState().distinct_id).toBeUndefined(); + expect((yield* readState).distinct_id).toBeUndefined(); expect(runtime.identity.current()).toBeUndefined(); - }).pipe(Effect.provide(makeLayer(analytics, runtime))); + }).pipe(Effect.provide(makeLayer(analytics, runtime)), withTempHome); }, ); }); @@ -176,669 +187,607 @@ describe("telemetryStateLayer.stitchLogin / clearDistinctId", () => { describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing recovery)", () => { const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; - const runLoad = () => loadOrCreateTelemetryState().pipe(Effect.provide(BunServices.layer)); - const runLoadAt = (now: Date) => - loadOrCreateTelemetryState({ now }).pipe(Effect.provide(BunServices.layer)); + const runLoad = () => + loadOrCreateTelemetryState().pipe(Effect.provide(BunServices.layer), withTempHome); + const runLoadAt = (now: DateTime.Utc) => + loadOrCreateTelemetryState({ now }).pipe(Effect.provide(BunServices.layer), withTempHome); - it.effect("a bool-only file missing device_id/session_id is wholly regenerated", () => { - writeFileSync(telemetryPath(), JSON.stringify({ enabled: false })); - return Effect.gen(function* () { + it.effect("a bool-only file missing device_id/session_id is wholly regenerated", () => + Effect.gen(function* () { + yield* seedJson({ enabled: false }); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).toMatch(UUID_RE); expect(state.session_id).toMatch(UUID_RE); - }); - }); + }), + ); - it.effect("an empty device_id string invalidates an otherwise-valid file", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ + it.effect("an empty device_id string invalidates an otherwise-valid file", () => + Effect.gen(function* () { + yield* seedJson({ enabled: false, device_id: "", session_id: "session-1", - session_last_active: new Date().toISOString(), + session_last_active: yield* nowIso, schema_version: 2, - }), - ); - return Effect.gen(function* () { + }); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).toMatch(UUID_RE); expect(state.session_id).not.toBe("session-1"); - }); - }); + }), + ); - it.effect("a fully valid file with a recent session is preserved verbatim", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ + it.effect("a fully valid file with a recent session is preserved verbatim", () => + Effect.gen(function* () { + yield* seedJson({ enabled: false, device_id: "d", session_id: "s", - session_last_active: new Date().toISOString(), + session_last_active: yield* nowIso, schema_version: 2, - }), - ); - return Effect.gen(function* () { + }); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); expect(state.session_id).toBe("s"); expect(state.schema_version).toBe(2); - }); - }); + }), + ); it.effect( "the consent form with a unix-millis session_last_active decodes and preserves enabled:false", - () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ + () => + Effect.gen(function* () { + yield* seedJson({ consent: "denied", device_id: "d", session_id: "s", session_last_active: 1750000000000, - }), - ); - return Effect.gen(function* () { + }); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); - }); - }, + }), ); - it.effect("a mistyped enabled on the consent form is malformed and is wholly regenerated", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ + it.effect("a mistyped enabled on the consent form is malformed and is wholly regenerated", () => + Effect.gen(function* () { + yield* seedJson({ consent: "denied", enabled: "invalid", device_id: "d", session_id: "s", - session_last_active: new Date().toISOString(), - }), - ); - return Effect.gen(function* () { + session_last_active: yield* nowIso, + }); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); expect(state.session_id).not.toBe("s"); - }); - }); + }), + ); - it.effect("a null enabled on the consent form decodes and preserves the state", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ + it.effect("a null enabled on the consent form decodes and preserves the state", () => + Effect.gen(function* () { + yield* seedJson({ consent: "denied", enabled: null, device_id: "d", session_id: "s", - session_last_active: new Date().toISOString(), - }), - ); - return Effect.gen(function* () { + session_last_active: yield* nowIso, + }); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); expect(state.session_id).toBe("s"); - }); - }); + }), + ); - it.effect("an unrecognized consent value is malformed and is wholly regenerated", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ + it.effect("an unrecognized consent value is malformed and is wholly regenerated", () => + Effect.gen(function* () { + yield* seedJson({ consent: "maybe", device_id: "d", session_id: "s", - session_last_active: new Date().toISOString(), - }), - ); - return Effect.gen(function* () { + session_last_active: yield* nowIso, + }); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); expect(state.session_id).not.toBe("s"); - }); - }); + }), + ); it.effect( "a calendar-invalid session_last_active (Feb 29, non-leap year) is wholly regenerated", - () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ + () => + Effect.gen(function* () { + yield* seedJson({ enabled: false, device_id: "d", session_id: "s", session_last_active: "2025-02-29T00:00:00Z", - }), - ); - return Effect.gen(function* () { + }); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).toMatch(UUID_RE); expect(state.session_id).toMatch(UUID_RE); - }); - }, + }), ); - it.effect("a valid leap-day session_last_active decodes and preserves the state", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ + it.effect("a valid leap-day session_last_active decodes and preserves the state", () => + Effect.gen(function* () { + yield* seedJson({ enabled: false, device_id: "d", session_id: "s", session_last_active: "2024-02-29T00:00:00Z", - }), - ); - return Effect.gen(function* () { + }); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); - }); - }); + }), + ); - it.effect("an out-of-range hour (T24) in session_last_active is wholly regenerated", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ + it.effect("an out-of-range hour (T24) in session_last_active is wholly regenerated", () => + Effect.gen(function* () { + yield* seedJson({ enabled: false, device_id: "d", session_id: "s", session_last_active: "2025-01-01T24:00:00Z", - }), - ); - return Effect.gen(function* () { + }); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); - }); - }); + }), + ); it.effect( "a Go-valid zone offset JS cannot parse (+24:00) still decodes and preserves the state", - () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ + () => + Effect.gen(function* () { + yield* seedJson({ enabled: false, device_id: "d", session_id: "s", session_last_active: "2025-01-01T00:00:00+24:00", - }), - ); - return Effect.gen(function* () { + }); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); - }); - }, + }), ); - it.effect("a Go-valid comma fractional-second separator decodes and preserves the state", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ + it.effect("a Go-valid comma fractional-second separator decodes and preserves the state", () => + Effect.gen(function* () { + yield* seedJson({ enabled: false, device_id: "d", session_id: "s", session_last_active: "2025-01-01T00:00:00,123Z", - }), - ); - return Effect.gen(function* () { + }); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); - }); - }); + }), + ); - it.effect("a comma with no fractional digits is malformed and is wholly regenerated", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ + it.effect("a comma with no fractional digits is malformed and is wholly regenerated", () => + Effect.gen(function* () { + yield* seedJson({ enabled: false, device_id: "d", session_id: "s", session_last_active: "2025-01-01T00:00:00,Z", - }), - ); - return Effect.gen(function* () { + }); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); - }); - }); + }), + ); - it.effect("a recent comma-fraction timestamp keeps the session id within 30 minutes", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ + it.effect("a recent comma-fraction timestamp keeps the session id within 30 minutes", () => + Effect.gen(function* () { + yield* seedJson({ enabled: false, device_id: "d", session_id: "s", session_last_active: "2025-01-01T00:00:00,5Z", - }), - ); - return Effect.gen(function* () { - const state = yield* runLoadAt(new Date("2025-01-01T00:10:00Z")); + }); + const state = yield* runLoadAt(DateTime.makeUnsafe("2025-01-01T00:10:00Z")); expect(state.session_id).toBe("s"); expect(state.device_id).toBe("d"); expect(state.enabled).toBe(false); - }); - }); + }), + ); - it.effect("a Go-exotic +05:60 offset participates in the expiry arithmetic", () => { + it.effect("a Go-exotic +05:60 offset participates in the expiry arithmetic", () => // `+05:60` normalizes to a 6-hour offset, so this instant is 2025-01-01T00:00:00Z — 10 // minutes before `now`, so the session is retained. (JS `new Date` returns NaN for // minute-60 offsets, which would wrongly rotate it.) - writeFileSync( - telemetryPath(), - JSON.stringify({ + Effect.gen(function* () { + yield* seedJson({ enabled: false, device_id: "d", session_id: "s", session_last_active: "2025-01-01T06:00:00+05:60", - }), - ); - return Effect.gen(function* () { - const state = yield* runLoadAt(new Date("2025-01-01T00:10:00Z")); + }); + const state = yield* runLoadAt(DateTime.makeUnsafe("2025-01-01T00:10:00Z")); expect(state.session_id).toBe("s"); expect(state.device_id).toBe("d"); - }); - }); + }), + ); - it.effect("a +24:00 offset shifts the instant a full day back, expiring the session", () => { + it.effect("a +24:00 offset shifts the instant a full day back, expiring the session", () => // Wall clock 2025-01-01T00:00:00 at +24:00 is 2024-12-31T00:00:00Z, so at `now` = // 2025-01-01T00:10:00Z the session is 24h10m stale and rotates. Reading the wall clock as // UTC (ignoring the offset) would wrongly retain it. - writeFileSync( - telemetryPath(), - JSON.stringify({ + Effect.gen(function* () { + yield* seedJson({ enabled: false, device_id: "d", session_id: "s", session_last_active: "2025-01-01T00:00:00+24:00", - }), - ); - return Effect.gen(function* () { - const state = yield* runLoadAt(new Date("2025-01-01T00:10:00Z")); + }); + const state = yield* runLoadAt(DateTime.makeUnsafe("2025-01-01T00:10:00Z")); expect(state.session_id).not.toBe("s"); expect(state.device_id).toBe("d"); expect(state.enabled).toBe(false); - }); - }); + }), + ); - it.effect("consent-form unix millis beyond the JS Date range preserve the state like Go", () => { + it.effect("a year 0001 timestamp is not remapped to 1901, expiring the session", () => + Effect.gen(function* () { + yield* seedJson({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "0001-01-01T00:00:00Z", + }); + const state = yield* runLoadAt(DateTime.makeUnsafe("1901-01-01T00:10:00Z")); + expect(state.session_id).not.toBe("s"); + expect(state.device_id).toBe("d"); + }), + ); + + it.effect("consent-form unix millis beyond the JS Date range preserve the state like Go", () => // A far-future instant (~year 287396): the elapsed-time comparison is hugely negative, so // the session is never expired and the state decodes. Kept as a plain number here since a // `Date`/`toISOString` round-trip throws beyond ±8.64e15. - writeFileSync( - telemetryPath(), - JSON.stringify({ + Effect.gen(function* () { + yield* seedJson({ consent: "denied", device_id: "d", session_id: "s", session_last_active: 9_000_000_000_000_000, - }), - ); - return Effect.gen(function* () { + }); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); expect(state.session_id).toBe("s"); - }); - }); + }), + ); - it.effect("consent-form unix millis beyond the int64 range regenerate everything like Go", () => { + it.effect("consent-form unix millis beyond the int64 range regenerate everything like Go", () => // A float/exponent token (`1e+100`) is rejected outright for an int64 field, so this // regenerates wholesale even though the file said "denied". - writeFileSync( - telemetryPath(), - JSON.stringify({ + Effect.gen(function* () { + yield* seedJson({ consent: "denied", device_id: "d", session_id: "s", session_last_active: 1e100, - }), - ); - return Effect.gen(function* () { + }); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); expect(state.session_id).not.toBe("s"); - }); - }); + }), + ); - it.effect("consent-form unix millis at Go's int64 bounds preserve the state", () => { + it.effect("consent-form unix millis at Go's int64 bounds preserve the state", () => // Hand-built JSON so the raw text pins the exact int64 max literal 9223372036854775807 // (`JSON.stringify` of the rounded double would emit a different literal). The raw-token // check accepts it via exact BigInt bounds — the parsed double rounds to 2^63 and can't be // distinguished from the invalid 9223372036854775808 (see the companion test below). - writeFileSync( - telemetryPath(), - '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":9223372036854775807}', - ); - return Effect.gen(function* () { + Effect.gen(function* () { + yield* writeTelemetryFile( + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":9223372036854775807}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); expect(state.session_id).toBe("s"); - }); - }); + }), + ); - it.effect("consent-form unix millis at Go's int64 min decode but expire the session", () => { + it.effect("consent-form unix millis at Go's int64 min decode but expire the session", () => // int64 min -9223372036854775808 = -(2^63) is exactly representable as a double, so this // literal round-trips precisely. The instant is far past, so the file decodes // (enabled/device_id preserved, no wholesale regeneration) while the stale session id rotates. - writeFileSync( - telemetryPath(), - '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":-9223372036854775808}', - ); - return Effect.gen(function* () { + Effect.gen(function* () { + yield* writeTelemetryFile( + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":-9223372036854775808}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); expect(state.session_id).not.toBe("s"); - }); - }); + }), + ); - it.effect("consent-form unix millis written as an exponent token regenerate like Go", () => { - writeFileSync( - telemetryPath(), - '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":1e3}', - ); - return Effect.gen(function* () { + it.effect("consent-form unix millis written as an exponent token regenerate like Go", () => + Effect.gen(function* () { + yield* writeTelemetryFile( + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":1e3}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); expect(state.session_id).not.toBe("s"); - }); - }); + }), + ); - it.effect( - "consent-form unix millis written as an integer-valued float regenerate like Go", - () => { - writeFileSync( - telemetryPath(), + it.effect("consent-form unix millis written as an integer-valued float regenerate like Go", () => + Effect.gen(function* () { + yield* writeTelemetryFile( '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":1750000000000.0}', ); - return Effect.gen(function* () { - const state = yield* runLoad(); - expect(state.enabled).toBe(true); - expect(state.device_id).not.toBe("d"); - expect(state.session_id).not.toBe("s"); - }); - }, + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).not.toBe("d"); + expect(state.session_id).not.toBe("s"); + }), ); - it.effect("consent-form unix millis one past int64 max regenerate exactly like Go", () => { + it.effect("consent-form unix millis one past int64 max regenerate exactly like Go", () => // 9223372036854775808 parses to the same double as the int64 max literal 9223372036854775807 // (both round to 2^63), so only the raw token can tell them apart. - writeFileSync( - telemetryPath(), - '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":9223372036854775808}', - ); - return Effect.gen(function* () { + Effect.gen(function* () { + yield* writeTelemetryFile( + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":9223372036854775808}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); expect(state.session_id).not.toBe("s"); - }); - }); + }), + ); - it.effect("a non-integer number token nested under an unknown key stays out of scope", () => { + it.effect("a non-integer number token nested under an unknown key stays out of scope", () => // The raw-token capture is scoped to the root object by holder identity, and unknown fields // are ignored entirely, so a nested `session_last_active` must neither shadow nor invalidate // the valid top-level millis. - writeFileSync( - telemetryPath(), - '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":1750000000000,"extra":{"session_last_active":1.5}}', - ); - return Effect.gen(function* () { + Effect.gen(function* () { + yield* writeTelemetryFile( + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":1750000000000,"extra":{"session_last_active":1.5}}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); - }); - }); + }), + ); - it.effect("a schema_version written as an integer-valued float regenerates like Go", () => { + it.effect("a schema_version written as an integer-valued float regenerates like Go", () => // `schema_version` is an int field, where the token `1.0` is rejected, so the whole file is // malformed and regenerated even though `JSON.parse` reads it as 1. - writeFileSync( - telemetryPath(), - '{"enabled":false,"device_id":"d","session_id":"s","session_last_active":"2026-01-01T00:00:00Z","schema_version":1.0}', - ); - return Effect.gen(function* () { + Effect.gen(function* () { + yield* writeTelemetryFile( + '{"enabled":false,"device_id":"d","session_id":"s","session_last_active":"2026-01-01T00:00:00Z","schema_version":1.0}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); expect(state.session_id).not.toBe("s"); - }); - }); + }), + ); - it.effect("a schema_version beyond the int64 range regenerates everything like Go", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ + it.effect("a schema_version beyond the int64 range regenerates everything like Go", () => + Effect.gen(function* () { + yield* seedJson({ enabled: false, device_id: "d", session_id: "s", - session_last_active: new Date().toISOString(), + session_last_active: yield* nowIso, schema_version: 1e100, - }), - ); - return Effect.gen(function* () { + }); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); expect(state.session_id).not.toBe("s"); - }); - }); + }), + ); describe("duplicate root keys (Go per-occurrence decoding)", () => { - it.effect("a wrong-typed earlier consent regenerates even when the final one is valid", () => { - writeFileSync( - telemetryPath(), - '{"consent":false,"consent":"denied","session_last_active":1750000000000,"device_id":"d","session_id":"s"}', - ); - return Effect.gen(function* () { + it.effect("a wrong-typed earlier consent regenerates even when the final one is valid", () => + Effect.gen(function* () { + yield* writeTelemetryFile( + '{"consent":false,"consent":"denied","session_last_active":1750000000000,"device_id":"d","session_id":"s"}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); - }); - }); + }), + ); - it.effect("a wrong-typed FINAL consent regenerates too", () => { - writeFileSync( - telemetryPath(), - '{"consent":"denied","consent":false,"session_last_active":1750000000000,"device_id":"d","session_id":"s"}', - ); - return Effect.gen(function* () { + it.effect("a wrong-typed FINAL consent regenerates too", () => + Effect.gen(function* () { + yield* writeTelemetryFile( + '{"consent":"denied","consent":false,"session_last_active":1750000000000,"device_id":"d","session_id":"s"}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); - }); - }); + }), + ); - it.effect("well-typed duplicate enabled decodes cleanly with last-value-wins", () => { - writeFileSync( - telemetryPath(), - `{"enabled":true,"enabled":false,"session_last_active":${JSON.stringify(new Date().toISOString())},"device_id":"d","session_id":"s"}`, - ); - return Effect.gen(function* () { + it.effect("well-typed duplicate enabled decodes cleanly with last-value-wins", () => + Effect.gen(function* () { + yield* writeTelemetryFile( + `{"enabled":true,"enabled":false,"session_last_active":"${yield* nowIso}","device_id":"d","session_id":"s"}`, + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); expect(state.session_id).toBe("s"); - }); - }); + }), + ); - it.effect("a non-integer earlier schema_version token regenerates like Go", () => { + it.effect("a non-integer earlier schema_version token regenerates like Go", () => // `1e3` is rejected for the int field on its first occurrence; the valid `2` after it // cannot save the file. - writeFileSync( - telemetryPath(), - '{"consent":"granted","session_last_active":1750000000000,"device_id":"d","session_id":"s","schema_version":1e3,"schema_version":2}', - ); - return Effect.gen(function* () { + Effect.gen(function* () { + yield* writeTelemetryFile( + '{"consent":"granted","session_last_active":1750000000000,"device_id":"d","session_id":"s","schema_version":1e3,"schema_version":2}', + ); const state = yield* runLoad(); expect(state.device_id).not.toBe("d"); expect(state.schema_version).toBe(1); - }); - }); + }), + ); - it.effect("duplicate session_last_active takes the last token (json.RawMessage)", () => { + it.effect("duplicate session_last_active takes the last token (json.RawMessage)", () => // This field is never type-checked per occurrence — only the final token is parsed, so // junk before it is fine. - writeFileSync( - telemetryPath(), - '{"consent":"denied","session_last_active":true,"session_last_active":1750000000000,"device_id":"d","session_id":"s"}', - ); - return Effect.gen(function* () { + Effect.gen(function* () { + yield* writeTelemetryFile( + '{"consent":"denied","session_last_active":true,"session_last_active":1750000000000,"device_id":"d","session_id":"s"}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); - }); - }); + }), + ); - it.effect( - "a wrong-typed earlier device_id regenerates even when the final one is valid", - () => { - writeFileSync( - telemetryPath(), - `{"enabled":false,"device_id":0,"device_id":"d","session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())}}`, + it.effect("a wrong-typed earlier device_id regenerates even when the final one is valid", () => + Effect.gen(function* () { + yield* writeTelemetryFile( + `{"enabled":false,"device_id":0,"device_id":"d","session_id":"s","session_last_active":"${yield* nowIso}"}`, ); - return Effect.gen(function* () { - const state = yield* runLoad(); - expect(state.enabled).toBe(true); - expect(state.device_id).not.toBe("d"); - }); - }, + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).not.toBe("d"); + }), ); - it.effect("null occurrences are decode-valid for pointer and string fields alike", () => { + it.effect("null occurrences are decode-valid for pointer and string fields alike", () => // `null` decodes cleanly for both a boolean field (later duplicate overwrites) and a // string field (no-op) — nothing here is rejected. - writeFileSync( - telemetryPath(), - `{"enabled":null,"enabled":false,"device_id":null,"device_id":"d","session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())}}`, - ); - return Effect.gen(function* () { + Effect.gen(function* () { + yield* writeTelemetryFile( + `{"enabled":null,"enabled":false,"device_id":null,"device_id":"d","session_id":"s","session_last_active":"${yield* nowIso}"}`, + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); expect(state.session_id).toBe("s"); - }); - }); + }), + ); - it.effect("a null FINAL device_id keeps the earlier value (null is a decode no-op)", () => { + it.effect("a null FINAL device_id keeps the earlier value (null is a decode no-op)", () => // `device_id` keeps `"d"` — decoding `null` into this field leaves the previous // occurrence's value in place, where `JSON.parse`'s last-value-wins would surface `null` // and wrongly regenerate. - writeFileSync( - telemetryPath(), - `{"enabled":false,"device_id":"d","device_id":null,"session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())}}`, - ); - return Effect.gen(function* () { + Effect.gen(function* () { + yield* writeTelemetryFile( + `{"enabled":false,"device_id":"d","device_id":null,"session_id":"s","session_last_active":"${yield* nowIso}"}`, + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); expect(state.session_id).toBe("s"); - }); - }); + }), + ); - it.effect("a null FINAL schema_version keeps the earlier non-zero value", () => { - writeFileSync( - telemetryPath(), - `{"enabled":false,"device_id":"d","session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())},"schema_version":7,"schema_version":null}`, - ); - return Effect.gen(function* () { + it.effect("a null FINAL schema_version keeps the earlier non-zero value", () => + Effect.gen(function* () { + yield* writeTelemetryFile( + `{"enabled":false,"device_id":"d","session_id":"s","session_last_active":"${yield* nowIso}","schema_version":7,"schema_version":null}`, + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.schema_version).toBe(7); - }); - }); + }), + ); - it.effect("wrong-typed duplicates of UNKNOWN keys never invalidate the file", () => { + it.effect("wrong-typed duplicates of UNKNOWN keys never invalidate the file", () => // Unknown fields are skipped untyped, so no occurrence of `junk` can error. - writeFileSync( - telemetryPath(), - `{"enabled":false,"junk":false,"junk":"x","device_id":"d","session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())}}`, - ); - return Effect.gen(function* () { + Effect.gen(function* () { + yield* writeTelemetryFile( + `{"enabled":false,"junk":false,"junk":"x","device_id":"d","session_id":"s","session_last_active":"${yield* nowIso}"}`, + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); - }); - }); + }), + ); - it.effect("an escaped duplicate key is unescaped before field matching, like Go", () => { + it.effect("an escaped duplicate key is unescaped before field matching, like Go", () => // Key tokens are unescaped before field matching, so `"\u0063onsent":false` is a // wrong-typed `consent` occurrence. - writeFileSync( - telemetryPath(), - '{"\\u0063onsent":false,"consent":"denied","session_last_active":1750000000000,"device_id":"d","session_id":"s"}', - ); - return Effect.gen(function* () { + Effect.gen(function* () { + yield* writeTelemetryFile( + '{"\\u0063onsent":false,"consent":"denied","session_last_active":1750000000000,"device_id":"d","session_id":"s"}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); - }); - }); + }), + ); }); }); describe("exact int64 schema_version round-trip (Go json.Marshal parity)", () => { - const runLoad = () => loadOrCreateTelemetryState().pipe(Effect.provide(BunServices.layer)); + const runLoad = () => + loadOrCreateTelemetryState().pipe(Effect.provide(BunServices.layer), withTempHome); // File contents are hand-built strings: `JSON.stringify(9007199254740993)` // would round inside the test itself, hiding exactly the bug under test. - const fileWith = (schemaVersionToken: string): string => - `{"enabled":false,"device_id":"d","session_id":"s","session_last_active":${JSON.stringify( - new Date().toISOString(), - )},"schema_version":${schemaVersionToken}}`; + const fileWith = (schemaVersionToken: string) => + nowIso.pipe( + Effect.map( + (now) => + `{"enabled":false,"device_id":"d","session_id":"s","session_last_active":"${now}","schema_version":${schemaVersionToken}}`, + ), + ); - it.effect("a valid schema_version above 2^53 is persisted verbatim, like Go's int64", () => { - writeFileSync(telemetryPath(), fileWith("9007199254740993")); - return Effect.gen(function* () { + it.effect("a valid schema_version above 2^53 is persisted verbatim, like Go's int64", () => + Effect.gen(function* () { + yield* writeTelemetryFile(yield* fileWith("9007199254740993")); yield* runLoad(); - const written = readFileSync(telemetryPath(), "utf8"); + const written = yield* readTelemetryFile; expect(written).toContain('"schema_version":9007199254740993'); expect(written).not.toContain("9007199254740992"); - }); - }); + }), + ); - it.effect("the int64 maximum round-trips exactly", () => { - writeFileSync(telemetryPath(), fileWith("9223372036854775807")); - return Effect.gen(function* () { + it.effect("the int64 maximum round-trips exactly", () => + Effect.gen(function* () { + yield* writeTelemetryFile(yield* fileWith("9223372036854775807")); yield* runLoad(); - const written = readFileSync(telemetryPath(), "utf8"); + const written = yield* readTelemetryFile; expect(written).toContain('"schema_version":9223372036854775807'); - }); - }); + }), + ); - it.effect("setTelemetryEnabled's rewrite also preserves the exact token", () => { - writeFileSync(telemetryPath(), fileWith("9007199254740993")); - return Effect.gen(function* () { - yield* setTelemetryEnabled(true).pipe(Effect.provide(BunServices.layer)); - const written = readFileSync(telemetryPath(), "utf8"); + it.effect("setTelemetryEnabled's rewrite also preserves the exact token", () => + Effect.gen(function* () { + yield* writeTelemetryFile(yield* fileWith("9007199254740993")); + yield* setTelemetryEnabled(true).pipe(Effect.provide(BunServices.layer), withTempHome); + const written = yield* readTelemetryFile; expect(written).toContain('"enabled":true'); expect(written).toContain('"schema_version":9007199254740993'); - }); - }); + }), + ); - it.effect("a zero schema_version still falls back to the current constant, like Go", () => { - writeFileSync(telemetryPath(), fileWith("0")); - return Effect.gen(function* () { + it.effect("a zero schema_version still falls back to the current constant, like Go", () => + Effect.gen(function* () { + yield* writeTelemetryFile(yield* fileWith("0")); const state = yield* runLoad(); expect(state.schema_version).toBe(1); - const written = readFileSync(telemetryPath(), "utf8"); + const written = yield* readTelemetryFile; expect(written).toContain('"schema_version":1'); - }); - }); + }), + ); }); From 86d16bb4d1f4e25bdf7b0b34a5caec49af30fdfd Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:11:24 +0000 Subject: [PATCH 2/7] refactor(cli): cover the rest of the `telemetry` area with effect lint (CLI-2741) (#7207) ## TL;DR brings `command-telemetry` under the effect lint, which completes the `telemetry` area ## whats introduced? - the per file entries collapse into `telemetry/**` - `validateOutputFormat` yields its error directly instead of through `Effect.fail`, with the same error and message - the tests merge the stacked `Effect.provide` calls into one layer each, drop the two `InvalidOutputFormatError` casts, use `Effect.succeed` for constants and run the secret leak check through a schema codec instead of `JSON.stringify` ## ref - closes: CLI-2741 --- .oxlintrc.effect.json | 6 +- apps/cli/src/telemetry/command-telemetry.ts | 8 +- .../telemetry/command-telemetry.unit.test.ts | 962 ++++++++++-------- 3 files changed, 550 insertions(+), 426 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index c2bb6e5060..0359d128e2 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -9,11 +9,7 @@ "!packages/stack/**", "!apps/cli/src/commands/**", "!apps/cli/src/shared/**", - "!apps/cli/src/telemetry/analytics.*", - "!apps/cli/src/telemetry/command-telemetry-attributes.ts", - "!apps/cli/src/telemetry/linked-project-cache.*", - "!apps/cli/src/telemetry/telemetry-output-format.*", - "!apps/cli/src/telemetry/telemetry-state.*", + "!apps/cli/src/telemetry/**", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. "apps/cli/src/shared/cli/bin.ts", diff --git a/apps/cli/src/telemetry/command-telemetry.ts b/apps/cli/src/telemetry/command-telemetry.ts index 954ed420bf..679082d5fa 100644 --- a/apps/cli/src/telemetry/command-telemetry.ts +++ b/apps/cli/src/telemetry/command-telemetry.ts @@ -99,11 +99,9 @@ export const validateOutputFormat = (allowed: ReadonlyArray) => if (Option.isNone(flag) || Option.isNone(flag.value)) return; const value = flag.value.value; if (allowed.includes(value)) return; - return yield* Effect.fail( - new InvalidOutputFormatError({ - message: invalidOutputFormatMessage(value, allowed), - }), - ); + return yield* new InvalidOutputFormatError({ + message: invalidOutputFormatMessage(value, allowed), + }); }); const REDACTED_VALUE = ""; diff --git a/apps/cli/src/telemetry/command-telemetry.unit.test.ts b/apps/cli/src/telemetry/command-telemetry.unit.test.ts index c10f958782..3c04ae5cea 100644 --- a/apps/cli/src/telemetry/command-telemetry.unit.test.ts +++ b/apps/cli/src/telemetry/command-telemetry.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunCrypto } from "@effect/platform-bun"; -import { Cause, Effect, Exit, Layer, Option, Stdio } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Schema, Stdio } from "effect"; import { Flag } from "effect/cli"; import { commandRuntimeLayer } from "../shared/runtime/command-runtime.layer.ts"; import { @@ -51,6 +51,8 @@ const FAILURE_PROPERTY_NAMES = [ PropWorkflow, ] as const; +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + function mockIdentityStitch(opts: { stitchedDistinctId?: string }) { return { layer: Layer.succeed( @@ -92,13 +94,15 @@ describe("withCommandTelemetry", () => { Effect.currentSpan.pipe( Effect.map((span) => Object.fromEntries(span.attributes)), withCommandTelemetry(), - Effect.provide(mockContextualAnalytics().layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.provide( - mockTelemetryRuntime({ consent, deviceId: "device-1", sessionId: "session-1" }), + Layer.mergeAll( + mockContextualAnalytics().layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + mockTelemetryRuntime({ consent, deviceId: "device-1", sessionId: "session-1" }), + ), ), ); @@ -136,15 +140,17 @@ describe("withCommandTelemetry", () => { expect(typeof span.attributes.get("command_run_id")).toBe("string"); }).pipe( withCommandTelemetry(), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["backups", "list"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["backups", "list"]), + }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -170,12 +176,16 @@ describe("withCommandTelemetry", () => { return recordCommandTelemetry({ [PropOrioleDb]: true }).pipe( Effect.andThen(Effect.fail(new DbDumpRunError({ message: "dump failed" }))), withCommandTelemetry(), - Effect.provide(stackBackendLayer("legacy")), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "dump", "--local"]) })), - Effect.provide(commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer))), + Effect.provide( + Layer.mergeAll( + stackBackendLayer("legacy"), + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["db", "dump", "--local"]) }), + commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -194,15 +204,17 @@ describe("withCommandTelemetry", () => { return Effect.void.pipe( withCommandTelemetry(), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["backups", "list", "--output", "yaml"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["backups", "list", "--output", "yaml"]), + }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties.output_format).toBe("yaml"); @@ -216,22 +228,24 @@ describe("withCommandTelemetry", () => { return Effect.void.pipe( withCommandTelemetry(), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "json" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed([ - "backups", - "list", - "--output", - "pretty", - "--output-format", - "json", - ]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "json" }).layer, + Stdio.layerTest({ + args: Effect.succeed([ + "backups", + "list", + "--output", + "pretty", + "--output-format", + "json", + ]), + }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties.output_format).toBe("json"); @@ -247,15 +261,17 @@ describe("withCommandTelemetry", () => { withCommandTelemetry({ flags: { projectRef: Option.some("abcdefghijklmnopqrst") }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["secrets", "list", "--project-ref", "abcdefghijklmnopqrst"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["secrets", "list", "--project-ref", "abcdefghijklmnopqrst"]), + }), + commandRuntimeLayer(["secrets", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["secrets", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -275,15 +291,17 @@ describe("withCommandTelemetry", () => { withCommandTelemetry({ flags: { envFile: Option.some("/path/to/.env") }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["secrets", "set", "--env-file=/path/to/.env"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["secrets", "set", "--env-file=/path/to/.env"]), + }), + commandRuntimeLayer(["secrets", "set"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["secrets", "set"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -300,13 +318,15 @@ describe("withCommandTelemetry", () => { withCommandTelemetry({ flags: { password: Option.some("super-secret") }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ args: Effect.succeed(["db", "dump", "--password", "super-secret"]) }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["db", "dump", "--password", "super-secret"]) }), + commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -324,15 +344,17 @@ describe("withCommandTelemetry", () => { flags: { schema: Option.some(["public"]) }, aliases: { s: "schema" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["db", "lint", "-s", "public"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["db", "lint", "-s", "public"]), + }), + commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -356,15 +378,17 @@ describe("withCommandTelemetry", () => { config, aliases: { f: "follow" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["stack", "logs", "-f", "--service", "database"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["stack", "logs", "-f", "--service", "database"]), + }), + commandRuntimeLayer(["stack", "logs"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["stack", "logs"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -383,15 +407,17 @@ describe("withCommandTelemetry", () => { flags: { exclude: ["public.users"], file: Option.some("out.sql") }, aliases: { s: "schema", x: "exclude", f: "file", p: "password" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["db", "dump", "-x", "public.users", "-f", "out.sql"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["db", "dump", "-x", "public.users", "-f", "out.sql"]), + }), + commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -409,15 +435,17 @@ describe("withCommandTelemetry", () => { flags: { file: Option.some("query.sql") }, aliases: { f: "file" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["db", "query", "-f", "query.sql"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["db", "query", "-f", "query.sql"]), + }), + commandRuntimeLayer(["db", "query"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["db", "query"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -435,26 +463,26 @@ describe("withCommandTelemetry", () => { flags: { schema: ["public"], password: Option.some("secret") }, aliases: { s: "schema", p: "password" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide( - Stdio.layerTest({ - args: Effect.succeed([ - "db", - "schema", - "declarative", - "generate", - "-s", - "public", - "-p", - "secret", - ]), - }), - ), Effect.provide( - commandRuntimeLayer(["db", "schema", "declarative", "generate"]).pipe( - Layer.provide(BunCrypto.layer), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed([ + "db", + "schema", + "declarative", + "generate", + "-s", + "public", + "-p", + "secret", + ]), + }), + commandRuntimeLayer(["db", "schema", "declarative", "generate"]).pipe( + Layer.provide(BunCrypto.layer), + ), ), ), Effect.tap(() => @@ -474,26 +502,26 @@ describe("withCommandTelemetry", () => { flags: { schema: ["public"], file: Option.some("out.sql") }, aliases: { s: "schema", f: "file" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed([ - "db", - "schema", - "declarative", - "sync", - "-s", - "public", - "-f", - "out.sql", - ]), - }), - ), - Effect.provide( - commandRuntimeLayer(["db", "schema", "declarative", "sync"]).pipe( - Layer.provide(BunCrypto.layer), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed([ + "db", + "schema", + "declarative", + "sync", + "-s", + "public", + "-f", + "out.sql", + ]), + }), + commandRuntimeLayer(["db", "schema", "declarative", "sync"]).pipe( + Layer.provide(BunCrypto.layer), + ), ), ), Effect.tap(() => @@ -515,21 +543,21 @@ describe("withCommandTelemetry", () => { disableDbSslEnforcement: false, }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed([ - "ssl-enforcement", - "update", - "--enable-db-ssl-enforcement", - "--disable-db-ssl-enforcement", - ]), - }), - ), - Effect.provide( - commandRuntimeLayer(["ssl-enforcement", "update"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed([ + "ssl-enforcement", + "update", + "--enable-db-ssl-enforcement", + "--disable-db-ssl-enforcement", + ]), + }), + commandRuntimeLayer(["ssl-enforcement", "update"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), Effect.tap(() => Effect.sync(() => { @@ -551,15 +579,17 @@ describe("withCommandTelemetry", () => { flags: { projectRef: Option.some("abcdefghijklmnopqrst") }, safeFlags: ["project-ref"], }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["link", "--project-ref", "abcdefghijklmnopqrst"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["link", "--project-ref", "abcdefghijklmnopqrst"]), + }), + commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -582,13 +612,15 @@ describe("withCommandTelemetry", () => { flags: { lang: "python" }, config, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ args: Effect.succeed(["gen", "types", "--lang", "python"]) }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["gen", "types", "--lang", "python"]) }), + commandRuntimeLayer(["gen", "types"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["gen", "types"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -614,14 +646,16 @@ describe("withCommandTelemetry", () => { flags: { algorithm: "RS256" }, config, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ args: Effect.succeed(["gen", "signing-key", "--algorithm", "RS256"]) }), - ), - Effect.provide( - commandRuntimeLayer(["gen", "signing-key"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["gen", "signing-key", "--algorithm", "RS256"]), + }), + commandRuntimeLayer(["gen", "signing-key"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), Effect.tap(() => Effect.sync(() => { @@ -647,11 +681,15 @@ describe("withCommandTelemetry", () => { config, aliases: { t: "type" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["sso", "add", "-t", "saml"]) })), - Effect.provide(commandRuntimeLayer(["sso", "add"]).pipe(Layer.provide(BunCrypto.layer))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["sso", "add", "-t", "saml"]) }), + commandRuntimeLayer(["sso", "add"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -673,14 +711,14 @@ describe("withCommandTelemetry", () => { flags: { algorithm: Option.some("ES256") }, config, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide( - Stdio.layerTest({ args: Effect.succeed(["gen", "signing-key", "--algorithm", "ES256"]) }), - ), Effect.provide( - commandRuntimeLayer(["gen", "signing-key"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["gen", "signing-key", "--algorithm", "ES256"]) }), + commandRuntimeLayer(["gen", "signing-key"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), Effect.tap(() => Effect.sync(() => { @@ -703,15 +741,17 @@ describe("withCommandTelemetry", () => { flags: { lang: "go", schema: "public" }, config, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["gen", "types", "--lang", "go", "--schema", "public"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["gen", "types", "--lang", "go", "--schema", "public"]), + }), + commandRuntimeLayer(["gen", "types"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["gen", "types"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -726,11 +766,15 @@ describe("withCommandTelemetry", () => { return Effect.void.pipe( withCommandTelemetry({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -744,11 +788,15 @@ describe("withCommandTelemetry", () => { const analytics = mockContextualAnalytics(); return withCommandTelemetry()(Effect.die(new StdoutClosedError())).pipe( - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), Effect.exit, Effect.tap(() => Effect.sync(() => { @@ -766,14 +814,18 @@ describe("withCommandTelemetry", () => { const secret = "dump failed for postgres://customer.internal/private"; return withCommandTelemetry()(Effect.fail(new DbDumpRunError({ message: secret }))).pipe( - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), Effect.exit, Effect.tap(() => - Effect.sync(() => { + Effect.gen(function* () { expect(analytics.captured).toHaveLength(1); expect(analytics.captured[0]?.properties).toMatchObject({ exit_code: 1, @@ -785,7 +837,7 @@ describe("withCommandTelemetry", () => { }); expect(analytics.captured[0]?.properties).not.toHaveProperty(PropSuggestedCommand); expect(analytics.captured[0]?.properties).not.toHaveProperty(PropWorkflow); - expect(JSON.stringify(analytics.captured[0])).not.toContain(secret); + expect(yield* encodeJson(analytics.captured[0])).not.toContain(secret); }), ), Effect.asVoid, @@ -797,11 +849,15 @@ describe("withCommandTelemetry", () => { return Effect.fail(failure).pipe( withCommandTelemetry(), - Effect.provide(failingAnalytics(new Error("telemetry defect"))), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) })), - Effect.provide(commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer))), + Effect.provide( + Layer.mergeAll( + failingAnalytics(new Error("telemetry defect")), + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) }), + commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -821,11 +877,15 @@ describe("withCommandTelemetry", () => { // landing during the trailing capture must not be. return Effect.void.pipe( withCommandTelemetry(), - Effect.provide(interruptingAnalytics()), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) })), - Effect.provide(commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer))), + Effect.provide( + Layer.mergeAll( + interruptingAnalytics(), + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) }), + commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -848,11 +908,15 @@ describe("withCommandTelemetry", () => { yield* pc.setExitCode(1); }).pipe( withCommandTelemetry(), - Effect.provide(analytics.layer), - Effect.provide(processControl.layer), - Effect.provide(mockOutput({ format: "json" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "lint"]) })), - Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + processControl.layer, + mockOutput({ format: "json" }).layer, + Stdio.layerTest({ args: Effect.succeed(["db", "lint"]) }), + commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -878,11 +942,15 @@ describe("withCommandTelemetry", () => { yield* pc.setExitCode(1); }).pipe( withCommandTelemetry(), - Effect.provide(analytics.layer), - Effect.provide(processControl.layer), - Effect.provide(mockOutput({ format: "json" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "advisors"]) })), - Effect.provide(commandRuntimeLayer(["db", "advisors"]).pipe(Layer.provide(BunCrypto.layer))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + processControl.layer, + mockOutput({ format: "json" }).layer, + Stdio.layerTest({ args: Effect.succeed(["db", "advisors"]) }), + commandRuntimeLayer(["db", "advisors"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties).toMatchObject({ @@ -913,11 +981,15 @@ describe("withCommandTelemetry", () => { yield* pc.setExitCode(1); }), ), - Effect.provide(analytics.layer), - Effect.provide(processControl.layer), - Effect.provide(mockOutput({ format: "json" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) })), - Effect.provide(commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + processControl.layer, + mockOutput({ format: "json" }).layer, + Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) }), + commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties).toMatchObject({ @@ -941,12 +1013,14 @@ describe("withCommandTelemetry", () => { yield* pc.setExitCode(2); }).pipe( withCommandTelemetry(), - Effect.provide(analytics.layer), - Effect.provide(processControl.layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["unknown", "command"]) })), Effect.provide( - commandRuntimeLayer(["unknown", "command"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + analytics.layer, + processControl.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["unknown", "command"]) }), + commandRuntimeLayer(["unknown", "command"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), Effect.tap(() => Effect.sync(() => { @@ -974,14 +1048,14 @@ describe("withCommandTelemetry", () => { yield* pc.setExitCode(2); }).pipe( withCommandTelemetry(), - Effect.provide(analytics.layer), - Effect.provide(processControl.layer), - Effect.provide(mockOutput({ format: "json" }).layer), - Effect.provide( - Stdio.layerTest({ args: Effect.succeed(["config", "diff", "--exit-code"]) }), - ), Effect.provide( - commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + analytics.layer, + processControl.layer, + mockOutput({ format: "json" }).layer, + Stdio.layerTest({ args: Effect.succeed(["config", "diff", "--exit-code"]) }), + commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), Effect.tap(() => Effect.sync(() => { @@ -1011,12 +1085,14 @@ describe("withCommandTelemetry", () => { yield* pc.setExitCode(2); }).pipe( withCommandTelemetry(), - Effect.provide(analytics.layer), - Effect.provide(processControl.layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), Effect.provide( - commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + analytics.layer, + processControl.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), Effect.tap(() => Effect.sync(() => { @@ -1041,11 +1117,15 @@ describe("withCommandTelemetry", () => { return Effect.fail(failure).pipe( withCommandTelemetry(), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["config", "diff"]) })), - Effect.provide(commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunCrypto.layer))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["config", "diff"]) }), + commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), Effect.exit, Effect.tap(() => Effect.sync(() => { @@ -1066,14 +1146,16 @@ describe("withCommandTelemetry", () => { it.live("skips analytics capture when analytics are disabled", () => { const analytics = mockContextualAnalytics(); - return Effect.sync(() => "ok").pipe( + return Effect.succeed("ok").pipe( withCommandTelemetry({ analytics: false }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["telemetry", "enable"]) })), Effect.provide( - commandRuntimeLayer(["telemetry", "enable"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["telemetry", "enable"]) }), + commandRuntimeLayer(["telemetry", "enable"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), Effect.tap(() => Effect.sync(() => { @@ -1093,22 +1175,22 @@ describe("withCommandTelemetry", () => { timestamp: Option.some(1707407047), }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide( - Stdio.layerTest({ - args: Effect.succeed([ - "backups", - "restore", - "--timestamp=1707407047", - "--project-ref", - "abcdefghijklmnopqrst", - ]), - }), - ), Effect.provide( - commandRuntimeLayer(["backups", "restore"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed([ + "backups", + "restore", + "--timestamp=1707407047", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), + commandRuntimeLayer(["backups", "restore"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), Effect.tap(() => Effect.sync(() => { @@ -1123,20 +1205,24 @@ describe("withCommandTelemetry", () => { it.live("rejects an -o value outside the command's enum, before running it", () => { const analytics = mockContextualAnalytics(); - return Effect.sync(() => "must not run").pipe( + return Effect.succeed("must not run").pipe( withCommandTelemetry({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "-o", "table"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), - // `table` is valid on the shared global union but not for a resource command. - Effect.provide(Layer.succeed(OutputFlag, Option.some("table" as const))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + mockProcessControl().layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list", "-o", "table"]) }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + // `table` is valid on the shared global union but not for a resource command. + Layer.succeed(OutputFlag, Option.some("table" as const)), + ), + ), Effect.flip, Effect.tap((error) => Effect.sync(() => { expect(error).toBeInstanceOf(InvalidOutputFormatError); - expect((error as InvalidOutputFormatError).message).toBe( + expect(error.message).toBe( 'invalid argument "table" for "-o, --output" flag: must be one of [ env | pretty | json | toml | yaml ]', ); expect(analytics.captured).toEqual([]); @@ -1148,14 +1234,18 @@ describe("withCommandTelemetry", () => { it.live("accepts a command-specific -o value declared via outputFormats", () => { const analytics = mockContextualAnalytics(); - return Effect.sync(() => "ok").pipe( + return Effect.succeed("ok").pipe( withCommandTelemetry({ flags: {}, outputFormats: QUERY_OUTPUT_FORMATS }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "query", "-o", "csv"]) })), - Effect.provide(commandRuntimeLayer(["db", "query"]).pipe(Layer.provide(BunCrypto.layer))), - Effect.provide(Layer.succeed(OutputFlag, Option.some("csv" as const))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + mockProcessControl().layer, + Stdio.layerTest({ args: Effect.succeed(["db", "query", "-o", "csv"]) }), + commandRuntimeLayer(["db", "query"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.succeed(OutputFlag, Option.some("csv" as const)), + ), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -1171,12 +1261,16 @@ describe("withCommandTelemetry", () => { return Effect.void.pipe( withCommandTelemetry(), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["link"]) })), - Effect.provide(commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer))), - Effect.provide(stitch.layer), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["link"]) }), + commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer)), + stitch.layer, + ), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -1189,18 +1283,22 @@ describe("withCommandTelemetry", () => { it.live("rejects a resource-only -o value for db query's narrower enum", () => { const analytics = mockContextualAnalytics(); - return Effect.sync(() => "must not run").pipe( + return Effect.succeed("must not run").pipe( withCommandTelemetry({ flags: {}, outputFormats: QUERY_OUTPUT_FORMATS }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "query", "-o", "yaml"]) })), - Effect.provide(commandRuntimeLayer(["db", "query"]).pipe(Layer.provide(BunCrypto.layer))), - Effect.provide(Layer.succeed(OutputFlag, Option.some("yaml" as const))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + mockProcessControl().layer, + Stdio.layerTest({ args: Effect.succeed(["db", "query", "-o", "yaml"]) }), + commandRuntimeLayer(["db", "query"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.succeed(OutputFlag, Option.some("yaml" as const)), + ), + ), Effect.flip, Effect.tap((error) => Effect.sync(() => { - expect((error as InvalidOutputFormatError).message).toBe( + expect(error.message).toBe( 'invalid argument "yaml" for "-o, --output" flag: must be one of [ json | table | csv ]', ); }), @@ -1214,12 +1312,16 @@ describe("withCommandTelemetry", () => { return Effect.void.pipe( withCommandTelemetry(), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["link"]) })), - Effect.provide(commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer))), - Effect.provide(stitch.layer), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["link"]) }), + commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer)), + stitch.layer, + ), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -1236,12 +1338,14 @@ describe("withCommandTelemetry", () => { return Effect.void.pipe( withCommandTelemetry(), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), Effect.provide( - commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), Effect.tap(() => Effect.sync(() => { @@ -1261,15 +1365,17 @@ describe("withCommandTelemetry", () => { flags: { schema: Option.some(["--linked"]) }, aliases: { s: "schema" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["db", "lint", "--schema", "--linked"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["db", "lint", "--schema", "--linked"]), + }), + commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record; @@ -1288,15 +1394,17 @@ describe("withCommandTelemetry", () => { flags: { schema: Option.some(["public"]), linked: true }, aliases: { s: "schema" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["db", "lint", "--schema=public", "--linked"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["db", "lint", "--schema=public", "--linked"]), + }), + commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record; @@ -1314,15 +1422,17 @@ describe("withCommandTelemetry", () => { flags: { schema: Option.some(["public"]), linked: true }, aliases: { s: "schema" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["db", "lint", "-s", "public", "--linked"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["db", "lint", "-s", "public", "--linked"]), + }), + commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record; @@ -1340,15 +1450,17 @@ describe("withCommandTelemetry", () => { withCommandTelemetry({ flags: { dbUrl: Option.some("x"), local: true }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["db", "lint", "--db-url", "x", "--local"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["db", "lint", "--db-url", "x", "--local"]), + }), + commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record; @@ -1364,12 +1476,16 @@ describe("withCommandTelemetry", () => { return Effect.void.pipe( withCommandTelemetry({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "--debug"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), - Effect.provide(Layer.succeed(DebugFlag, true)), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list", "--debug"]) }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.succeed(DebugFlag, true), + ), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1386,22 +1502,24 @@ describe("withCommandTelemetry", () => { withCommandTelemetry({ flags: { projectRef: Option.some("abcdefghijklmnopqrst") }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed([ - "secrets", - "list", - "--project-ref", - "abcdefghijklmnopqrst", - "--debug", - ]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed([ + "secrets", + "list", + "--project-ref", + "abcdefghijklmnopqrst", + "--debug", + ]), + }), + commandRuntimeLayer(["secrets", "list"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.succeed(DebugFlag, true), + ), ), - Effect.provide(commandRuntimeLayer(["secrets", "list"]).pipe(Layer.provide(BunCrypto.layer))), - Effect.provide(Layer.succeed(DebugFlag, true)), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1421,18 +1539,18 @@ describe("withCommandTelemetry", () => { return Effect.void.pipe( withCommandTelemetry({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["backups", "list", "--workdir", "/tmp/project"]), - }), - ), Effect.provide( - commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["backups", "list", "--workdir", "/tmp/project"]), + }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.succeed(WorkdirFlag, Option.some("/tmp/project")), + ), ), - Effect.provide(Layer.succeed(WorkdirFlag, Option.some("/tmp/project"))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1450,18 +1568,18 @@ describe("withCommandTelemetry", () => { return Effect.void.pipe( withCommandTelemetry({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["backups", "list", "--dns-resolver", "https"]), - }), - ), Effect.provide( - commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["backups", "list", "--dns-resolver", "https"]), + }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.succeed(DnsResolverFlag, "https" as const), + ), ), - Effect.provide(Layer.succeed(DnsResolverFlag, "https" as const)), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1479,18 +1597,18 @@ describe("withCommandTelemetry", () => { return Effect.void.pipe( withCommandTelemetry({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["backups", "list", "--agent", "yes"]), - }), - ), - Effect.provide( - commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["backups", "list", "--agent", "yes"]), + }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.succeed(AgentFlag, "yes" as const), + ), ), - Effect.provide(Layer.succeed(AgentFlag, "yes" as const)), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1512,15 +1630,17 @@ describe("withCommandTelemetry", () => { return Effect.void.pipe( withCommandTelemetry({ flags: { output: "diff.sql" } }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["db", "diff", "--output", "diff.sql"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["db", "diff", "--output", "diff.sql"]), + }), + commandRuntimeLayer(["db", "diff"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["db", "diff"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1536,11 +1656,15 @@ describe("withCommandTelemetry", () => { return Effect.void.pipe( withCommandTelemetry({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "--debug"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list", "--debug"]) }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1555,15 +1679,17 @@ describe("withCommandTelemetry", () => { return Effect.void.pipe( withCommandTelemetry({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["test", "db", "--", "--linked"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["test", "db", "--", "--linked"]), + }), + commandRuntimeLayer(["test", "db"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["test", "db"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags; @@ -1578,12 +1704,16 @@ describe("withCommandTelemetry", () => { return Effect.void.pipe( withCommandTelemetry({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "-o", "json"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), - Effect.provide(Layer.succeed(OutputFlag, Option.some("json" as const))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list", "-o", "json"]) }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.succeed(OutputFlag, Option.some("json" as const)), + ), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1606,16 +1736,16 @@ describe("withCommandTelemetry", () => { withCommandTelemetry({ flags: { envFile: Option.some("--debug") }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["secrets", "set", "--env-file", "--debug"]), - }), - ), Effect.provide( - commandRuntimeLayer(["secrets", "set"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["secrets", "set", "--env-file", "--debug"]), + }), + commandRuntimeLayer(["secrets", "set"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), Effect.tap(() => Effect.sync(() => { From fa60bf333369d6a9bba9ae48165d38e1eac12d4e Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:11:25 +0000 Subject: [PATCH 3/7] refactor(cli): cover the `auth` and `config` areas with effect lint (CLI-2740) (#7212) ## TL;DR brings the `auth` and `config` areas under the effect lint, tests included ## whats introduced? - `auth/**` and `config/**` join the allow list - `command-platform-api` and `project-ref` yield their errors directly instead of through `Effect.fail`, with the same errors and messages - the tests swap `node:fs`, `node:path`, `JSON` and `Date` for `useTempWorkdir`, the `FileSystem` and `Path` services, schema codecs and `DateTime`, and the hand rolled fs and path fakes in the `command-platform-api` test go ## ref - closes: CLI-2740 --- .oxlintrc.effect.json | 2 + .../command-credentials.layer.unit.test.ts | 170 +++++---- .../src/auth/command-platform-api.layer.ts | 4 +- .../command-platform-api.layer.unit.test.ts | 225 +++++------- .../command-settings.layer.unit.test.ts | 332 ++++++++++-------- .../project-ref.layer.integration.test.ts | 15 +- apps/cli/src/config/project-ref.layer.ts | 16 +- .../src/config/project-ref.layer.unit.test.ts | 118 +++---- 8 files changed, 452 insertions(+), 430 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 0359d128e2..2c1e9997d6 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -7,7 +7,9 @@ "ignorePatterns": [ "**", "!packages/stack/**", + "!apps/cli/src/auth/**", "!apps/cli/src/commands/**", + "!apps/cli/src/config/**", "!apps/cli/src/shared/**", "!apps/cli/src/telemetry/**", // Last match wins across the whole list: keep bare re-exclusions after every diff --git a/apps/cli/src/auth/command-credentials.layer.unit.test.ts b/apps/cli/src/auth/command-credentials.layer.unit.test.ts index e05c9c7f57..cc6ddb296a 100644 --- a/apps/cli/src/auth/command-credentials.layer.unit.test.ts +++ b/apps/cli/src/auth/command-credentials.layer.unit.test.ts @@ -1,21 +1,20 @@ -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - statSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { Effect, Exit, FileSystem, Layer, Option, PlatformError, Redacted } from "effect"; -import { afterEach, beforeEach, vi } from "vitest"; +import { BunPath, BunServices } from "@effect/platform-bun"; +import { + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + PlatformError, + Redacted, + Schema, +} from "effect"; +import { beforeEach, vi } from "vitest"; import { DebugFlag, ProfileFlag, WorkdirFlag } from "../command-internal/global-flags.ts"; +import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; import { mockRuntimeInfo, processEnvLayer } from "../../tests/helpers/mocks.ts"; import { commandSettingsLayer } from "../config/command-settings.layer.ts"; import { debugLoggerLayer } from "../command-internal/debug-logger.layer.ts"; @@ -23,6 +22,9 @@ import { commandCredentialsLayer } from "./command-credentials.layer.ts"; import { CommandCredentials } from "./command-credentials.service.ts"; import { DeleteTokenError, InvalidAccessTokenError, NotLoggedInError } from "./errors.ts"; +const path = Effect.runSync(Effect.provide(Path.Path, BunPath.layer)); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + const passwords = new Map(); let throwOnSetPassword = false; let throwOnSetSecret = false; @@ -97,7 +99,7 @@ vi.mock("@napi-rs/keyring", () => ({ }, })); -let tempHome: string; +const tempHome = useTempWorkdir("supabase-creds-"); function makeLayer( opts: { @@ -108,7 +110,7 @@ function makeLayer( fs?: Layer.Layer; } = {}, ) { - const home = opts.home ?? tempHome; + const home = opts.home ?? tempHome.current; const env = { HOME: home, ...opts.env }; const envLayer = processEnvLayer(env); const runtimeInfoLayer = mockRuntimeInfo({ @@ -131,7 +133,7 @@ function makeLayer( Layer.provide(Layer.succeed(DebugFlag, opts.debug ?? false)), Layer.provide(runtimeInfoLayer), Layer.provide(opts.fs ?? BunServices.layer), - Layer.provide(BunServices.layer), + Layer.provideMerge(BunServices.layer), Layer.provide(envLayer), ); } @@ -146,11 +148,6 @@ beforeEach(() => { opaqueAccounts.clear(); failDeleteAccounts.clear(); throwOnFindCredentials = false; - tempHome = mkdtempSync(join(tmpdir(), "supabase-creds-")); -}); - -afterEach(() => { - rmSync(tempHome, { recursive: true, force: true }); }); const VALID_TOKEN = "sbp_" + "a".repeat(40); @@ -181,7 +178,7 @@ function captureStderr() { describe("commandCredentialsLayer.getAccessToken", () => { it.effect("surfaces a filesystem read failure instead of treating it as no token", () => { - const fallbackPath = join(tempHome, ".supabase", "access-token"); + const fallbackPath = path.join(tempHome.current, ".supabase", "access-token"); const fsLayer = Layer.succeed( FileSystem.FileSystem, FileSystem.makeNoop({ @@ -291,10 +288,13 @@ describe("commandCredentialsLayer.getAccessToken", () => { }); it.effect("falls back to ~/.supabase/access-token when keyring entries miss", () => { - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), `${VALID_TOKEN}\n`, { mode: 0o600 }); + const supaDir = path.join(tempHome.current, ".supabase"); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(supaDir, { recursive: true }); + yield* fs.writeFileString(path.join(supaDir, "access-token"), `${VALID_TOKEN}\n`, { + mode: 0o600, + }); const { getAccessToken } = yield* CommandCredentials; const token = yield* getAccessToken; expectSomeToken(token, VALID_TOKEN); @@ -302,10 +302,13 @@ describe("commandCredentialsLayer.getAccessToken", () => { }); it.effect("falls back to SUPABASE_HOME/access-token when configured", () => { - const supabaseHome = join(tempHome, "custom-supabase-home"); - mkdirSync(supabaseHome, { recursive: true }); - writeFileSync(join(supabaseHome, "access-token"), `${VALID_TOKEN}\n`, { mode: 0o600 }); + const supabaseHome = path.join(tempHome.current, "custom-supabase-home"); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(supabaseHome, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseHome, "access-token"), `${VALID_TOKEN}\n`, { + mode: 0o600, + }); const { getAccessToken } = yield* CommandCredentials; const token = yield* getAccessToken; expectSomeToken(token, VALID_TOKEN); @@ -327,7 +330,7 @@ describe("commandCredentialsLayer.getAccessToken", () => { const exit = yield* Effect.exit(getAccessToken); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - const errorJson = JSON.stringify(exit.cause); + const errorJson = yield* encodeJson(exit.cause); expect(errorJson).toContain("InvalidAccessTokenError"); expect(errorJson).toContain("Invalid access token format"); } @@ -367,10 +370,11 @@ describe("commandCredentialsLayer.getAccessToken", () => { it.effect("falls back to the filesystem when keyring throws", () => { throwOnGetPasswordAccounts.add("Supabase CLI/supabase"); throwOnGetPasswordAccounts.add("Supabase CLI/access-token"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), VALID_TOKEN, { mode: 0o600 }); + const supaDir = path.join(tempHome.current, ".supabase"); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(supaDir, { recursive: true }); + yield* fs.writeFileString(path.join(supaDir, "access-token"), VALID_TOKEN, { mode: 0o600 }); const { getAccessToken } = yield* CommandCredentials; const token = yield* getAccessToken; expectSomeToken(token, VALID_TOKEN); @@ -381,7 +385,7 @@ describe("commandCredentialsLayer.getAccessToken", () => { describe("commandCredentialsLayer.saveAccessToken", () => { it.effect("surfaces a fallback directory write failure as PlatformError", () => { throwOnSetPassword = true; - const fallbackDir = join(tempHome, ".supabase"); + const fallbackDir = path.join(tempHome.current, ".supabase"); const fsLayer = Layer.succeed( FileSystem.FileSystem, FileSystem.makeNoop({ @@ -415,7 +419,7 @@ describe("commandCredentialsLayer.saveAccessToken", () => { const exit = yield* Effect.exit(saveAccessToken("nope")); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("InvalidAccessTokenError"); + expect(yield* encodeJson(exit.cause)).toContain("InvalidAccessTokenError"); } }).pipe(Effect.provide(makeLayer())), ); @@ -452,7 +456,10 @@ describe("commandCredentialsLayer.saveAccessToken", () => { yield* saveAccessToken(VALID_TOKEN); expect(passwords.get(goWindowsKey("supabase")) ?? "").toBe(""); expect(passwords.has("Supabase CLI/supabase")).toBe(false); - const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8"); + const fs = yield* FileSystem.FileSystem; + const content = yield* fs.readFileString( + path.join(tempHome.current, ".supabase", "access-token"), + ); expect(content).toBe(VALID_TOKEN); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); @@ -464,12 +471,13 @@ describe("commandCredentialsLayer.saveAccessToken", () => { return Effect.gen(function* () { const { saveAccessToken } = yield* CommandCredentials; yield* saveAccessToken(VALID_TOKEN); - const fallbackDir = join(tempHome, ".supabase"); - const fallbackPath = join(fallbackDir, "access-token"); - const content = readFileSync(fallbackPath, "utf-8"); + const fs = yield* FileSystem.FileSystem; + const fallbackDir = path.join(tempHome.current, ".supabase"); + const fallbackPath = path.join(fallbackDir, "access-token"); + const content = yield* fs.readFileString(fallbackPath); expect(content).toBe(VALID_TOKEN); - expect(statSync(fallbackDir).mode & 0o777).toBe(0o755); - expect(statSync(fallbackPath).mode & 0o777).toBe(0o600); + expect((yield* fs.stat(fallbackDir)).mode & 0o777).toBe(0o755); + expect((yield* fs.stat(fallbackPath)).mode & 0o777).toBe(0o600); }).pipe( Effect.provide(makeLayer()), Effect.ensuring(Effect.sync(() => process.umask(prevUmask))), @@ -478,64 +486,74 @@ describe("commandCredentialsLayer.saveAccessToken", () => { it.effect("filesystem fallback honors SUPABASE_HOME when configured", () => { throwOnSetPassword = true; - const supabaseHome = join(tempHome, "custom-supabase-home"); + const supabaseHome = path.join(tempHome.current, "custom-supabase-home"); return Effect.gen(function* () { const { saveAccessToken } = yield* CommandCredentials; yield* saveAccessToken(VALID_TOKEN); - const content = readFileSync(join(supabaseHome, "access-token"), "utf-8"); + const fs = yield* FileSystem.FileSystem; + const content = yield* fs.readFileString(path.join(supabaseHome, "access-token")); expect(content).toBe(VALID_TOKEN); - expect(existsSync(join(tempHome, ".supabase", "access-token"))).toBe(false); + expect(yield* fs.exists(path.join(tempHome.current, ".supabase", "access-token"))).toBe( + false, + ); }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_HOME: supabaseHome } }))); }); }); describe("commandCredentialsLayer.deleteAccessToken", () => { - const seedTokenFile = (home: string, token = VALID_TOKEN) => { - const supaDir = join(home, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), token, { mode: 0o600 }); - }; - const tokenFileExists = (home: string) => existsSync(join(home, ".supabase", "access-token")); + const seedTokenFile = Effect.fnUntraced(function* (home: string, token = VALID_TOKEN) { + const fs = yield* FileSystem.FileSystem; + const supaDir = path.join(home, ".supabase"); + yield* fs.makeDirectory(supaDir, { recursive: true }); + yield* fs.writeFileString(path.join(supaDir, "access-token"), token, { mode: 0o600 }); + }); + const tokenFileExists = Effect.fnUntraced(function* (home: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path.join(home, ".supabase", "access-token")); + }); it.effect("logged in via keyring profile entry → deletes file + entry, succeeds", () => { passwords.set("Supabase CLI/supabase", VALID_TOKEN); passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); - seedTokenFile(tempHome); return Effect.gen(function* () { + yield* seedTokenFile(tempHome.current); const { deleteAccessToken } = yield* CommandCredentials; yield* deleteAccessToken; expect(passwords.has("Supabase CLI/supabase")).toBe(false); expect(passwords.has("Supabase CLI/access-token")).toBe(false); - expect(tokenFileExists(tempHome)).toBe(false); + expect(yield* tokenFileExists(tempHome.current)).toBe(false); }).pipe(Effect.provide(makeLayer())); }); it.effect("logged in via keyring profile entry → deletes the SUPABASE_HOME file", () => { - const supabaseHome = join(tempHome, "custom-supabase-home"); - mkdirSync(supabaseHome, { recursive: true }); - writeFileSync(join(supabaseHome, "access-token"), VALID_TOKEN, { mode: 0o600 }); + const supabaseHome = path.join(tempHome.current, "custom-supabase-home"); passwords.set("Supabase CLI/supabase", VALID_TOKEN); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(supabaseHome, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseHome, "access-token"), VALID_TOKEN, { + mode: 0o600, + }); const { deleteAccessToken } = yield* CommandCredentials; yield* deleteAccessToken; expect(passwords.has("Supabase CLI/supabase")).toBe(false); - expect(existsSync(join(supabaseHome, "access-token"))).toBe(false); + expect(yield* fs.exists(path.join(supabaseHome, "access-token"))).toBe(false); }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_HOME: supabaseHome } }))); }); it.effect( "keyring profile entry absent → NotLoggedInError even though the file was removed", () => { - seedTokenFile(tempHome); return Effect.gen(function* () { + yield* seedTokenFile(tempHome.current); const { deleteAccessToken } = yield* CommandCredentials; const exit = yield* Effect.exit(deleteAccessToken); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("NotLoggedInError"); - expect(JSON.stringify(exit.cause)).toContain("You were not logged in, nothing to do."); + expect(yield* encodeJson(exit.cause)).toContain("NotLoggedInError"); + expect(yield* encodeJson(exit.cause)).toContain("You were not logged in, nothing to do."); } - expect(tokenFileExists(tempHome)).toBe(false); + expect(yield* tokenFileExists(tempHome.current)).toBe(false); }).pipe(Effect.provide(makeLayer())); }, ); @@ -546,7 +564,7 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { const exit = yield* Effect.exit(deleteAccessToken); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("NotLoggedInError"); + expect(yield* encodeJson(exit.cause)).toContain("NotLoggedInError"); } expect(passwords.has(goWindowsKey("supabase"))).toBe(false); expect(withTargetCalls).toEqual([]); @@ -560,7 +578,7 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { const exit = yield* Effect.exit(deleteAccessToken); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("NotLoggedInError"); + expect(yield* encodeJson(exit.cause)).toContain("NotLoggedInError"); } }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); @@ -568,23 +586,23 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { it.effect( "keyring unavailable (SUPABASE_NO_KEYRING) with token in file → removes file, still NotLoggedIn", () => { - seedTokenFile(tempHome); return Effect.gen(function* () { + yield* seedTokenFile(tempHome.current); const { deleteAccessToken } = yield* CommandCredentials; const exit = yield* Effect.exit(deleteAccessToken); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("NotLoggedInError"); + expect(yield* encodeJson(exit.cause)).toContain("NotLoggedInError"); } - expect(tokenFileExists(tempHome)).toBe(false); + expect(yield* tokenFileExists(tempHome.current)).toBe(false); }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_NO_KEYRING: "1" } }))); }, ); it.effect("file remove error (non-ENOENT) → DeleteTokenError before touching keyring", () => { - const home = tempHome; + const home = tempHome.current; const env = { HOME: home }; - const tokenPath = join(home, ".supabase", "access-token"); + const tokenPath = path.join(home, ".supabase", "access-token"); // Seeds a profile keyring entry to prove the keyring is never touched once the file removal fails. passwords.set("Supabase CLI/supabase", VALID_TOKEN); const runtimeInfoLayer = mockRuntimeInfo({ homeDir: home, cwd: home }); @@ -627,8 +645,8 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { const exit = yield* Effect.exit(deleteAccessToken); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("DeleteTokenError"); - expect(JSON.stringify(exit.cause)).toContain("failed to remove access token file"); + expect(yield* encodeJson(exit.cause)).toContain("DeleteTokenError"); + expect(yield* encodeJson(exit.cause)).toContain("failed to remove access token file"); } expect(passwords.has("Supabase CLI/supabase")).toBe(true); }).pipe(Effect.provide(layer)); @@ -642,8 +660,10 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { const exit = yield* Effect.exit(deleteAccessToken); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("DeleteTokenError"); - expect(JSON.stringify(exit.cause)).toContain("failed to delete access token from keyring"); + expect(yield* encodeJson(exit.cause)).toContain("DeleteTokenError"); + expect(yield* encodeJson(exit.cause)).toContain( + "failed to delete access token from keyring", + ); } }).pipe(Effect.provide(makeLayer())); }); @@ -680,7 +700,7 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { const exit = yield* Effect.exit(deleteAccessToken); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("DeleteTokenError"); + expect(yield* encodeJson(exit.cause)).toContain("DeleteTokenError"); } expect(passwords.has(goWindowsKey("supabase"))).toBe(true); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); @@ -705,7 +725,7 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { const exit = yield* Effect.exit(deleteAccessToken); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("DeleteTokenError"); + expect(yield* encodeJson(exit.cause)).toContain("DeleteTokenError"); } }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }, @@ -803,7 +823,7 @@ describe("commandCredentialsLayer.deleteProjectCredential", () => { const exit = yield* Effect.exit(deleteProjectCredential("abcdefghijklmnopqrs1")); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("CredentialDeleteError"); + expect(yield* encodeJson(exit.cause)).toContain("CredentialDeleteError"); } }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }, diff --git a/apps/cli/src/auth/command-platform-api.layer.ts b/apps/cli/src/auth/command-platform-api.layer.ts index 223096c775..89f1e1a039 100644 --- a/apps/cli/src/auth/command-platform-api.layer.ts +++ b/apps/cli/src/auth/command-platform-api.layer.ts @@ -50,13 +50,13 @@ export const makeCommandPlatformApi = Effect.gen(function* () { const authGateToken = yield* resolveAccessToken; if (Option.isNone(authGateToken)) { - return yield* Effect.fail(new AccessTokenRequiredError({ message: MISSING_TOKEN_MESSAGE })); + return yield* new AccessTokenRequiredError({ message: MISSING_TOKEN_MESSAGE }); } yield* debugLogger.debug(`Supabase CLI ${CLI_VERSION}`); yield* debugLogger.debug(`Using profile: ${cliSettings.profile} (${cliSettings.projectHost})`); const storedToken = yield* resolveAccessToken; if (Option.isNone(storedToken)) { - return yield* Effect.fail(new AccessTokenRequiredError({ message: MISSING_TOKEN_MESSAGE })); + return yield* new AccessTokenRequiredError({ message: MISSING_TOKEN_MESSAGE }); } return yield* makeApiClient( diff --git a/apps/cli/src/auth/command-platform-api.layer.unit.test.ts b/apps/cli/src/auth/command-platform-api.layer.unit.test.ts index 71b087fc45..1203e1aba1 100644 --- a/apps/cli/src/auth/command-platform-api.layer.unit.test.ts +++ b/apps/cli/src/auth/command-platform-api.layer.unit.test.ts @@ -1,14 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { DateTime, Effect, Exit, FileSystem, Layer, Option, Path, Redacted, Schema } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import type * as HttpClientRequest from "effect/http/HttpClientRequest"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { vi } from "vitest"; +import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; import { DebugFlag, DnsResolverFlag } from "../command-internal/global-flags.ts"; import { Analytics } from "../shared/telemetry/analytics.service.ts"; import { TelemetryRuntime } from "../shared/telemetry/runtime.service.ts"; @@ -25,6 +23,15 @@ import { CommandPlatformApi } from "./command-platform-api.service.ts"; const VALID_TOKEN = "sbp_" + "a".repeat(40); const SESSION_LAST_ACTIVE = 1_777_200_000_000; +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const TelemetryConfigJson = Schema.fromJsonString( + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + distinct_id: Schema.optionalKey(Schema.String), + schema_version: Schema.optionalKey(Schema.Finite), + }), +); + function mockCliSettings(opts: { accessToken?: string; apiUrl?: string; @@ -114,55 +121,32 @@ function mockAnalytics() { return { layer, aliases, identifies }; } -function nodeFileSystemLayer() { - return Layer.succeed(FileSystem.FileSystem, { - [FileSystem.FileSystem.key]: FileSystem.FileSystem.key, - exists: (filePath: string) => - Effect.tryPromise(() => - access(filePath) - .then(() => true) - .catch(() => false), - ), - makeDirectory: (dirPath: string, opts?: { recursive?: boolean; mode?: number }) => - Effect.tryPromise(() => - mkdir(dirPath, { recursive: opts?.recursive, mode: opts?.mode }).then(() => undefined), - ), - readFileString: (filePath: string) => Effect.tryPromise(() => readFile(filePath, "utf8")), - writeFileString: (filePath: string, content: string, opts?: { mode?: number }) => - Effect.tryPromise(() => writeFile(filePath, content, { mode: opts?.mode })), - } as unknown as FileSystem.FileSystem); -} - -function nodePathLayer() { - return Layer.succeed(Path.Path, { - [Path.Path.key]: Path.Path.key, - ...path, - } as unknown as Path.Path); -} - -function tempTelemetryConfig(opts: { distinctId?: string; enabled?: boolean } = {}) { - const dir = mkdtempSync(path.join(tmpdir(), "supabase-platform-api-")); - writeFileSync( +const writeTelemetryConfig = Effect.fnUntraced(function* ( + dir: string, + opts: { distinctId?: string; enabled?: boolean } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString( path.join(dir, "telemetry.json"), - JSON.stringify({ + yield* encodeJson({ enabled: opts.enabled ?? true, device_id: "device-123", session_id: "session-123", - session_last_active: new Date(SESSION_LAST_ACTIVE).toISOString(), + session_last_active: DateTime.formatIso(DateTime.makeUnsafe(SESSION_LAST_ACTIVE)), ...(opts.distinctId === undefined ? {} : { distinct_id: opts.distinctId }), schema_version: 1, }), ); - return dir; -} +}); -function readTelemetryConfig(configDir: string) { - return JSON.parse(readFileSync(path.join(configDir, "telemetry.json"), "utf8")) as { - enabled?: boolean; - distinct_id?: string; - schema_version?: number; - }; -} +const readTelemetryConfig = Effect.fnUntraced(function* (configDir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* Schema.decodeEffect(TelemetryConfigJson)( + yield* fs.readFileString(path.join(configDir, "telemetry.json")), + ); +}); function withBaseDeps( opts: { @@ -176,8 +160,8 @@ function withBaseDeps( } = {}, ) { const analytics = opts.analytics ?? mockAnalytics(); - // Builds the shared `IdentityStitch` service from the test's Analytics/TelemetryRuntime/ - // FileSystem/Path fakes; alias/persist assertions rely on this real service instance. + /* Builds the shared `IdentityStitch` service from the test's Analytics/TelemetryRuntime fakes + and Bun's FileSystem/Path; alias/persist assertions rely on this real service instance. */ const identityStitch = identityStitchLayer.pipe( Layer.provide(analytics.layer), Layer.provide( @@ -189,8 +173,7 @@ function withBaseDeps( isCi: opts.isCi, }), ), - Layer.provide(nodeFileSystemLayer()), - Layer.provide(nodePathLayer()), + Layer.provide(BunServices.layer), ); return (layer: Layer.Layer) => layer.pipe( @@ -223,6 +206,8 @@ function captureRequests(responseHeaders: Record = {}) { } describe("commandPlatformApiLayer", () => { + const telemetryDir = useTempWorkdir("supabase-platform-api-"); + it.effect("uses env access token over keyring-stored token", () => { const http = captureRequests(); const layer = commandPlatformApiLayer.pipe( @@ -271,7 +256,7 @@ describe("commandPlatformApiLayer", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = yield* encodeJson(exit.cause); expect(errorJson).toContain("AccessTokenRequiredError"); expect(errorJson).toContain("Access token not provided"); } @@ -297,7 +282,7 @@ describe("commandPlatformApiLayer", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = yield* encodeJson(exit.cause); expect(errorJson).toContain("InvalidAccessTokenError"); expect(errorJson).toContain("Invalid access token format"); } @@ -372,7 +357,7 @@ describe("commandPlatformApiLayer", () => { }); it.effect("stitches identity from X-Gotrue-Id responses outside CI", () => { - const configDir = tempTelemetryConfig(); + const configDir = telemetryDir.current; const analytics = mockAnalytics(); const http = captureRequests({ "X-Gotrue-Id": "user-123" }); const layer = commandPlatformApiLayer.pipe( @@ -383,24 +368,21 @@ describe("commandPlatformApiLayer", () => { ); return Effect.gen(function* () { - try { - const api = yield* CommandPlatformApi; - yield* api.v1.listAllProjects(); - - expect(analytics.aliases).toEqual([{ distinctId: "user-123", alias: "device-123" }]); - expect(analytics.identifies).toEqual([]); - const telemetry = readTelemetryConfig(configDir); - expect(telemetry.distinct_id).toBe("user-123"); - expect(telemetry.enabled).toBe(true); - expect(telemetry.schema_version).toBe(1); - } finally { - rmSync(configDir, { recursive: true, force: true }); - } - }).pipe(Effect.provide(layer)); + yield* writeTelemetryConfig(configDir); + const api = yield* CommandPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([{ distinctId: "user-123", alias: "device-123" }]); + expect(analytics.identifies).toEqual([]); + const telemetry = yield* readTelemetryConfig(configDir); + expect(telemetry.distinct_id).toBe("user-123"); + expect(telemetry.enabled).toBe(true); + expect(telemetry.schema_version).toBe(1); + }).pipe(Effect.provide([layer, BunServices.layer])); }); it.effect("does not stitch identity from X-Gotrue-Id responses in CI", () => { - const configDir = tempTelemetryConfig(); + const configDir = telemetryDir.current; const analytics = mockAnalytics(); const http = captureRequests({ "X-Gotrue-Id": "user-123" }); const layer = commandPlatformApiLayer.pipe( @@ -411,21 +393,18 @@ describe("commandPlatformApiLayer", () => { ); return Effect.gen(function* () { - try { - const api = yield* CommandPlatformApi; - yield* api.v1.listAllProjects(); - - expect(analytics.aliases).toEqual([]); - expect(analytics.identifies).toEqual([]); - expect(readTelemetryConfig(configDir).distinct_id).toBeUndefined(); - } finally { - rmSync(configDir, { recursive: true, force: true }); - } - }).pipe(Effect.provide(layer)); + yield* writeTelemetryConfig(configDir); + const api = yield* CommandPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([]); + expect(analytics.identifies).toEqual([]); + expect((yield* readTelemetryConfig(configDir)).distinct_id).toBeUndefined(); + }).pipe(Effect.provide([layer, BunServices.layer])); }); it.effect("does not stitch identity in a first-run non-TTY runtime", () => { - const configDir = mkdtempSync(path.join(tmpdir(), "supabase-platform-api-")); + const configDir = telemetryDir.current; const analytics = mockAnalytics(); const http = captureRequests({ "X-Gotrue-Id": "user-123" }); const layer = commandPlatformApiLayer.pipe( @@ -436,21 +415,19 @@ describe("commandPlatformApiLayer", () => { ); return Effect.gen(function* () { - try { - const api = yield* CommandPlatformApi; - yield* api.v1.listAllProjects(); - - expect(analytics.aliases).toEqual([]); - expect(analytics.identifies).toEqual([]); - expect(existsSync(path.join(configDir, "telemetry.json"))).toBe(false); - } finally { - rmSync(configDir, { recursive: true, force: true }); - } - }).pipe(Effect.provide(layer)); + const api = yield* CommandPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([]); + expect(analytics.identifies).toEqual([]); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + expect(yield* fs.exists(path.join(configDir, "telemetry.json"))).toBe(false); + }).pipe(Effect.provide([layer, BunServices.layer])); }); it.effect("stitches identity in a first-run TTY runtime", () => { - const configDir = mkdtempSync(path.join(tmpdir(), "supabase-platform-api-")); + const configDir = telemetryDir.current; const analytics = mockAnalytics(); const http = captureRequests({ "X-Gotrue-Id": "user-123" }); const layer = commandPlatformApiLayer.pipe( @@ -461,21 +438,17 @@ describe("commandPlatformApiLayer", () => { ); return Effect.gen(function* () { - try { - const api = yield* CommandPlatformApi; - yield* api.v1.listAllProjects(); - - expect(analytics.aliases).toEqual([{ distinctId: "user-123", alias: "device-123" }]); - expect(analytics.identifies).toEqual([]); - expect(readTelemetryConfig(configDir).distinct_id).toBe("user-123"); - } finally { - rmSync(configDir, { recursive: true, force: true }); - } - }).pipe(Effect.provide(layer)); + const api = yield* CommandPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([{ distinctId: "user-123", alias: "device-123" }]); + expect(analytics.identifies).toEqual([]); + expect((yield* readTelemetryConfig(configDir)).distinct_id).toBe("user-123"); + }).pipe(Effect.provide([layer, BunServices.layer])); }); it.effect("does not stitch identity when a distinct_id is already known", () => { - const configDir = tempTelemetryConfig({ distinctId: "existing-user" }); + const configDir = telemetryDir.current; const analytics = mockAnalytics(); const http = captureRequests({ "X-Gotrue-Id": "user-123" }); const layer = commandPlatformApiLayer.pipe( @@ -486,21 +459,18 @@ describe("commandPlatformApiLayer", () => { ); return Effect.gen(function* () { - try { - const api = yield* CommandPlatformApi; - yield* api.v1.listAllProjects(); - - expect(analytics.aliases).toEqual([]); - expect(analytics.identifies).toEqual([]); - expect(readTelemetryConfig(configDir).distinct_id).toBe("existing-user"); - } finally { - rmSync(configDir, { recursive: true, force: true }); - } - }).pipe(Effect.provide(layer)); + yield* writeTelemetryConfig(configDir, { distinctId: "existing-user" }); + const api = yield* CommandPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([]); + expect(analytics.identifies).toEqual([]); + expect((yield* readTelemetryConfig(configDir)).distinct_id).toBe("existing-user"); + }).pipe(Effect.provide([layer, BunServices.layer])); }); it.effect("does not stitch identity when legacy telemetry state is disabled", () => { - const configDir = tempTelemetryConfig({ enabled: false }); + const configDir = telemetryDir.current; const analytics = mockAnalytics(); const http = captureRequests({ "X-Gotrue-Id": "user-123" }); const layer = commandPlatformApiLayer.pipe( @@ -511,19 +481,16 @@ describe("commandPlatformApiLayer", () => { ); return Effect.gen(function* () { - try { - const api = yield* CommandPlatformApi; - yield* api.v1.listAllProjects(); - - expect(analytics.aliases).toEqual([]); - expect(analytics.identifies).toEqual([]); - const telemetry = readTelemetryConfig(configDir); - expect(telemetry.enabled).toBe(false); - expect(telemetry.distinct_id).toBeUndefined(); - } finally { - rmSync(configDir, { recursive: true, force: true }); - } - }).pipe(Effect.provide(layer)); + yield* writeTelemetryConfig(configDir, { enabled: false }); + const api = yield* CommandPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([]); + expect(analytics.identifies).toEqual([]); + const telemetry = yield* readTelemetryConfig(configDir); + expect(telemetry.enabled).toBe(false); + expect(telemetry.distinct_id).toBeUndefined(); + }).pipe(Effect.provide([layer, BunServices.layer])); }); }); @@ -554,7 +521,7 @@ describe("commandPlatformApiFactoryLayer (lazy token)", () => { const exit = yield* Effect.exit(factory.make); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = yield* encodeJson(exit.cause); expect(errorJson).toContain("AccessTokenRequiredError"); expect(errorJson).toContain("Access token not provided"); } diff --git a/apps/cli/src/config/command-settings.layer.unit.test.ts b/apps/cli/src/config/command-settings.layer.unit.test.ts index 13925aee71..c548a92f91 100644 --- a/apps/cli/src/config/command-settings.layer.unit.test.ts +++ b/apps/cli/src/config/command-settings.layer.unit.test.ts @@ -1,20 +1,29 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { Cause, ConfigProvider, Effect, Exit, Layer, Option, Redacted } from "effect"; -import { afterEach, beforeEach, vi } from "vitest"; +import { BunPath, BunServices } from "@effect/platform-bun"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Redacted, +} from "effect"; +import { vi } from "vitest"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { DebugFlag, ProfileFlag, WorkdirFlag } from "../command-internal/global-flags.ts"; +import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; import { mockRuntimeInfo } from "../../tests/helpers/mocks.ts"; import { debugLoggerLayer } from "../command-internal/debug-logger.layer.ts"; import { ProfileLoadError } from "../command-internal/profile-load.ts"; import { commandSettingsLayer } from "./command-settings.layer.ts"; import { CommandSettings } from "./command-settings.service.ts"; +const path = Effect.runSync(Effect.provide(Path.Path, BunPath.layer)); + function makeLayer(opts: { profileFlag?: string; workdirFlag?: Option.Option; @@ -38,7 +47,7 @@ function makeLayer(opts: { Layer.provide( mockRuntimeInfo({ cwd: opts.cwd ?? "/test/cwd", - homeDir: opts.home ?? join(tempRoot, "home"), + homeDir: opts.home ?? path.join(tempRoot.current, "home"), }), ), Layer.provide(BunServices.layer), @@ -52,9 +61,7 @@ function makeLayer(opts: { // Profile load failures surface as layer-build failures. function configExit(opts: Parameters[0]) { - return Effect.gen(function* () { - return yield* CommandSettings; - }).pipe(Effect.provide(makeLayer(opts)), Effect.exit); + return Effect.service(CommandSettings).pipe(Effect.provide(makeLayer(opts)), Effect.exit); } function expectProfileLoadFailure(exit: Exit.Exit, ...fragments: string[]) { @@ -70,26 +77,15 @@ function expectProfileLoadFailure(exit: Exit.Exit, ...fragment } } -let tempRoot: string; - -beforeEach(() => { - tempRoot = mkdtempSync(join(tmpdir(), "supabase-cli-settings-")); -}); - -afterEach(() => { - rmSync(tempRoot, { recursive: true, force: true }); -}); +const tempRoot = useTempWorkdir("supabase-cli-settings-"); describe("commandSettingsLayer", () => { it.effect("reads updated values from a live provider through CommandSettings", () => { const env: Record = { SUPABASE_PROJECT_ID: "first-project", }; - const layer = makeLayer({ cwd: tempRoot, env }); - const readSettings = () => - Effect.gen(function* () { - return yield* CommandSettings; - }).pipe(Effect.provide(layer)); + const layer = makeLayer({ cwd: tempRoot.current, env }); + const readSettings = () => Effect.service(CommandSettings).pipe(Effect.provide(layer)); return Effect.gen(function* () { const first = yield* readSettings(); @@ -116,7 +112,7 @@ describe("commandSettingsLayer", () => { return Effect.gen(function* () { const config = yield* CommandSettings; expect(config.projectId).toEqual(Option.some("windows-project")); - }).pipe(Effect.provide(makeLayer({ cwd: tempRoot, env }))); + }).pipe(Effect.provide(makeLayer({ cwd: tempRoot.current, env }))); }); it.effect("defaults to supabase profile and api.supabase.com when no flags or env", () => @@ -128,7 +124,7 @@ describe("commandSettingsLayer", () => { expect(config.projectHost).toBe("supabase.co"); expect(config.poolerHost).toBe("supabase.com"); expect(config.dashboardUrl).toBe("https://supabase.com/dashboard"); - }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), + }).pipe(Effect.provide(makeLayer({ cwd: tempRoot.current }))), ); it.effect("uses SUPABASE_PROFILE env when the flag is left at default", () => @@ -140,7 +136,9 @@ describe("commandSettingsLayer", () => { expect(config.projectHost).toBe("supabase.red"); expect(config.poolerHost).toBe("supabase.green"); }).pipe( - Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: "supabase-staging" }, cwd: tempRoot })), + Effect.provide( + makeLayer({ env: { SUPABASE_PROFILE: "supabase-staging" }, cwd: tempRoot.current }), + ), ), ); @@ -148,14 +146,14 @@ describe("commandSettingsLayer", () => { Effect.gen(function* () { const config = yield* CommandSettings; expect(config.workdirEnvValue).toEqual(Option.some("")); - }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_WORKDIR: "" }, cwd: tempRoot }))), + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_WORKDIR: "" }, cwd: tempRoot.current }))), ); it.effect("captures an absent SUPABASE_WORKDIR as none", () => Effect.gen(function* () { const config = yield* CommandSettings; expect(Option.isNone(config.workdirEnvValue)).toBe(true); - }).pipe(Effect.provide(makeLayer({ env: {}, cwd: tempRoot }))), + }).pipe(Effect.provide(makeLayer({ env: {}, cwd: tempRoot.current }))), ); it.effect("captures SUPABASE_DB_PASSWORD and GITHUB_TOKEN as redacted options", () => @@ -173,7 +171,7 @@ describe("commandSettingsLayer", () => { Effect.provide( makeLayer({ env: { SUPABASE_DB_PASSWORD: "db-pw", GITHUB_TOKEN: "gh-tok" }, - cwd: tempRoot, + cwd: tempRoot.current, }), ), ), @@ -186,7 +184,7 @@ describe("commandSettingsLayer", () => { expect(Option.isNone(config.githubToken)).toBe(true); }).pipe( Effect.provide( - makeLayer({ env: { SUPABASE_DB_PASSWORD: "", GITHUB_TOKEN: "" }, cwd: tempRoot }), + makeLayer({ env: { SUPABASE_DB_PASSWORD: "", GITHUB_TOKEN: "" }, cwd: tempRoot.current }), ), ), ); @@ -196,14 +194,14 @@ describe("commandSettingsLayer", () => { const config = yield* CommandSettings; expect(config.profile).toBe("supabase"); expect(config.profileEnvValue).toEqual(Option.some("")); - }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: "" }, cwd: tempRoot }))), + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: "" }, cwd: tempRoot.current }))), ); it.effect("uses supabase-local profile and localhost API URL", () => Effect.gen(function* () { const config = yield* CommandSettings; expect(config.apiUrl).toBe("http://localhost:8080"); - }).pipe(Effect.provide(makeLayer({ profileFlag: "supabase-local", cwd: tempRoot }))), + }).pipe(Effect.provide(makeLayer({ profileFlag: "supabase-local", cwd: tempRoot.current }))), ); it.effect("resolves the snap profile API URL and project host", () => @@ -211,63 +209,73 @@ describe("commandSettingsLayer", () => { const config = yield* CommandSettings; expect(config.apiUrl).toBe("https://cloudapi.snap.com"); expect(config.projectHost).toBe("snapcloud.dev"); - }).pipe(Effect.provide(makeLayer({ profileFlag: "snap", cwd: tempRoot }))), + }).pipe(Effect.provide(makeLayer({ profileFlag: "snap", cwd: tempRoot.current }))), ); it.effect("reads the persisted ~/.supabase/profile file when no flag/env is set", () => { - const home = join(tempRoot, "home"); - mkdirSync(join(home, ".supabase"), { recursive: true }); - writeFileSync(join(home, ".supabase", "profile"), "supabase-staging\n"); + const home = path.join(tempRoot.current, "home"); return Effect.gen(function* () { - const config = yield* CommandSettings; + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path.join(home, ".supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(home, ".supabase", "profile"), "supabase-staging\n"); + const config = yield* Effect.provide( + Effect.service(CommandSettings), + makeLayer({ home, cwd: tempRoot.current }), + ); expect(config.profile).toBe("supabase-staging"); - }).pipe(Effect.provide(makeLayer({ home, cwd: tempRoot }))); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("reads the persisted profile file from SUPABASE_HOME when configured", () => { - const home = join(tempRoot, "home"); - const supabaseHome = join(tempRoot, "custom-supabase-home"); - mkdirSync(supabaseHome, { recursive: true }); - writeFileSync(join(supabaseHome, "profile"), "supabase-staging\n"); + const home = path.join(tempRoot.current, "home"); + const supabaseHome = path.join(tempRoot.current, "custom-supabase-home"); return Effect.gen(function* () { - const config = yield* CommandSettings; + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(supabaseHome, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseHome, "profile"), "supabase-staging\n"); + const config = yield* Effect.provide( + Effect.service(CommandSettings), + makeLayer({ home, cwd: tempRoot.current, env: { SUPABASE_HOME: supabaseHome } }), + ); expect(config.profile).toBe("supabase-staging"); - }).pipe( - Effect.provide(makeLayer({ home, cwd: tempRoot, env: { SUPABASE_HOME: supabaseHome } })), - ); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("debug logs the persisted profile file source", () => { - const home = join(tempRoot, "home"); - const profilePath = join(home, ".supabase", "profile"); - mkdirSync(join(home, ".supabase"), { recursive: true }); - writeFileSync(profilePath, "supabase-staging\n"); + const home = path.join(tempRoot.current, "home"); + const profilePath = path.join(home, ".supabase", "profile"); const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); return Effect.gen(function* () { - const config = yield* CommandSettings; + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path.join(home, ".supabase"), { recursive: true }); + yield* fs.writeFileString(profilePath, "supabase-staging\n"); + const config = yield* Effect.provide( + Effect.service(CommandSettings), + makeLayer({ home, cwd: tempRoot.current, debug: true }), + ); expect(config.profile).toBe("supabase-staging"); expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join("")).toContain( `Loading profile from file: ${profilePath}\n`, ); }).pipe( Effect.ensuring(Effect.sync(() => stderr.mockRestore())), - Effect.provide(makeLayer({ home, cwd: tempRoot, debug: true })), + Effect.provide(BunServices.layer), ); }); it.effect("flag and env take precedence over the persisted profile file", () => { - const home = join(tempRoot, "home"); - mkdirSync(join(home, ".supabase"), { recursive: true }); - writeFileSync(join(home, ".supabase", "profile"), "supabase-staging"); + const home = path.join(tempRoot.current, "home"); return Effect.gen(function* () { - const config = yield* CommandSettings; + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path.join(home, ".supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(home, ".supabase", "profile"), "supabase-staging"); + const config = yield* Effect.provide( + Effect.service(CommandSettings), + makeLayer({ home, cwd: tempRoot.current, env: { SUPABASE_PROFILE: "supabase-local" } }), + ); // SUPABASE_PROFILE wins over the file. expect(config.profile).toBe("supabase-local"); - }).pipe( - Effect.provide( - makeLayer({ home, cwd: tempRoot, env: { SUPABASE_PROFILE: "supabase-local" } }), - ), - ); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect( @@ -276,7 +284,7 @@ describe("commandSettingsLayer", () => { Effect.gen(function* () { const exit = yield* configExit({ env: { SUPABASE_PROFILE: "rogue-profile" }, - cwd: tempRoot, + cwd: tempRoot.current, }); expectProfileLoadFailure(exit, "failed to read profile: Unsupported Config Type"); }), @@ -284,7 +292,7 @@ describe("commandSettingsLayer", () => { it.effect("fails when --profile names a non-existent profile instead of falling back", () => Effect.gen(function* () { - const exit = yield* configExit({ profileFlag: "resms", cwd: tempRoot }); + const exit = yield* configExit({ profileFlag: "resms", cwd: tempRoot.current }); expectProfileLoadFailure(exit, "failed to read profile: Unsupported Config Type"); }), ); @@ -294,7 +302,7 @@ describe("commandSettingsLayer", () => { const config = yield* CommandSettings; expect(config.profile).toBe("supabase-staging"); expect(config.apiUrl).toBe("https://api.supabase.green"); - }).pipe(Effect.provide(makeLayer({ profileFlag: "SUPABASE-STAGING", cwd: tempRoot }))), + }).pipe(Effect.provide(makeLayer({ profileFlag: "SUPABASE-STAGING", cwd: tempRoot.current }))), ); it.effect( @@ -309,7 +317,7 @@ describe("commandSettingsLayer", () => { makeLayer({ argv: ["link", "--profile", "supabase"], env: { SUPABASE_PROFILE: "rogue-profile" }, - cwd: tempRoot, + cwd: tempRoot.current, }), ), ), @@ -325,7 +333,7 @@ describe("commandSettingsLayer", () => { makeLayer({ argv: ["link", "--profile", "rogue-profile", "--profile", "supabase"], profileFlag: "rogue-profile", - cwd: tempRoot, + cwd: tempRoot.current, }), ), ), @@ -336,38 +344,44 @@ describe("commandSettingsLayer", () => { const exit = yield* configExit({ argv: ["link", "--profile", "supabase", "--profile", "resms"], profileFlag: "supabase", - cwd: tempRoot, + cwd: tempRoot.current, }); expectProfileLoadFailure(exit, "failed to read profile: Unsupported Config Type"); }), ); it.effect("explicit --profile supabase shadows an unloadable persisted profile file", () => { - const home = join(tempRoot, "home"); - mkdirSync(join(home, ".supabase"), { recursive: true }); - writeFileSync(join(home, ".supabase", "profile"), "resms\n"); + const home = path.join(tempRoot.current, "home"); return Effect.gen(function* () { - const config = yield* CommandSettings; + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path.join(home, ".supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(home, ".supabase", "profile"), "resms\n"); + const config = yield* Effect.provide( + Effect.service(CommandSettings), + makeLayer({ argv: ["login", "--profile=supabase"], home, cwd: tempRoot.current }), + ); expect(config.profile).toBe("supabase"); - }).pipe( - Effect.provide(makeLayer({ argv: ["login", "--profile=supabase"], home, cwd: tempRoot })), - ); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("loads api_url, name, pooler_host, and dashboard_url from a YAML profile file", () => { - const profilePath = join(tempRoot, "profile.yaml"); - writeFileSync( - profilePath, - [ - "name: cli-e2e", - 'api_url: "http://127.0.0.1:9999"', - "project_host: localhost", - "pooler_host: staging.example.com", - 'dashboard_url: "http://127.0.0.1:9999"', - ].join("\n"), - ); + const profilePath = path.join(tempRoot.current, "profile.yaml"); return Effect.gen(function* () { - const config = yield* CommandSettings; + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + profilePath, + [ + "name: cli-e2e", + 'api_url: "http://127.0.0.1:9999"', + "project_host: localhost", + "pooler_host: staging.example.com", + 'dashboard_url: "http://127.0.0.1:9999"', + ].join("\n"), + ); + const config = yield* Effect.provide( + Effect.service(CommandSettings), + makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot.current }), + ); expect(config.profile).toBe("cli-e2e"); expect(config.profileEnvValue).toEqual(Option.some(profilePath)); expect(config.apiUrl).toBe("http://127.0.0.1:9999"); @@ -375,47 +389,61 @@ describe("commandSettingsLayer", () => { expect(config.poolerHost).toBe("staging.example.com"); // dashboard_url feeds the connect-failure hint; cli-e2e points it at its replay server. expect(config.dashboardUrl).toBe("http://127.0.0.1:9999"); - }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("keeps pooler_host empty when a YAML profile omits it — Go omitempty", () => { - const profilePath = join(tempRoot, "no-pooler.yaml"); - writeFileSync( - profilePath, - [ - "name: cli-e2e", - 'api_url: "http://127.0.0.1:9999"', - 'dashboard_url: "http://127.0.0.1:9999"', - "project_host: localhost", - ].join("\n"), - ); + const profilePath = path.join(tempRoot.current, "no-pooler.yaml"); return Effect.gen(function* () { - const config = yield* CommandSettings; + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + profilePath, + [ + "name: cli-e2e", + 'api_url: "http://127.0.0.1:9999"', + 'dashboard_url: "http://127.0.0.1:9999"', + "project_host: localhost", + ].join("\n"), + ); + const config = yield* Effect.provide( + Effect.service(CommandSettings), + makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot.current }), + ); expect(config.projectHost).toBe("localhost"); // An absent pooler_host disables the MITM domain assertion rather // than falling back to supabase.com. expect(config.poolerHost).toBe(""); - }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("fails when a YAML profile omits required keys — Go validator parity", () => { - const profilePath = join(tempRoot, "no-host.yaml"); - writeFileSync(profilePath, ["name: cli-e2e", 'api_url: "http://127.0.0.1:9999"'].join("\n")); + const profilePath = path.join(tempRoot.current, "no-host.yaml"); return Effect.gen(function* () { - const exit = yield* configExit({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + profilePath, + ["name: cli-e2e", 'api_url: "http://127.0.0.1:9999"'].join("\n"), + ); + const exit = yield* configExit({ + env: { SUPABASE_PROFILE: profilePath }, + cwd: tempRoot.current, + }); expectProfileLoadFailure( exit, "invalid profile:", "Field validation for 'DashboardURL' failed on the 'required' tag", "Field validation for 'ProjectHost' failed on the 'required' tag", ); - }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("fails when SUPABASE_PROFILE points to a non-existent file — Go parity", () => Effect.gen(function* () { - const missingPath = join(tempRoot, "missing.yaml"); - const exit = yield* configExit({ env: { SUPABASE_PROFILE: missingPath }, cwd: tempRoot }); + const missingPath = path.join(tempRoot.current, "missing.yaml"); + const exit = yield* configExit({ + env: { SUPABASE_PROFILE: missingPath }, + cwd: tempRoot.current, + }); expectProfileLoadFailure( exit, `failed to read profile: open ${missingPath}: no such file or directory`, @@ -424,24 +452,29 @@ describe("commandSettingsLayer", () => { ); it.effect("fails when SUPABASE_PROFILE points to malformed YAML — Go parity", () => { - const profilePath = join(tempRoot, "broken.yaml"); - writeFileSync(profilePath, "::: not yaml :::\n[unbalanced"); + const profilePath = path.join(tempRoot.current, "broken.yaml"); return Effect.gen(function* () { - const exit = yield* configExit({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(profilePath, "::: not yaml :::\n[unbalanced"); + const exit = yield* configExit({ + env: { SUPABASE_PROFILE: profilePath }, + cwd: tempRoot.current, + }); expectProfileLoadFailure(exit, "failed to read profile: While parsing config:"); - }); + }).pipe(Effect.provide(BunServices.layer)); }); // Files written by older, more lenient CLI versions may still exist on disk and must // still fail validation. it.effect("fails when the persisted profile file names an unloadable profile", () => { - const home = join(tempRoot, "home"); - mkdirSync(join(home, ".supabase"), { recursive: true }); - writeFileSync(join(home, ".supabase", "profile"), "resms\n"); + const home = path.join(tempRoot.current, "home"); return Effect.gen(function* () { - const exit = yield* configExit({ home, cwd: tempRoot }); + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path.join(home, ".supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(home, ".supabase", "profile"), "resms\n"); + const exit = yield* configExit({ home, cwd: tempRoot.current }); expectProfileLoadFailure(exit, "failed to read profile: Unsupported Config Type"); - }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("ignores SUPABASE_API_URL — Go parity", () => @@ -450,7 +483,7 @@ describe("commandSettingsLayer", () => { expect(config.apiUrl).toBe("https://api.supabase.com"); }).pipe( Effect.provide( - makeLayer({ env: { SUPABASE_API_URL: "https://nope.example.com" }, cwd: tempRoot }), + makeLayer({ env: { SUPABASE_API_URL: "https://nope.example.com" }, cwd: tempRoot.current }), ), ), ); @@ -463,7 +496,9 @@ describe("commandSettingsLayer", () => { expect(Redacted.value(config.accessToken.value)).toBe("sbp_test"); } }).pipe( - Effect.provide(makeLayer({ env: { SUPABASE_ACCESS_TOKEN: "sbp_test" }, cwd: tempRoot })), + Effect.provide( + makeLayer({ env: { SUPABASE_ACCESS_TOKEN: "sbp_test" }, cwd: tempRoot.current }), + ), ), ); @@ -473,7 +508,7 @@ describe("commandSettingsLayer", () => { expect(Option.getOrUndefined(config.projectId)).toBe("myrefabcdefghijklmno"); }).pipe( Effect.provide( - makeLayer({ env: { SUPABASE_PROJECT_ID: "myrefabcdefghijklmno" }, cwd: tempRoot }), + makeLayer({ env: { SUPABASE_PROJECT_ID: "myrefabcdefghijklmno" }, cwd: tempRoot.current }), ), ), ); @@ -486,7 +521,7 @@ describe("commandSettingsLayer", () => { expect(config.profileEnvValue).toEqual(Option.some("")); expect(Option.isNone(config.accessToken)).toBe(true); expect(Option.isNone(config.projectId)).toBe(true); - expect(config.workdir).toBe(tempRoot); + expect(config.workdir).toBe(tempRoot.current); expect(config.explicitWorkdir).toBe(false); }).pipe( Effect.provide( @@ -497,7 +532,7 @@ describe("commandSettingsLayer", () => { SUPABASE_PROJECT_ID: "", SUPABASE_WORKDIR: "", }, - cwd: tempRoot, + cwd: tempRoot.current, }), ), ), @@ -515,7 +550,7 @@ describe("commandSettingsLayer", () => { makeLayer({ workdirFlag: Option.some("/flag/workdir"), env: { SUPABASE_WORKDIR: "/env/workdir" }, - cwd: tempRoot, + cwd: tempRoot.current, }), ), ), @@ -527,7 +562,9 @@ describe("commandSettingsLayer", () => { expect(config.workdir).toBe("/env/workdir"); expect(config.explicitWorkdir).toBe(true); }).pipe( - Effect.provide(makeLayer({ env: { SUPABASE_WORKDIR: "/env/workdir" }, cwd: tempRoot })), + Effect.provide( + makeLayer({ env: { SUPABASE_WORKDIR: "/env/workdir" }, cwd: tempRoot.current }), + ), ), ); @@ -543,7 +580,7 @@ describe("commandSettingsLayer", () => { makeLayer({ workdirFlag: Option.some(""), env: { SUPABASE_WORKDIR: "/env/workdir" }, - cwd: tempRoot, + cwd: tempRoot.current, }), ), ), @@ -552,30 +589,30 @@ describe("commandSettingsLayer", () => { it.effect("an empty --workdir flag with no env falls through to the walk-up", () => Effect.gen(function* () { const config = yield* CommandSettings; - expect(config.workdir).toBe(tempRoot); + expect(config.workdir).toBe(tempRoot.current); expect(config.explicitWorkdir).toBe(false); - }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some(""), cwd: tempRoot }))), + }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some(""), cwd: tempRoot.current }))), ); it.effect("resolves a relative --workdir flag against the real cwd", () => Effect.gen(function* () { const config = yield* CommandSettings; - expect(config.workdir).toBe(tempRoot); - }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some("."), cwd: tempRoot }))), + expect(config.workdir).toBe(tempRoot.current); + }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some("."), cwd: tempRoot.current }))), ); it.effect("resolves a relative --workdir flag with a subdirectory against the real cwd", () => Effect.gen(function* () { const config = yield* CommandSettings; - expect(config.workdir).toBe(join(tempRoot, "sub")); - }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some("sub"), cwd: tempRoot }))), + expect(config.workdir).toBe(path.join(tempRoot.current, "sub")); + }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some("sub"), cwd: tempRoot.current }))), ); it.effect("resolves a relative SUPABASE_WORKDIR env value against the real cwd", () => Effect.gen(function* () { const config = yield* CommandSettings; - expect(config.workdir).toBe(tempRoot); - }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_WORKDIR: "." }, cwd: tempRoot }))), + expect(config.workdir).toBe(tempRoot.current); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_WORKDIR: "." }, cwd: tempRoot.current }))), ); it.effect("keeps an absolute --workdir flag unchanged", () => @@ -583,30 +620,39 @@ describe("commandSettingsLayer", () => { const config = yield* CommandSettings; expect(config.workdir).toBe("/flag/workdir"); }).pipe( - Effect.provide(makeLayer({ workdirFlag: Option.some("/flag/workdir"), cwd: tempRoot })), + Effect.provide( + makeLayer({ workdirFlag: Option.some("/flag/workdir"), cwd: tempRoot.current }), + ), ), ); it.effect("walks up from CWD looking for supabase/config.toml", () => { - const projectRoot = join(tempRoot, "project"); - const nested = join(projectRoot, "deep", "child"); - mkdirSync(join(projectRoot, "supabase"), { recursive: true }); - mkdirSync(nested, { recursive: true }); - writeFileSync(join(projectRoot, "supabase", "config.toml"), 'project_id = "x"\n'); + const projectRoot = path.join(tempRoot.current, "project"); + const nested = path.join(projectRoot, "deep", "child"); return Effect.gen(function* () { - const config = yield* CommandSettings; + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.makeDirectory(nested, { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), + 'project_id = "x"\n', + ); + const config = yield* Effect.provide( + Effect.service(CommandSettings), + makeLayer({ cwd: nested }), + ); expect(config.workdir).toBe(projectRoot); expect(config.explicitWorkdir).toBe(false); - }).pipe(Effect.provide(makeLayer({ cwd: nested }))); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("falls back to CWD when no supabase/config.toml found", () => Effect.gen(function* () { const config = yield* CommandSettings; - expect(config.workdir).toBe(tempRoot); + expect(config.workdir).toBe(tempRoot.current); expect(config.explicitWorkdir).toBe(false); - }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), + }).pipe(Effect.provide(makeLayer({ cwd: tempRoot.current }))), ); it.effect("populates userAgent from CLI_VERSION", () => @@ -614,6 +660,6 @@ describe("commandSettingsLayer", () => { const config = yield* CommandSettings; // The sentinel `0.0.0-dev` value applies when SUPABASE_CLI_VERSION is unset (tests). expect(config.userAgent).toMatch(/^SupabaseCLI\//); - }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), + }).pipe(Effect.provide(makeLayer({ cwd: tempRoot.current }))), ); }); diff --git a/apps/cli/src/config/project-ref.layer.integration.test.ts b/apps/cli/src/config/project-ref.layer.integration.test.ts index 5fcb71d55c..4d49ab1daa 100644 --- a/apps/cli/src/config/project-ref.layer.integration.test.ts +++ b/apps/cli/src/config/project-ref.layer.integration.test.ts @@ -1,10 +1,7 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - import type { V1ListAllProjectsOutput } from "@supabase/api/effect"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { VALID_REF, @@ -110,15 +107,17 @@ describe("projectRefLayer Management API requests", () => { }); it.effect("requests nothing when supabase/.temp/project-ref links the project", () => { - const tempDir = join(workdir.current, "supabase", ".temp"); - mkdirSync(tempDir, { recursive: true }); - writeFileSync(join(tempDir, "project-ref"), VALID_REF); const { api, layer } = setup({}); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = path.join(workdir.current, "supabase", ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString(path.join(tempDir, "project-ref"), VALID_REF); const { resolve } = yield* ProjectRefResolver; const ref = yield* resolve(Option.none()); expect(ref).toBe(VALID_REF); expect(api.requests).toEqual([]); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide([layer, BunServices.layer])); }); }); diff --git a/apps/cli/src/config/project-ref.layer.ts b/apps/cli/src/config/project-ref.layer.ts index eed180e0e3..cef3f5c181 100644 --- a/apps/cli/src/config/project-ref.layer.ts +++ b/apps/cli/src/config/project-ref.layer.ts @@ -104,9 +104,7 @@ export const projectRefLayer = Layer.effect( const chosen = yield* promptForProjectRef("Select a project:"); return yield* assertValid(chosen); } - return yield* Effect.fail( - new ProjectRefNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }), - ); + return yield* new ProjectRefNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }); }), resolveForLink: (flagValue) => Effect.gen(function* () { @@ -121,11 +119,9 @@ export const projectRefLayer = Layer.effect( const chosen = yield* promptForProjectRef("Select a project:"); return yield* assertValid(chosen); } - return yield* Effect.fail( - new ProjectRefRequiredError({ - message: `required flag(s) "project-ref" not set`, - }), - ); + return yield* new ProjectRefRequiredError({ + message: `required flag(s) "project-ref" not set`, + }); }), resolveOptional: (flagValue) => Effect.gen(function* () { @@ -151,9 +147,7 @@ export const projectRefLayer = Layer.effect( if (Option.isSome(fileValue)) { return yield* assertValid(fileValue.value); } - return yield* Effect.fail( - new ProjectRefNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }), - ); + return yield* new ProjectRefNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }); }), promptProjectRef: promptForProjectRef, }); diff --git a/apps/cli/src/config/project-ref.layer.unit.test.ts b/apps/cli/src/config/project-ref.layer.unit.test.ts index 9926a42691..27e937141c 100644 --- a/apps/cli/src/config/project-ref.layer.unit.test.ts +++ b/apps/cli/src/config/project-ref.layer.unit.test.ts @@ -1,15 +1,11 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import type { ApiClient } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Effect, Exit, Layer, Option } from "effect"; -import { afterEach, beforeEach } from "vitest"; +import { Effect, Exit, FileSystem, Layer, Option, Path, Schema } from "effect"; import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts"; import { CommandPlatformApi } from "../auth/command-platform-api.service.ts"; +import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; import { mockOutput, mockTty } from "../../tests/helpers/mocks.ts"; import { CommandSettings } from "./command-settings.service.ts"; import { ProjectRefResolver } from "./project-ref.service.ts"; @@ -18,6 +14,8 @@ import { projectRefLayer } from "./project-ref.layer.ts"; const VALID_REF = "abcdefghijklmnopqrst"; const ANOTHER_REF = "qrstuvwxyzabcdefghij"; +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + function mockCliSettings(opts: { workdir: string; projectId?: string }) { return Layer.succeed(CommandSettings, { dbPassword: Option.none(), @@ -80,32 +78,26 @@ function makeLayer(opts: { make: CommandPlatformApi.pipe(Effect.provide(mockPlatformApi(opts.projects ?? []))), }), ), - Layer.provide(BunServices.layer), + Layer.provideMerge(BunServices.layer), ); return { layer, out }; } -let tempRoot: string; - -beforeEach(() => { - tempRoot = mkdtempSync(join(tmpdir(), "supabase-project-ref-")); -}); +const tempRoot = useTempWorkdir("supabase-project-ref-"); -afterEach(() => { - rmSync(tempRoot, { recursive: true, force: true }); +const writeRefFile = Effect.fnUntraced(function* (workdir: string, content: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = path.join(workdir, "supabase", ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString(path.join(tempDir, "project-ref"), content); }); -function writeRefFile(workdir: string, content: string) { - const tempDir = join(workdir, "supabase", ".temp"); - mkdirSync(tempDir, { recursive: true }); - writeFileSync(join(tempDir, "project-ref"), content); -} - describe("projectRefLayer", () => { it.effect("prefers --project-ref flag over env and file", () => { - writeRefFile(tempRoot, ANOTHER_REF); - const { layer } = makeLayer({ workdir: tempRoot, projectId: ANOTHER_REF }); + const { layer } = makeLayer({ workdir: tempRoot.current, projectId: ANOTHER_REF }); return Effect.gen(function* () { + yield* writeRefFile(tempRoot.current, ANOTHER_REF); const { resolve } = yield* ProjectRefResolver; const ref = yield* resolve(Option.some(VALID_REF)); expect(ref).toBe(VALID_REF); @@ -113,9 +105,9 @@ describe("projectRefLayer", () => { }); it.effect("uses SUPABASE_PROJECT_ID when flag is unset", () => { - writeRefFile(tempRoot, ANOTHER_REF); - const { layer } = makeLayer({ workdir: tempRoot, projectId: VALID_REF }); + const { layer } = makeLayer({ workdir: tempRoot.current, projectId: VALID_REF }); return Effect.gen(function* () { + yield* writeRefFile(tempRoot.current, ANOTHER_REF); const { resolve } = yield* ProjectRefResolver; const ref = yield* resolve(Option.none()); expect(ref).toBe(VALID_REF); @@ -123,9 +115,9 @@ describe("projectRefLayer", () => { }); it.effect("reads /supabase/.temp/project-ref when env and flag are unset", () => { - writeRefFile(tempRoot, VALID_REF); - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { + yield* writeRefFile(tempRoot.current, VALID_REF); const { resolve } = yield* ProjectRefResolver; const ref = yield* resolve(Option.none()); expect(ref).toBe(VALID_REF); @@ -133,9 +125,9 @@ describe("projectRefLayer", () => { }); it.effect("trims whitespace from the temp/project-ref file content", () => { - writeRefFile(tempRoot, ` ${VALID_REF}\n\n`); - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { + yield* writeRefFile(tempRoot.current, ` ${VALID_REF}\n\n`); const { resolve } = yield* ProjectRefResolver; const ref = yield* resolve(Option.none()); expect(ref).toBe(VALID_REF); @@ -148,7 +140,7 @@ describe("projectRefLayer", () => { { id: ANOTHER_REF, name: "beta", organization_slug: "acme", region: "eu-west-1" }, ]; const { layer, out } = makeLayer({ - workdir: tempRoot, + workdir: tempRoot.current, stdinIsTty: true, projects, promptSelectResponses: [ANOTHER_REF], @@ -174,28 +166,30 @@ describe("projectRefLayer", () => { const projects = [ { id: VALID_REF, name: "alpha", organization_slug: "acme", region: "us-east-1" }, ]; - const refPath = join(tempRoot, "supabase", ".temp", "project-ref"); const { layer } = makeLayer({ - workdir: tempRoot, + workdir: tempRoot.current, stdinIsTty: true, projects, promptSelectResponses: [VALID_REF], }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const refPath = path.join(tempRoot.current, "supabase", ".temp", "project-ref"); const { resolve } = yield* ProjectRefResolver; yield* resolve(Option.none()); - expect(existsSync(refPath)).toBe(false); + expect(yield* fs.exists(refPath)).toBe(false); }).pipe(Effect.provide(layer)); }); it.effect("fails with ProjectRefNotLinkedError on non-TTY with no source", () => { - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { const { resolve } = yield* ProjectRefResolver; const exit = yield* Effect.exit(resolve(Option.none())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = yield* encodeJson(exit.cause); expect(errorJson).toContain("ProjectRefNotLinkedError"); expect(errorJson).toContain("supabase link"); } @@ -203,13 +197,13 @@ describe("projectRefLayer", () => { }); it.effect("fails with InvalidProjectRefError when the resolved ref is malformed", () => { - const { layer } = makeLayer({ workdir: tempRoot, projectId: "not-a-valid-ref" }); + const { layer } = makeLayer({ workdir: tempRoot.current, projectId: "not-a-valid-ref" }); return Effect.gen(function* () { const { resolve } = yield* ProjectRefResolver; const exit = yield* Effect.exit(resolve(Option.none())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = yield* encodeJson(exit.cause); expect(errorJson).toContain("InvalidProjectRefError"); expect(errorJson).toContain("Invalid project ref format"); } @@ -217,7 +211,7 @@ describe("projectRefLayer", () => { }); it.effect("rejects invalid ref from --project-ref flag", () => { - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { const { resolve } = yield* ProjectRefResolver; const exit = yield* Effect.exit(resolve(Option.some("BADREF"))); @@ -226,9 +220,9 @@ describe("projectRefLayer", () => { }); it.effect("rejects invalid ref from temp/project-ref file", () => { - writeRefFile(tempRoot, "BADREF"); - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { + yield* writeRefFile(tempRoot.current, "BADREF"); const { resolve } = yield* ProjectRefResolver; const exit = yield* Effect.exit(resolve(Option.none())); expect(Exit.isFailure(exit)).toBe(true); @@ -237,9 +231,9 @@ describe("projectRefLayer", () => { describe("resolveOptional", () => { it.effect("prefers the flag value", () => { - writeRefFile(tempRoot, ANOTHER_REF); - const { layer } = makeLayer({ workdir: tempRoot, projectId: ANOTHER_REF }); + const { layer } = makeLayer({ workdir: tempRoot.current, projectId: ANOTHER_REF }); return Effect.gen(function* () { + yield* writeRefFile(tempRoot.current, ANOTHER_REF); const { resolveOptional } = yield* ProjectRefResolver; const ref = yield* resolveOptional(Option.some(VALID_REF)); expect(ref).toEqual(Option.some(VALID_REF)); @@ -247,7 +241,7 @@ describe("projectRefLayer", () => { }); it.effect("falls back to projectId then the ref file", () => { - const { layer } = makeLayer({ workdir: tempRoot, projectId: VALID_REF }); + const { layer } = makeLayer({ workdir: tempRoot.current, projectId: VALID_REF }); return Effect.gen(function* () { const { resolveOptional } = yield* ProjectRefResolver; const ref = yield* resolveOptional(Option.none()); @@ -256,9 +250,9 @@ describe("projectRefLayer", () => { }); it.effect("reads the ref file when flag and projectId are unset", () => { - writeRefFile(tempRoot, VALID_REF); - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { + yield* writeRefFile(tempRoot.current, VALID_REF); const { resolveOptional } = yield* ProjectRefResolver; const ref = yield* resolveOptional(Option.none()); expect(ref).toEqual(Option.some(VALID_REF)); @@ -266,7 +260,7 @@ describe("projectRefLayer", () => { }); it.effect("returns None and never fails when nothing resolves", () => { - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { const { resolveOptional } = yield* ProjectRefResolver; const ref = yield* resolveOptional(Option.none()); @@ -277,18 +271,18 @@ describe("projectRefLayer", () => { describe("loadProjectRef (Go flags.LoadProjectRef — non-prompting)", () => { it.effect("prefers flag, then projectId, then the ref file", () => { - writeRefFile(tempRoot, ANOTHER_REF); - const { layer } = makeLayer({ workdir: tempRoot, projectId: ANOTHER_REF }); + const { layer } = makeLayer({ workdir: tempRoot.current, projectId: ANOTHER_REF }); return Effect.gen(function* () { + yield* writeRefFile(tempRoot.current, ANOTHER_REF); const { loadProjectRef } = yield* ProjectRefResolver; expect(yield* loadProjectRef(Option.some(VALID_REF))).toBe(VALID_REF); }).pipe(Effect.provide(layer)); }); it.effect("reads the ref file when flag and projectId are unset", () => { - writeRefFile(tempRoot, VALID_REF); - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { + yield* writeRefFile(tempRoot.current, VALID_REF); const { loadProjectRef } = yield* ProjectRefResolver; expect(yield* loadProjectRef(Option.none())).toBe(VALID_REF); }).pipe(Effect.provide(layer)); @@ -296,7 +290,7 @@ describe("projectRefLayer", () => { it.effect("fails fast with ProjectRefNotLinkedError and never prompts on a TTY", () => { const { layer, out } = makeLayer({ - workdir: tempRoot, + workdir: tempRoot.current, stdinIsTty: true, projects: [ { id: VALID_REF, name: "alpha", organization_slug: "acme", region: "us-east-1" }, @@ -308,7 +302,7 @@ describe("projectRefLayer", () => { const exit = yield* Effect.exit(loadProjectRef(Option.none())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = yield* encodeJson(exit.cause); expect(errorJson).toContain("ProjectRefNotLinkedError"); expect(errorJson).toContain("supabase link"); } @@ -317,13 +311,13 @@ describe("projectRefLayer", () => { }); it.effect("validates the resolved ref format", () => { - const { layer } = makeLayer({ workdir: tempRoot, projectId: "not-a-valid-ref" }); + const { layer } = makeLayer({ workdir: tempRoot.current, projectId: "not-a-valid-ref" }); return Effect.gen(function* () { const { loadProjectRef } = yield* ProjectRefResolver; const exit = yield* Effect.exit(loadProjectRef(Option.none())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("InvalidProjectRefError"); + expect(yield* encodeJson(exit.cause)).toContain("InvalidProjectRefError"); } }).pipe(Effect.provide(layer)); }); @@ -331,7 +325,7 @@ describe("projectRefLayer", () => { describe("resolveForLink", () => { it.effect("prefers the --project-ref flag", () => { - const { layer } = makeLayer({ workdir: tempRoot, projectId: ANOTHER_REF }); + const { layer } = makeLayer({ workdir: tempRoot.current, projectId: ANOTHER_REF }); return Effect.gen(function* () { const { resolveForLink } = yield* ProjectRefResolver; const ref = yield* resolveForLink(Option.some(VALID_REF)); @@ -340,7 +334,7 @@ describe("projectRefLayer", () => { }); it.effect("uses SUPABASE_PROJECT_ID when the flag is unset", () => { - const { layer } = makeLayer({ workdir: tempRoot, projectId: VALID_REF }); + const { layer } = makeLayer({ workdir: tempRoot.current, projectId: VALID_REF }); return Effect.gen(function* () { const { resolveForLink } = yield* ProjectRefResolver; const ref = yield* resolveForLink(Option.none()); @@ -349,14 +343,14 @@ describe("projectRefLayer", () => { }); it.effect("skips the ref file (Go MemMapFs) and fails off-TTY with no flag/projectId", () => { - writeRefFile(tempRoot, VALID_REF); - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { + yield* writeRefFile(tempRoot.current, VALID_REF); const { resolveForLink } = yield* ProjectRefResolver; const exit = yield* Effect.exit(resolveForLink(Option.none())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = yield* encodeJson(exit.cause); expect(errorJson).toContain("ProjectRefRequiredError"); expect(errorJson).toContain(`required flag(s) \\"project-ref\\" not set`); } @@ -368,7 +362,7 @@ describe("projectRefLayer", () => { { id: VALID_REF, name: "alpha", organization_slug: "acme", region: "us-east-1" }, ]; const { layer, out } = makeLayer({ - workdir: tempRoot, + workdir: tempRoot.current, stdinIsTty: true, projects, promptSelectResponses: [VALID_REF], @@ -382,13 +376,13 @@ describe("projectRefLayer", () => { }); it.effect("rejects an invalid --project-ref flag", () => { - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { const { resolveForLink } = yield* ProjectRefResolver; const exit = yield* Effect.exit(resolveForLink(Option.some("BADREF"))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("InvalidProjectRefError"); + expect(yield* encodeJson(exit.cause)).toContain("InvalidProjectRefError"); } }).pipe(Effect.provide(layer)); }); @@ -401,7 +395,7 @@ describe("projectRefLayer", () => { { id: ANOTHER_REF, name: "beta", organization_slug: "acme", region: "eu-west-1" }, ]; const { layer, out } = makeLayer({ - workdir: tempRoot, + workdir: tempRoot.current, stdinIsTty: true, projects, promptSelectResponses: [ANOTHER_REF], From fc9892d6cbe33f0472252816bb8cfba48135c644 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:11:26 +0000 Subject: [PATCH 4/7] refactor(cli): cover the `src/cli` area with effect lint (CLI-2742) (#7217) ## TL;DR brings the `src/cli` area under the effect lint, tests included ## whats introduced? - `src/cli/**` joins the allow list, and `docs`, `output` and the top level files follow in the next PR of this stack. - shell completion runs as one effect with a named span, taking the clock and the run id from `Clock` and `Crypto`, so its output, exit codes and telemetry event stay the same and the capture still lands before exit - `isValidGoRfc3339` checks dates through `DateTime`, with a few new test cases for `gen bearer-jwt --exp` - the cli tests move to `it.effect`, `it.live` and a new `withTempCliProject` helper ## ref - related: CLI-2742 --- .oxlintrc.effect.json | 1 + apps/cli/src/cli/agent-output.e2e.test.ts | 309 ++++++++++-------- apps/cli/src/cli/complete.e2e.test.ts | 223 +++++++------ apps/cli/src/cli/complete.integration.test.ts | 82 +++-- apps/cli/src/cli/complete.ts | 79 +++-- apps/cli/src/cli/complete.unit.test.ts | 158 +++++---- apps/cli/src/cli/main.e2e.test.ts | 44 +-- apps/cli/src/cli/main.ts | 6 +- .../root-stack-aliases.integration.test.ts | 64 ++-- apps/cli/tests/helpers/cli.ts | 29 ++ 10 files changed, 561 insertions(+), 434 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 2c1e9997d6..4e117c6d1d 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -8,6 +8,7 @@ "**", "!packages/stack/**", "!apps/cli/src/auth/**", + "!apps/cli/src/cli/**", "!apps/cli/src/commands/**", "!apps/cli/src/config/**", "!apps/cli/src/shared/**", diff --git a/apps/cli/src/cli/agent-output.e2e.test.ts b/apps/cli/src/cli/agent-output.e2e.test.ts index 97f372eb8b..2b71217596 100644 --- a/apps/cli/src/cli/agent-output.e2e.test.ts +++ b/apps/cli/src/cli/agent-output.e2e.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, test } from "vitest"; -import { runSupabase, stripAnsi } from "../../tests/helpers/cli.ts"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { runSupabaseEffect, stripAnsi } from "../../tests/helpers/cli.ts"; function parseJsonLines(output: string): Array { return stripAnsi(output) @@ -10,151 +11,175 @@ function parseJsonLines(output: string): Array { } describe("CLI agent output", () => { - test("formats parse errors as JSON for detected coding agents", async () => { - const { exitCode, stdout, stderr } = await runSupabase(["definitely-not-a-command"], { - env: { CODEX_SANDBOX: "1" }, - }); - - expect(exitCode).toBe(1); - // stdout carries exactly one JSON error line; the library's help doc goes to stderr. - expect(parseJsonLines(stdout)).toEqual([ - expect.objectContaining({ - _tag: "Error", - error: expect.objectContaining({ code: "UnknownSubcommand" }), - }), - ]); - expect(parseJsonLines(stderr)).toEqual([expect.objectContaining({ _tag: "Help" })]); - }); - - test("formats Stack functions serve parse errors as stream-json for detected coding agents", async () => { - const { exitCode, stdout, stderr } = await runSupabase(["functions", "serve", "--bogus"], { - env: { CODEX_SANDBOX: "1", SUPABASE_EXPERIMENTAL_STACK: "1" }, - }); - - expect(exitCode).toBe(1); - expect(parseJsonLines(stdout)).toEqual([ - expect.objectContaining({ - type: "error", - error: expect.objectContaining({ - code: "UnrecognizedOption", - message: expect.stringContaining("--bogus"), + it.live("formats parse errors as JSON for detected coding agents", () => + Effect.gen(function* () { + const { exitCode, stdout, stderr } = yield* runSupabaseEffect(["definitely-not-a-command"], { + env: { CODEX_SANDBOX: "1" }, + }); + + expect(exitCode).toBe(1); + // stdout carries exactly one JSON error line; the library's help doc goes to stderr. + expect(parseJsonLines(stdout)).toEqual([ + expect.objectContaining({ + _tag: "Error", + error: expect.objectContaining({ code: "UnknownSubcommand" }), }), + ]); + expect(parseJsonLines(stderr)).toEqual([expect.objectContaining({ _tag: "Help" })]); + }), + ); + + it.live( + "formats Stack functions serve parse errors as stream-json for detected coding agents", + () => + Effect.gen(function* () { + const { exitCode, stdout, stderr } = yield* runSupabaseEffect( + ["functions", "serve", "--bogus"], + { + env: { CODEX_SANDBOX: "1", SUPABASE_EXPERIMENTAL_STACK: "1" }, + }, + ); + + expect(exitCode).toBe(1); + expect(parseJsonLines(stdout)).toEqual([ + expect.objectContaining({ + type: "error", + error: expect.objectContaining({ + code: "UnrecognizedOption", + message: expect.stringContaining("--bogus"), + }), + }), + ]); + expect(parseJsonLines(stderr)).toEqual([expect.objectContaining({ _tag: "Help" })]); }), - ]); - expect(parseJsonLines(stderr)).toEqual([expect.objectContaining({ _tag: "Help" })]); - }); - - test("keeps legacy functions serve handler errors in JSON for detected coding agents", async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["functions", "serve", "--inspect-main"], - { - env: { CODEX_SANDBOX: "1", SUPABASE_EXPERIMENTAL_STACK: "0" }, - }, - ); - - expect(exitCode).toBe(1); - expect(parseJsonLines(stdout)).toEqual([ - expect.objectContaining({ - _tag: "Error", - error: expect.objectContaining({ - message: expect.stringContaining("--inspect-main must be used together"), + ); + + it.live("keeps legacy functions serve handler errors in JSON for detected coding agents", () => + Effect.gen(function* () { + const { exitCode, stdout, stderr } = yield* runSupabaseEffect( + ["functions", "serve", "--inspect-main"], + { + env: { CODEX_SANDBOX: "1", SUPABASE_EXPERIMENTAL_STACK: "0" }, + }, + ); + + expect(exitCode).toBe(1); + expect(parseJsonLines(stdout)).toEqual([ + expect.objectContaining({ + _tag: "Error", + error: expect.objectContaining({ + message: expect.stringContaining("--inspect-main must be used together"), + }), }), - }), - ]); - expect(stderr).toBe(""); - }); - - test("defaults functions serve to stream-json for detected coding agents", async () => { - const { exitCode, stdout, stderr } = await runSupabase(["functions", "serve"], { - env: { CODEX_SANDBOX: "1", SUPABASE_EXPERIMENTAL_STACK: "1" }, - }); - const explicitJson = await runSupabase(["functions", "serve", "--output-format", "json"], { - env: { CODEX_SANDBOX: "1", SUPABASE_EXPERIMENTAL_STACK: "1" }, - }); - - expect(exitCode).toBe(1); - expect(parseJsonLines(stdout)).toEqual([ - expect.objectContaining({ - type: "error", - error: expect.objectContaining({ - code: "FunctionsServeStackError", - message: "The local stack is not running.", + ]); + expect(stderr).toBe(""); + }), + ); + + it.live("defaults functions serve to stream-json for detected coding agents", () => + Effect.gen(function* () { + const { exitCode, stdout, stderr } = yield* runSupabaseEffect(["functions", "serve"], { + env: { CODEX_SANDBOX: "1", SUPABASE_EXPERIMENTAL_STACK: "1" }, + }); + const explicitJson = yield* runSupabaseEffect( + ["functions", "serve", "--output-format", "json"], + { + env: { CODEX_SANDBOX: "1", SUPABASE_EXPERIMENTAL_STACK: "1" }, + }, + ); + + expect(exitCode).toBe(1); + expect(parseJsonLines(stdout)).toEqual([ + expect.objectContaining({ + type: "error", + error: expect.objectContaining({ + code: "FunctionsServeStackError", + message: "The local stack is not running.", + }), }), - }), - ]); - expect(stderr).toBe(""); - expect(explicitJson.exitCode).toBe(1); - expect(parseJsonLines(explicitJson.stdout)).toEqual([ - expect.objectContaining({ - _tag: "Error", - error: expect.objectContaining({ - code: "FunctionsServeStackError", - message: "Functions serve requires text or stream-json output.", + ]); + expect(stderr).toBe(""); + expect(explicitJson.exitCode).toBe(1); + expect(parseJsonLines(explicitJson.stdout)).toEqual([ + expect.objectContaining({ + _tag: "Error", + error: expect.objectContaining({ + code: "FunctionsServeStackError", + message: "Functions serve requires text or stream-json output.", + }), }), - }), - ]); - expect(explicitJson.stderr).toBe(""); - }); - - test("keeps parse errors in text mode when --output-format=text is explicit", async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["--output-format", "text", "definitely-not-a-command"], - { + ]); + expect(explicitJson.stderr).toBe(""); + }), + ); + + it.live("keeps parse errors in text mode when --output-format=text is explicit", () => + Effect.gen(function* () { + const { exitCode, stdout, stderr } = yield* runSupabaseEffect( + ["--output-format", "text", "definitely-not-a-command"], + { + env: { CODEX_SANDBOX: "1" }, + }, + ); + + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain("DESCRIPTION"); + expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"'); + }), + ); + + it.live("keeps parse errors in text mode when --agent=no is explicit", () => + Effect.gen(function* () { + const { exitCode, stdout, stderr } = yield* runSupabaseEffect( + ["--agent", "no", "definitely-not-a-command"], + { + env: { CODEX_SANDBOX: "1" }, + }, + ); + + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain("DESCRIPTION"); + expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"'); + }), + ); + + it.live("formats parse errors as JSON when --agent=yes is explicit", () => + Effect.gen(function* () { + const { exitCode, stdout, stderr } = yield* runSupabaseEffect( + ["--agent", "yes", "definitely-not-a-command"], + { + env: {}, + }, + ); + + expect(exitCode).toBe(1); + expect(parseJsonLines(stdout)).toEqual([ + expect.objectContaining({ + _tag: "Error", + error: expect.objectContaining({ code: "UnknownSubcommand" }), + }), + ]); + expect(parseJsonLines(stderr)).toEqual([expect.objectContaining({ _tag: "Help" })]); + }), + ); + + it.live("keeps built-in version and help in text mode for detected coding agents", () => + Effect.gen(function* () { + const version = yield* runSupabaseEffect(["--version"], { env: { CODEX_SANDBOX: "1" }, - }, - ); - - expect(exitCode).toBe(1); - expect(stdout).toBe(""); - expect(stderr).toContain("DESCRIPTION"); - expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"'); - }); - - test("keeps parse errors in text mode when --agent=no is explicit", async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["--agent", "no", "definitely-not-a-command"], - { + }); + const help = yield* runSupabaseEffect(["--help"], { env: { CODEX_SANDBOX: "1" }, - }, - ); - - expect(exitCode).toBe(1); - expect(stdout).toBe(""); - expect(stderr).toContain("DESCRIPTION"); - expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"'); - }); - - test("formats parse errors as JSON when --agent=yes is explicit", async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["--agent", "yes", "definitely-not-a-command"], - { - env: {}, - }, - ); - - expect(exitCode).toBe(1); - expect(parseJsonLines(stdout)).toEqual([ - expect.objectContaining({ - _tag: "Error", - error: expect.objectContaining({ code: "UnknownSubcommand" }), - }), - ]); - expect(parseJsonLines(stderr)).toEqual([expect.objectContaining({ _tag: "Help" })]); - }); - - test("keeps built-in version and help in text mode for detected coding agents", async () => { - const version = await runSupabase(["--version"], { - env: { CODEX_SANDBOX: "1" }, - }); - const help = await runSupabase(["--help"], { - env: { CODEX_SANDBOX: "1" }, - }); - - expect(version.exitCode).toBe(0); - expect(version.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); - expect(() => JSON.parse(version.stdout)).toThrow(); - expect(help.exitCode).toBe(0); - expect(help.stdout).toContain("DESCRIPTION"); - expect(() => JSON.parse(help.stdout)).toThrow(); - }); + }); + + expect(version.exitCode).toBe(0); + expect(version.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); + expect(() => JSON.parse(version.stdout)).toThrow(); + expect(help.exitCode).toBe(0); + expect(help.stdout).toContain("DESCRIPTION"); + expect(() => JSON.parse(help.stdout)).toThrow(); + }), + ); }); diff --git a/apps/cli/src/cli/complete.e2e.test.ts b/apps/cli/src/cli/complete.e2e.test.ts index 3943c8dbe7..35640ce142 100644 --- a/apps/cli/src/cli/complete.e2e.test.ts +++ b/apps/cli/src/cli/complete.e2e.test.ts @@ -1,120 +1,141 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { describe, expect, test } from "vitest"; -import { makeTempCliProject, makeTempHome, runSupabase } from "../../tests/helpers/cli.ts"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path, Schema } from "effect"; +import { runSupabaseEffect, withTempCliProject, withTempHome } from "../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; describe("supabase __complete", () => { - test( + it.live( "migration li completes to list with a description and the NoFileComp directive", - { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabase(["__complete", "migration", "li"], {}); - expect(exitCode).toBe(0); - const lines = stdout.trim().split("\n"); - expect(lines[0]).toBe("list\tList local and remote migrations"); - expect(lines.at(-1)).toBe(":4"); - }, + () => + Effect.gen(function* () { + const { exitCode, stdout } = yield* runSupabaseEffect( + ["__complete", "migration", "li"], + {}, + ); + expect(exitCode).toBe(0); + const lines = stdout.trim().split("\n"); + expect(lines[0]).toBe("list\tList local and remote migrations"); + expect(lines.at(-1)).toBe(":4"); + }), + E2E_TIMEOUT_MS, ); - test( + it.live( "__completeNoDesc strips the description from the same candidate", - { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabase(["__completeNoDesc", "migration", "li"], {}); - expect(exitCode).toBe(0); - const lines = stdout.trim().split("\n"); - expect(lines[0]).toBe("list"); - expect(lines.at(-1)).toBe(":4"); - }, + () => + Effect.gen(function* () { + const { exitCode, stdout } = yield* runSupabaseEffect( + ["__completeNoDesc", "migration", "li"], + {}, + ); + expect(exitCode).toBe(0); + const lines = stdout.trim().split("\n"); + expect(lines[0]).toBe("list"); + expect(lines.at(-1)).toBe(":4"); + }), + E2E_TIMEOUT_MS, ); - test("root-level flag-name completion offers --debug", { timeout: E2E_TIMEOUT_MS }, async () => { - const { exitCode, stdout } = await runSupabase(["__complete", "--d"], {}); - expect(exitCode).toBe(0); - expect(stdout).toContain("--debug\toutput debug logs to stderr"); - }); + it.live( + "root-level flag-name completion offers --debug", + () => + Effect.gen(function* () { + const { exitCode, stdout } = yield* runSupabaseEffect(["__complete", "--d"], {}); + expect(exitCode).toBe(0); + expect(stdout).toContain("--debug\toutput debug logs to stderr"); + }), + E2E_TIMEOUT_MS, + ); - test( + it.live( "routes complete command paths and keeps malformed config on the legacy tree", - { timeout: E2E_TIMEOUT_MS }, - async () => { - const project = await makeTempCliProject("supabase-completion-routing-e2e-"); - const home = makeTempHome(); - try { - await mkdir(path.join(project.dir, "supabase"), { recursive: true }); - await writeFile(path.join(project.dir, "supabase", "config.toml"), "[experimental\n"); + () => + withTempHome((home) => + withTempCliProject("supabase-completion-routing-e2e-", (project) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(project.dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(project.dir, "supabase", "config.toml"), + "[experimental\n", + ); - const prefix = await runSupabase(["__complete", "start"], { - cwd: project.dir, - home: home.dir, - env: { - SUPABASE_EXPERIMENTAL_STACK: undefined, - SUPABASE_WORKDIR: undefined, - }, - }); - expect(prefix.exitCode).toBe(0); - expect(prefix.stdout).toContain("start"); - expect(prefix.stderr).toBe(""); + const prefix = yield* runSupabaseEffect(["__complete", "start"], { + cwd: project.dir, + home: home.dir, + env: { + SUPABASE_EXPERIMENTAL_STACK: undefined, + SUPABASE_WORKDIR: undefined, + }, + }); + expect(prefix.exitCode).toBe(0); + expect(prefix.stdout).toContain("start"); + expect(prefix.stderr).toBe(""); - const fallback = await runSupabase(["__complete", "--output-format=json", "start", "--"], { - cwd: project.dir, - home: home.dir, - env: { - SUPABASE_EXPERIMENTAL_STACK: undefined, - SUPABASE_WORKDIR: undefined, - }, - }); - expect(fallback.exitCode).toBe(0); - expect(fallback.stdout).toContain("--ignore-health-check"); - expect(fallback.stderr).toBe(""); + const fallback = yield* runSupabaseEffect( + ["__complete", "--output-format=json", "start", "--"], + { + cwd: project.dir, + home: home.dir, + env: { + SUPABASE_EXPERIMENTAL_STACK: undefined, + SUPABASE_WORKDIR: undefined, + }, + }, + ); + expect(fallback.exitCode).toBe(0); + expect(fallback.stdout).toContain("--ignore-health-check"); + expect(fallback.stderr).toBe(""); - const help = await runSupabase(["start", "--help"], { - cwd: project.dir, - home: home.dir, - env: { - SUPABASE_EXPERIMENTAL_STACK: undefined, - SUPABASE_WORKDIR: undefined, - }, - }); - expect(help.exitCode).toBe(0); - expect(help.stdout).toContain("--ignore-health-check"); - expect(help.stderr).toBe(""); + const help = yield* runSupabaseEffect(["start", "--help"], { + cwd: project.dir, + home: home.dir, + env: { + SUPABASE_EXPERIMENTAL_STACK: undefined, + SUPABASE_WORKDIR: undefined, + }, + }); + expect(help.exitCode).toBe(0); + expect(help.stdout).toContain("--ignore-health-check"); + expect(help.stderr).toBe(""); - const completionFailure = await runSupabase( - ["__complete", "--output-format=json", "start", "--"], - { - cwd: project.dir, - home: home.dir, - env: { - SUPABASE_EXPERIMENTAL_STACK: "invalid", - SUPABASE_WORKDIR: undefined, - }, - }, - ); - expect(completionFailure.exitCode).toBe(1); - expect(completionFailure.stdout).toBe(""); - expect(completionFailure.stderr).toContain("must be 0 or 1"); + const completionFailure = yield* runSupabaseEffect( + ["__complete", "--output-format=json", "start", "--"], + { + cwd: project.dir, + home: home.dir, + env: { + SUPABASE_EXPERIMENTAL_STACK: "invalid", + SUPABASE_WORKDIR: undefined, + }, + }, + ); + expect(completionFailure.exitCode).toBe(1); + expect(completionFailure.stdout).toBe(""); + expect(completionFailure.stderr).toContain("must be 0 or 1"); - const invalidEnv = await runSupabase(["start", "--output-format=json"], { - cwd: project.dir, - home: home.dir, - env: { - SUPABASE_EXPERIMENTAL_STACK: "invalid", - SUPABASE_WORKDIR: undefined, - }, - }); - expect(invalidEnv.exitCode).toBe(1); - expect(invalidEnv.stderr).toBe(""); - expect(JSON.parse(invalidEnv.stdout)).toMatchObject({ - _tag: "Error", - error: { code: "StackRoutingError" }, - }); - } finally { - await project.cleanup(); - home[Symbol.dispose](); - } - }, + const invalidEnv = yield* runSupabaseEffect(["start", "--output-format=json"], { + cwd: project.dir, + home: home.dir, + env: { + SUPABASE_EXPERIMENTAL_STACK: "invalid", + SUPABASE_WORKDIR: undefined, + }, + }); + expect(invalidEnv.exitCode).toBe(1); + expect(invalidEnv.stderr).toBe(""); + expect( + yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(invalidEnv.stdout), + ).toMatchObject({ + _tag: "Error", + error: { code: "StackRoutingError" }, + }); + }), + ), + ).pipe(Effect.provide(BunServices.layer)), + E2E_TIMEOUT_MS, ); }); diff --git a/apps/cli/src/cli/complete.integration.test.ts b/apps/cli/src/cli/complete.integration.test.ts index d829e10260..311df420e9 100644 --- a/apps/cli/src/cli/complete.integration.test.ts +++ b/apps/cli/src/cli/complete.integration.test.ts @@ -1,5 +1,6 @@ +import { BunCrypto } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; -import { describe, expect, it } from "vitest"; import { CurrentAnalyticsContext } from "../shared/telemetry/analytics-context.ts"; import { Analytics } from "../shared/telemetry/analytics.service.ts"; import { EventCommandExecuted, PropExitCode } from "../shared/telemetry/event-catalog.ts"; @@ -37,8 +38,9 @@ function makeCaptureTelemetry( analyticsLayer: Layer.Layer, ): CompleteDeps["captureTelemetry"] { return (exitCode, durationMs) => - Effect.runPromise( - captureCompleteTelemetryEffect(exitCode, durationMs).pipe(Effect.provide(analyticsLayer)), + captureCompleteTelemetryEffect(exitCode, durationMs).pipe( + Effect.provide([analyticsLayer, BunCrypto.layer]), + Effect.orDie, ); } @@ -62,43 +64,55 @@ function makeDeps(argv: ReadonlyArray, captureTelemetry: CompleteDeps["c } describe("__complete telemetry (CLI-1965 review finding)", () => { - it("fires cli_command_executed with command: __complete and exit_code: 0 for a normal completion request", async () => { - const analytics = mockAnalyticsWithContext(); - const { deps } = makeDeps( - ["__complete", "migration", "li"], - makeCaptureTelemetry(analytics.layer), - ); + it.effect( + "fires cli_command_executed with command: __complete and exit_code: 0 for a normal completion request", + () => + Effect.gen(function* () { + const analytics = mockAnalyticsWithContext(); + const { deps } = makeDeps( + ["__complete", "migration", "li"], + makeCaptureTelemetry(analytics.layer), + ); - expect(await tryComplete(deps)).toBe(true); + expect(yield* tryComplete(deps)).toBe(true); - const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); - expect(event).toBeDefined(); - expect(event?.command).toBe("__complete"); - expect(event?.properties[PropExitCode]).toBe(0); - }); + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event).toBeDefined(); + expect(event?.command).toBe("__complete"); + expect(event?.properties[PropExitCode]).toBe(0); + }), + ); - it("records exit_code: 1 for an unresolvable completion request (zero completion args)", async () => { - const analytics = mockAnalyticsWithContext(); - const { deps } = makeDeps(["__complete"], makeCaptureTelemetry(analytics.layer)); + it.effect( + "records exit_code: 1 for an unresolvable completion request (zero completion args)", + () => + Effect.gen(function* () { + const analytics = mockAnalyticsWithContext(); + const { deps } = makeDeps(["__complete"], makeCaptureTelemetry(analytics.layer)); - expect(await tryComplete(deps)).toBe(true); + expect(yield* tryComplete(deps)).toBe(true); - const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); - expect(event).toBeDefined(); - expect(event?.properties[PropExitCode]).toBe(1); - }); + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event).toBeDefined(); + expect(event?.properties[PropExitCode]).toBe(1); + }), + ); - it("records command: __complete — never __completeNoDesc — when invoked via the no-descriptions alias", async () => { - const analytics = mockAnalyticsWithContext(); - const { deps } = makeDeps( - ["__completeNoDesc", "migration", "li"], - makeCaptureTelemetry(analytics.layer), - ); + it.effect( + "records command: __complete — never __completeNoDesc — when invoked via the no-descriptions alias", + () => + Effect.gen(function* () { + const analytics = mockAnalyticsWithContext(); + const { deps } = makeDeps( + ["__completeNoDesc", "migration", "li"], + makeCaptureTelemetry(analytics.layer), + ); - await tryComplete(deps); + yield* tryComplete(deps); - const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); - expect(event?.command).toBe("__complete"); - expect(event?.command).not.toBe("__completeNoDesc"); - }); + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event?.command).toBe("__complete"); + expect(event?.command).not.toBe("__completeNoDesc"); + }), + ); }); diff --git a/apps/cli/src/cli/complete.ts b/apps/cli/src/cli/complete.ts index ed763866e2..c00c89fbe9 100644 --- a/apps/cli/src/cli/complete.ts +++ b/apps/cli/src/cli/complete.ts @@ -1,8 +1,9 @@ -import { BunServices } from "@effect/platform-bun"; -import { Cause, Effect, Layer, Option } from "effect"; +import { BunCrypto, BunServices } from "@effect/platform-bun"; +import { Cause, Clock, Crypto, DateTime, Effect, Layer, Option } from "effect"; import { FetchHttpClient } from "effect/http"; import { GlobalFlag } from "effect/cli"; import type { Command, Param, Primitive } from "effect/cli"; +import type { PlatformError } from "effect/PlatformError"; import process from "node:process"; import { QUERY_OUTPUT_FORMATS, @@ -90,7 +91,7 @@ export interface CompleteDeps { readonly stderrWrite: (message: string) => void; readonly exit: (code: number) => void; /** Fires the `cli_command_executed` telemetry capture for this request. */ - readonly captureTelemetry: (exitCode: number, durationMs: number) => Promise; + readonly captureTelemetry: (exitCode: number, durationMs: number) => Effect.Effect; } /** @@ -594,7 +595,7 @@ function isValidGoDuration(value: string): boolean { * Matches RFC 3339 (`2006-01-02T15:04:05Z07:00`): fractional seconds may use either `.` or * `,`, and the offset's own hour/minute are independently capped at 24/60 (not the 0-23/0-59 * bounds used for the time fields themselves). Calendar validity is checked by round-tripping - * through `Date#setUTCFullYear`, which — unlike the `Date` constructor — doesn't special-case + * through `DateTime.makeUnsafe`, which — unlike the `Date` constructor — doesn't special-case * a 0-99 year into 1900+year. */ const GO_RFC3339_PATTERN = @@ -611,13 +612,8 @@ function isValidGoRfc3339(value: string): boolean { if (offsetHour !== undefined && (Number(offsetHour) > 24 || Number(offsetMinute) > 60)) return false; - const roundTrip = new Date(0); - roundTrip.setUTCFullYear(y, mo - 1, d); - return ( - roundTrip.getUTCFullYear() === y && - roundTrip.getUTCMonth() === mo - 1 && - roundTrip.getUTCDate() === d - ); + const roundTrip = DateTime.toPartsUtc(DateTime.makeUnsafe({ year: y, month: mo, day: d })); + return roundTrip.year === y && roundTrip.month === mo && roundTrip.day === d; } /** @@ -1097,21 +1093,25 @@ export function formatCompletionResponse( export function captureCompleteTelemetryEffect( exitCode: number, durationMs: number, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const commandRunId = yield* crypto.randomUUIDv4; const analytics = yield* Analytics; - yield* analytics.capture(EventCommandExecuted, { - [PropExitCode]: exitCode, - [PropDurationMs]: durationMs, - [PropOutputFormat]: "text", - }); - }).pipe( - withAnalyticsContext({ - command_run_id: crypto.randomUUID(), - command: "__complete", - flags: undefined, - }), - ); + yield* analytics + .capture(EventCommandExecuted, { + [PropExitCode]: exitCode, + [PropDurationMs]: durationMs, + [PropOutputFormat]: "text", + }) + .pipe( + withAnalyticsContext({ + command_run_id: commandRunId, + command: "__complete", + flags: undefined, + }), + ); + }); } const COMPLETE_TELEMETRY_TIMEOUT = "2 seconds"; @@ -1129,34 +1129,33 @@ const completeAnalyticsLayer = analyticsLayer.pipe( Layer.provide(FetchHttpClient.layer), Layer.provide(cliConfigProviderLayer), Layer.provide(BunServices.layer), + Layer.merge(BunCrypto.layer), ); /** * Production default for `CompleteDeps.captureTelemetry`. Best-effort and bounded: a missing * consent, network hiccup, or DNS failure must never hang or fail a user's tab press. Callers - * must `await` this before exiting, since `process.exit` kills the process without waiting - * for pending async work. + * must run this to completion before exiting, since `process.exit` kills the process without + * waiting for pending async work. */ -function captureCompleteTelemetry(exitCode: number, durationMs: number): Promise { - return Effect.runPromise( - captureCompleteTelemetryEffect(exitCode, durationMs).pipe( - Effect.provide(completeAnalyticsLayer), - Effect.timeout(COMPLETE_TELEMETRY_TIMEOUT), - Effect.ignore, - ), +function captureCompleteTelemetry(exitCode: number, durationMs: number): Effect.Effect { + return captureCompleteTelemetryEffect(exitCode, durationMs).pipe( + Effect.provide(completeAnalyticsLayer), + Effect.timeout(COMPLETE_TELEMETRY_TIMEOUT), + Effect.ignore, ); } /** * Runs before Effect's CLI argv parser, returning `false` immediately when `deps.argv[0]` - * isn't a completion request, otherwise fully handling it and returning `true`. Async only - * because it awaits `deps.captureTelemetry` before `deps.exit(...)`, so the capture actually + * isn't a completion request, otherwise fully handling it and returning `true`. Runs + * `deps.captureTelemetry` to completion before `deps.exit(...)`, so the capture actually * reaches PostHog before the process exits. */ -export async function tryComplete(deps: CompleteDeps): Promise { +export const tryComplete = Effect.fn("cli.complete")(function* (deps: CompleteDeps) { if (deps.argv[0] !== "__complete" && deps.argv[0] !== "__completeNoDesc") return false; - const startedAt = Date.now(); + const startedAt = yield* Clock.currentTimeMillis; const response = respondToComplete(deps.root, deps.argv); if (response === undefined) { if (deps.routingFailure !== undefined) { @@ -1166,17 +1165,17 @@ export async function tryComplete(deps: CompleteDeps): Promise { : Cause.pretty(deps.routingFailure); deps.stderrWrite(`${message}\n`); } - await deps.captureTelemetry(1, Date.now() - startedAt); + yield* deps.captureTelemetry(1, (yield* Clock.currentTimeMillis) - startedAt); deps.exit(1); return true; } const includeDescriptions = resolveIncludeDescriptions(deps.argv[0], deps.env); deps.stdoutWrite(formatCompletionResponse(response, includeDescriptions)); - await deps.captureTelemetry(0, Date.now() - startedAt); + yield* deps.captureTelemetry(0, (yield* Clock.currentTimeMillis) - startedAt); deps.exit(0); return true; -} +}); export function defaultCompleteDeps( root?: Command.Command.Any, diff --git a/apps/cli/src/cli/complete.unit.test.ts b/apps/cli/src/cli/complete.unit.test.ts index f14b19c49a..9a19e81766 100644 --- a/apps/cli/src/cli/complete.unit.test.ts +++ b/apps/cli/src/cli/complete.unit.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; -import { Cause } from "effect"; +import { describe, expect, it, vi } from "@effect/vitest"; +import { Cause, Effect } from "effect"; import { rootCommand } from "./root.ts"; import { StackRoutingError } from "../command-internal/stack-backend.ts"; @@ -813,6 +813,26 @@ describe("respondToComplete", () => { }); }); + it("checks the calendar date for gen bearer-jwt --exp, years below 100 included", () => { + const complete = (exp: string) => + respondToComplete(rootCommand, [ + "__complete", + "gen", + "bearer-jwt", + "--role", + "anon", + "--exp", + exp, + "--p", + ]); + for (const exp of ["2024-02-30T00:00:00Z", "2023-02-29T00:00:00Z"]) { + expect(complete(exp)).toEqual({ candidates: [], directive: CompletionDirective.Default }); + } + for (const exp of ["2024-02-29T00:00:00Z", "0099-01-01T00:00:00Z"]) { + expect(complete(exp)?.candidates.map((c) => c.name)).toContain("--profile"); + } + }); + it("rejects a negative value for storage cp --jobs even though it's a string-typed flag in TS", () => { const result = respondToComplete(rootCommand, [ "__complete", @@ -1217,71 +1237,87 @@ describe("tryComplete", () => { exits.push(code); }, // No-op: telemetry capture is covered separately in complete.integration.test.ts. - captureTelemetry: async () => {}, + captureTelemetry: () => Effect.void, ...overrides, }; return { deps, stdoutWrites, stderrWrites, exits }; } - // `tryComplete` awaits `deps.captureTelemetry` before calling `deps.exit`. - it("returns false and does nothing for non-__complete argv", async () => { - const { deps, stdoutWrites, exits } = makeDeps({ argv: ["migration", "list"] }); - expect(await tryComplete(deps)).toBe(false); - expect(stdoutWrites).toEqual([]); - expect(exits).toEqual([]); - }); - - it("writes the formatted response to stdout and exits 0 for a real completion request", async () => { - const { deps, stdoutWrites, exits } = makeDeps(); - expect(await tryComplete(deps)).toBe(true); - expect(stdoutWrites).toHaveLength(1); - expect(stdoutWrites[0]).toContain("list\t"); - expect(stdoutWrites[0]).toMatch(/:4\n$/); - expect(exits).toEqual([0]); - }); - - it("respects __completeNoDesc by stripping descriptions from the written response", async () => { - const { deps, stdoutWrites } = makeDeps({ argv: ["__completeNoDesc", "migration", "li"] }); - await tryComplete(deps); - expect(stdoutWrites[0]).toBe("list\n:4\n"); - }); - - it("exits 1 and does not write anything to stdout for zero completion args", async () => { - const { deps, stdoutWrites, exits } = makeDeps({ argv: ["__complete"] }); - expect(await tryComplete(deps)).toBe(true); - expect(stdoutWrites).toEqual([]); - expect(exits).toEqual([1]); - }); - - it("writes invalid environment routing failures to stderr without emitting completion stdout", async () => { - const { deps, stdoutWrites, stderrWrites, exits } = makeDeps({ - root: undefined, - routingFailure: Cause.fail( - new StackRoutingError({ - message: "SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set", - }), - ), - }); - expect(await tryComplete(deps)).toBe(true); - expect(stdoutWrites).toEqual([]); - expect(stderrWrites).toHaveLength(1); - expect(stderrWrites[0]).toContain("SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set"); - expect(stderrWrites[0]).toContain( - "Suggestion: Set SUPABASE_EXPERIMENTAL_STACK=1 to enable stack commands, or 0 to use legacy start/stop/status.", - ); - expect(exits).toEqual([1]); - }); + /* `tryComplete` runs `deps.captureTelemetry` before calling `deps.exit`. */ + it.effect("returns false and does nothing for non-__complete argv", () => + Effect.gen(function* () { + const { deps, stdoutWrites, exits } = makeDeps({ argv: ["migration", "list"] }); + expect(yield* tryComplete(deps)).toBe(false); + expect(stdoutWrites).toEqual([]); + expect(exits).toEqual([]); + }), + ); + + it.effect( + "writes the formatted response to stdout and exits 0 for a real completion request", + () => + Effect.gen(function* () { + const { deps, stdoutWrites, exits } = makeDeps(); + expect(yield* tryComplete(deps)).toBe(true); + expect(stdoutWrites).toHaveLength(1); + expect(stdoutWrites[0]).toContain("list\t"); + expect(stdoutWrites[0]).toMatch(/:4\n$/); + expect(exits).toEqual([0]); + }), + ); + + it.effect("respects __completeNoDesc by stripping descriptions from the written response", () => + Effect.gen(function* () { + const { deps, stdoutWrites } = makeDeps({ argv: ["__completeNoDesc", "migration", "li"] }); + yield* tryComplete(deps); + expect(stdoutWrites[0]).toBe("list\n:4\n"); + }), + ); + + it.effect("exits 1 and does not write anything to stdout for zero completion args", () => + Effect.gen(function* () { + const { deps, stdoutWrites, exits } = makeDeps({ argv: ["__complete"] }); + expect(yield* tryComplete(deps)).toBe(true); + expect(stdoutWrites).toEqual([]); + expect(exits).toEqual([1]); + }), + ); + + it.effect( + "writes invalid environment routing failures to stderr without emitting completion stdout", + () => + Effect.gen(function* () { + const { deps, stdoutWrites, stderrWrites, exits } = makeDeps({ + root: undefined, + routingFailure: Cause.fail( + new StackRoutingError({ + message: "SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set", + }), + ), + }); + expect(yield* tryComplete(deps)).toBe(true); + expect(stdoutWrites).toEqual([]); + expect(stderrWrites).toHaveLength(1); + expect(stderrWrites[0]).toContain("SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set"); + expect(stderrWrites[0]).toContain( + "Suggestion: Set SUPABASE_EXPERIMENTAL_STACK=1 to enable stack commands, or 0 to use legacy start/stop/status.", + ); + expect(exits).toEqual([1]); + }), + ); - it("preserves a defect diagnostic when routing fails with a defect cause", async () => { - const { deps, stdoutWrites, stderrWrites, exits } = makeDeps({ - root: undefined, - routingFailure: Cause.die(new Error("completion routing defect")), - }); - expect(await tryComplete(deps)).toBe(true); - expect(stdoutWrites).toEqual([]); - expect(stderrWrites[0]).toContain("completion routing defect"); - expect(exits).toEqual([1]); - }); + it.effect("preserves a defect diagnostic when routing fails with a defect cause", () => + Effect.gen(function* () { + const { deps, stdoutWrites, stderrWrites, exits } = makeDeps({ + root: undefined, + routingFailure: Cause.die(new Error("completion routing defect")), + }); + expect(yield* tryComplete(deps)).toBe(true); + expect(stdoutWrites).toEqual([]); + expect(stderrWrites[0]).toContain("completion routing defect"); + expect(exits).toEqual([1]); + }), + ); }); describe("defaultCompleteDeps", () => { diff --git a/apps/cli/src/cli/main.e2e.test.ts b/apps/cli/src/cli/main.e2e.test.ts index e78b29f02c..172d71114d 100644 --- a/apps/cli/src/cli/main.e2e.test.ts +++ b/apps/cli/src/cli/main.e2e.test.ts @@ -1,26 +1,26 @@ -import { describe, expect, test } from "vitest"; -import { makeTempCliProject, makeTempHome, runSupabase } from "../../tests/helpers/cli.ts"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { runSupabaseEffect, withTempCliProject, withTempHome } from "../../tests/helpers/cli.ts"; describe("CLI feature routing", () => { - test("reports invalid stack feature configuration during completion", async () => { - const project = await makeTempCliProject("supabase-main-routing-e2e-"); - const home = makeTempHome(); - try { - const result = await runSupabase(["__complete", "start", "--"], { - cwd: project.dir, - home: home.dir, - env: { - SUPABASE_EXPERIMENTAL_STACK: "yes", - SUPABASE_WORKDIR: undefined, - }, - }); + it.live("reports invalid stack feature configuration during completion", () => + withTempHome((home) => + withTempCliProject("supabase-main-routing-e2e-", (project) => + Effect.gen(function* () { + const result = yield* runSupabaseEffect(["__complete", "start", "--"], { + cwd: project.dir, + home: home.dir, + env: { + SUPABASE_EXPERIMENTAL_STACK: "yes", + SUPABASE_WORKDIR: undefined, + }, + }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toContain("SUPABASE_EXPERIMENTAL_STACK must be 0 or 1"); - } finally { - await project.cleanup(); - home[Symbol.dispose](); - } - }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("SUPABASE_EXPERIMENTAL_STACK must be 0 or 1"); + }), + ), + ), + ); }); diff --git a/apps/cli/src/cli/main.ts b/apps/cli/src/cli/main.ts index ee7f1c8796..c22a5386db 100644 --- a/apps/cli/src/cli/main.ts +++ b/apps/cli/src/cli/main.ts @@ -37,8 +37,10 @@ const { rootCommand: selectedRoot, agentDefaultOutputFormat } = cliEntrypointFor const selectionCause = Exit.isFailure(selectionExit) ? selectionExit.cause : undefined; if ( - !(await tryComplete( - defaultCompleteDeps(Exit.isSuccess(selectionExit) ? selectedRoot : undefined, selectionCause), + !(await Effect.runPromise( + tryComplete( + defaultCompleteDeps(Exit.isSuccess(selectionExit) ? selectedRoot : undefined, selectionCause), + ), )) ) { await Effect.runPromise( diff --git a/apps/cli/src/cli/root-stack-aliases.integration.test.ts b/apps/cli/src/cli/root-stack-aliases.integration.test.ts index cbcefd0954..a6b9e685b2 100644 --- a/apps/cli/src/cli/root-stack-aliases.integration.test.ts +++ b/apps/cli/src/cli/root-stack-aliases.integration.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "@effect/vitest"; +import { describe, expect, it } from "@effect/vitest"; import { Console, Effect, Layer, Option } from "effect"; import { CliOutput, Command } from "effect/cli"; import { emptyEnv, fakeConsole, mockOutput } from "../../tests/helpers/mocks.ts"; @@ -33,46 +33,46 @@ const layerFor = (args: ReadonlyArray, console: Console.Console) => // `start`/`status`/`stop` commands (root.ts) — their `--help` text must describe the // top-level command, not leak the `stack ` invocation it is built from. describe("stack backend top-level alias help text", () => { - test("`start --help` describes the top-level command, not `stack start`", async () => { - const { console, calls } = fakeConsole(); - await Effect.runPromise( - Effect.scoped( + it.effect("`start --help` describes the top-level command, not `stack start`", () => + Effect.gen(function* () { + const { console, calls } = fakeConsole(); + yield* Effect.scoped( Command.runWith(stackStartAliasCommand, { version: "0.0.0-test" })(["--help"]).pipe( Effect.provide(layerFor(["--help"], console)), ), - ), - ); - const text = calls.join("\n"); - expect(text).toContain("supabase start"); - expect(text).not.toContain("stack start"); - expect(text).not.toContain("Start a managed local stack"); - }); + ); + const text = calls.join("\n"); + expect(text).toContain("supabase start"); + expect(text).not.toContain("stack start"); + expect(text).not.toContain("Start a managed local stack"); + }), + ); - test("`status --help` describes the top-level command, not `stack status`", async () => { - const { console, calls } = fakeConsole(); - await Effect.runPromise( - Effect.scoped( + it.effect("`status --help` describes the top-level command, not `stack status`", () => + Effect.gen(function* () { + const { console, calls } = fakeConsole(); + yield* Effect.scoped( Command.runWith(stackStatusAliasCommand, { version: "0.0.0-test" })(["--help"]).pipe( Effect.provide(layerFor(["--help"], console)), ), - ), - ); - const text = calls.join("\n"); - expect(text).toContain("supabase status"); - expect(text).not.toContain("stack status"); - }); + ); + const text = calls.join("\n"); + expect(text).toContain("supabase status"); + expect(text).not.toContain("stack status"); + }), + ); - test("`stop --help` describes the top-level command, not `stack stop`", async () => { - const { console, calls } = fakeConsole(); - await Effect.runPromise( - Effect.scoped( + it.effect("`stop --help` describes the top-level command, not `stack stop`", () => + Effect.gen(function* () { + const { console, calls } = fakeConsole(); + yield* Effect.scoped( Command.runWith(stackStopAliasCommand, { version: "0.0.0-test" })(["--help"]).pipe( Effect.provide(layerFor(["--help"], console)), ), - ), - ); - const text = calls.join("\n"); - expect(text).toContain("supabase stop"); - expect(text).not.toContain("stack stop"); - }); + ); + const text = calls.join("\n"); + expect(text).toContain("supabase stop"); + expect(text).not.toContain("stack stop"); + }), + ); }); diff --git a/apps/cli/tests/helpers/cli.ts b/apps/cli/tests/helpers/cli.ts index 7764ac1bbc..e02bdb2b14 100644 --- a/apps/cli/tests/helpers/cli.ts +++ b/apps/cli/tests/helpers/cli.ts @@ -173,6 +173,35 @@ export const withTempHome = ( }), ); +/** The run's owned temp CLI project could not be created or removed. */ +export class TempCliProjectError extends Data.TaggedError("TempCliProjectError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +/** + * Runs `use` with an owned temp CLI project from `makeTempCliProject`, which keeps its stack + * cleanup registration. Setup and removal failures both stay in the typed channel. + */ +export const withTempCliProject = ( + prefix: string, + use: (project: Awaited>) => Effect.Effect, +): Effect.Effect => + Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => makeTempCliProject(prefix), + catch: (cause) => + new TempCliProjectError({ message: "temp CLI project setup failed", cause }), + }), + use, + (project) => + Effect.tryPromise({ + try: () => project.cleanup(), + catch: (cause) => + new TempCliProjectError({ message: "temp CLI project cleanup failed", cause }), + }), + ); + /** * Concurrent e2e workers each keep only ports in their own slot, so a port one worker picked and * released before Docker binds it is never handed to another worker. Slots skip the low bit From 7ac16479327dc8a33c2aa1ad03b360ee8a847e8b Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:11:27 +0000 Subject: [PATCH 5/7] refactor(cli): cover the `docs` and `output` areas with effect lint (CLI-2742) (#7218) ## TL;DR brings the `docs` and `output` areas under the effect lint, with the top level `main.ts` and `globals.d.ts` ## whats introduced? - `docs/**`, `output/**`, `main.ts` and `globals.d.ts` join the allow list, and `output` and the top level files needed no changes - `readDocsContent` walks the docs through the `FileSystem` and `Path` services with a named span, keeps a leading BOM, and wraps YAML and shape failures in a typed `DocsExamplesError` that keeps the original error - the generator script rethrows the original fs, YAML or shape error, so the generated spec, error messages and exit codes stay the same, but an fs failure such as a missing `docs/supabase` or `templates/examples.yaml` no longer prints a source code frame or stack, since async `FileSystem` errors carry none - the walk matches develop on any tree that is not changing during the walk, except a directory `stat` cannot reach, such as one inside a readable but not searchable directory or past the path length limit, which is now skipped, or fails on its read when named `.md`, since the `FileSystem` service has no dirent types - the docs tests move to a lazy loader, scoped temp dirs and `Effect.flip` checks on the same messages ## ref - closes: CLI-2742 --- .oxlintrc.effect.json | 4 + apps/cli/scripts/generate-docs-spec.ts | 9 +- apps/cli/src/docs/docs-spec.content.ts | 81 ++++-- apps/cli/src/docs/docs-spec.unit.test.ts | 248 +++++++++++------- .../generate-docs-spec.script.unit.test.ts | 4 +- .../telemetry/__fixtures__/error-tags.txt | 1 + 6 files changed, 237 insertions(+), 110 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 4e117c6d1d..97dde045f2 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -11,6 +11,10 @@ "!apps/cli/src/cli/**", "!apps/cli/src/commands/**", "!apps/cli/src/config/**", + "!apps/cli/src/docs/**", + "!apps/cli/src/globals.d.ts", + "!apps/cli/src/main.ts", + "!apps/cli/src/output/**", "!apps/cli/src/shared/**", "!apps/cli/src/telemetry/**", // Last match wins across the whole list: keep bare re-exclusions after every diff --git a/apps/cli/scripts/generate-docs-spec.ts b/apps/cli/scripts/generate-docs-spec.ts index 54ec3e8112..83530c0455 100644 --- a/apps/cli/scripts/generate-docs-spec.ts +++ b/apps/cli/scripts/generate-docs-spec.ts @@ -7,6 +7,8 @@ * The version defaults to `latest` (a leading `v` is stripped); the workspace `package.json` * version is a semantic-release placeholder and is never used here. */ +import { BunServices } from "@effect/platform-bun"; +import { Effect } from "effect"; import path from "node:path"; import process from "node:process"; import { readDocsContent } from "../src/docs/docs-spec.content.ts"; @@ -19,7 +21,12 @@ function resolveVersion(): string { return argument.startsWith("v") ? argument.slice(1) : argument; } -const content = readDocsContent(path.resolve(import.meta.dir, "../docs")); +const content = await Effect.runPromise( + readDocsContent(path.resolve(import.meta.dir, "../docs")).pipe( + Effect.provide(BunServices.layer), + Effect.mapError((error) => error.cause ?? error), + ), +); const spec = buildDocsSpec({ root: rootCommand, diff --git a/apps/cli/src/docs/docs-spec.content.ts b/apps/cli/src/docs/docs-spec.content.ts index b655cc1ea3..2581a5331c 100644 --- a/apps/cli/src/docs/docs-spec.content.ts +++ b/apps/cli/src/docs/docs-spec.content.ts @@ -1,6 +1,11 @@ -import { readdirSync, readFileSync } from "node:fs"; -import path from "node:path"; +import { Effect, FileSystem, Path, Schema } from "effect"; +import type { PlatformError } from "effect/PlatformError"; import { parse } from "yaml"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../shared/telemetry/error-actionability.ts"; import type { DocsExample } from "./docs-spec.ts"; /** @@ -14,23 +19,69 @@ export interface DocsContent { readonly examples: Readonly>>; } -export function readDocsContent(docsDir: string): DocsContent { +/** Fails `readDocsContent` when `templates/examples.yaml` is not valid YAML or not the expected shape. */ +export class DocsExamplesError extends Schema.TaggedError()( + "DocsExamplesError", + { message: Schema.String, cause: Schema.Defect() }, +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +export const readDocsContent = Effect.fn("docs.readDocsContent")(function* ( + docsDir: string, +): Effect.fn.Return< + DocsContent, + PlatformError | DocsExamplesError, + FileSystem.FileSystem | Path.Path +> { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + /* Keeps a leading BOM, which `readFileString` strips. */ + const readUtf8 = (file: string) => + Effect.map(fs.readFile(file), (bytes) => + new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes), + ); + /* Never walks a symlink, even one `readLink` is denied on (a mode-000 link on macOS); an entry + `readLink` or `stat` cannot reach counts as a file, so a `.md` one still fails on its read. */ + const isWalkableDirectory = Effect.fnUntraced(function* (entryPath: string) { + const isSymlinkOrDenied = yield* fs.readLink(entryPath).pipe( + Effect.catchReason("PlatformError", "PermissionDenied", () => Effect.void), + Effect.isSuccess, + ); + if (isSymlinkOrDenied) return false; + return yield* Effect.match(fs.stat(entryPath), { + onFailure: () => false, + onSuccess: (info) => info.type === "Directory", + }); + }); const overlays = new Map(); - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) walk(entryPath); - else if (entry.name.endsWith(".md")) { - const key = path.relative(docsDir, entryPath).split(path.sep).join("/"); - overlays.set(key, readFileSync(entryPath, "utf8")); + const walk = (dir: string): Effect.Effect => + Effect.gen(function* () { + for (const name of yield* fs.readDirectory(dir)) { + const entryPath = path.join(dir, name); + if (yield* isWalkableDirectory(entryPath)) yield* walk(entryPath); + else if (name.endsWith(".md")) { + const key = path.relative(docsDir, entryPath).split(path.sep).join("/"); + overlays.set(key, yield* readUtf8(entryPath)); + } } - } - }; - walk(path.join(docsDir, "supabase")); + }); + yield* walk(path.join(docsDir, "supabase")); const examplesPath = path.join(docsDir, "templates/examples.yaml"); - return { overlays, examples: parseExamples(parse(readFileSync(examplesPath, "utf8"))) }; -} + const text = yield* readUtf8(examplesPath); + const examples = yield* Effect.try({ + try: () => parseExamples(parse(text)), + catch: (cause) => + new DocsExamplesError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + return { overlays, examples }; +}); /** * Narrows the parsed `examples.yaml` document to its expected shape — a mapping of doc id to diff --git a/apps/cli/src/docs/docs-spec.unit.test.ts b/apps/cli/src/docs/docs-spec.unit.test.ts index de61aa1041..28f189d037 100644 --- a/apps/cli/src/docs/docs-spec.unit.test.ts +++ b/apps/cli/src/docs/docs-spec.unit.test.ts @@ -1,13 +1,13 @@ -import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { beforeAll, describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; import { Command, Flag } from "effect/cli"; -import { describe, expect, it } from "vitest"; +import type { PlatformError } from "effect/PlatformError"; import { ExperimentalFlag } from "../command-internal/global-flags.ts"; import { rootCommand } from "../cli/root.ts"; -import { readDocsContent } from "./docs-spec.content.ts"; +import { DocsExamplesError, readDocsContent, type DocsContent } from "./docs-spec.content.ts"; import { buildDocsSpec, docsOverlayPath, @@ -18,17 +18,27 @@ import type { DocsCommand } from "./docs-spec.ts"; import { DOCS_EXCLUDED, DOCS_EXPERIMENTAL } from "./docs-spec.tables.ts"; interface BuiltSpecFixture { - readonly content: ReturnType; + readonly content: DocsContent; readonly spec: ReturnType; readonly byId: ReadonlyMap; } let builtSpecCache: BuiltSpecFixture | undefined; +let docsContentCache: DocsContent | undefined; + +const loadDocsContent = Effect.gen(function* () { + if (docsContentCache === undefined) { + const path = yield* Path.Path; + docsContentCache = yield* readDocsContent(path.resolve(import.meta.dirname, "../../docs")); + } + return docsContentCache; +}).pipe(Effect.provide(BunServices.layer)); /** Lazily built once, so runs filtered to the pure helpers skip the tree walk and disk reads. */ function builtSpec(): BuiltSpecFixture { if (builtSpecCache === undefined) { - const content = readDocsContent(path.resolve(import.meta.dirname, "../../docs")); + const content = docsContentCache; + if (content === undefined) throw new Error("builtSpec() needs loadDocsContent to run first"); const spec = buildDocsSpec({ root: rootCommand, version: "1.2.3", @@ -77,6 +87,8 @@ describe("docsStripOverlayHeading", () => { }); describe("buildDocsSpec", () => { + beforeAll(() => Effect.runPromise(Effect.asVoid(loadDocsContent))); + it("never publishes an unlisted command subtree", () => { // Walks the tree rather than matching a `supabase-experimental*` prefix, so it also // catches any other unlisted subtree, present or future. @@ -188,6 +200,10 @@ describe("buildDocsSpec", () => { expect(link?.description).toBe( docsStripOverlayHeading(content.overlays.get("supabase/link.md") ?? ""), ); + expect(content.overlays.has("supabase/db/diff.md")).toBe(true); + expect(byId.get("supabase-db-diff")?.description).toBe( + docsStripOverlayHeading(content.overlays.get("supabase/db/diff.md") ?? ""), + ); }); it("injects the required --experimental flag into experimental leaves only", () => { @@ -329,6 +345,8 @@ describe("review fixes stay fixed", () => { expect(yaml).not.toMatch(/&a\d+/); expect(yaml).not.toMatch(/\*a\d+/); }); + + beforeAll(() => Effect.runPromise(Effect.asVoid(loadDocsContent))); }); function byIdDescription(id: string): string { @@ -377,97 +395,143 @@ describe("build guards fail loudly", () => { ); }); - it("throws for orphaned overlays and examples keys", () => { - const root = Command.make("supabase").pipe( - Command.withSubcommands([Command.make("link")]), - Command.withGlobalFlags([ExperimentalFlag]), - ); - const overlays = new Map([["supabase/lonk.md", "## x\n\nBody.\n"]]); - const build = () => - buildDocsSpec({ - root, - version: "0", - overlays, - examples: { "supabase-lonk": [{ id: "a", code: "x" }] }, - }); - // The synthetic tree fails table validation before it reaches content validation; the - // content guard is asserted separately below against the real tree. - expect(build).toThrow(/stale static-table entries/); - const { content } = builtSpec(); - const badExamples = { ...content.examples, "supabase-not-a-command": [{ id: "a" }] }; - expect(() => - buildDocsSpec({ - root: rootCommand, - version: "0", - overlays: content.overlays, - examples: badExamples, - }), - ).toThrow(/content inputs match no command[^]*supabase-not-a-command/); - const badOverlays = new Map(content.overlays); - badOverlays.set("supabase/not-a-real-command.md", "## x\n\nBody.\n"); - expect(() => - buildDocsSpec({ - root: rootCommand, - version: "0", - overlays: badOverlays, - examples: content.examples, - }), - ).toThrow(/content inputs match no command[^]*overlay: "supabase\/not-a-real-command\.md"/); - }); + it.effect("throws for orphaned overlays and examples keys", () => + Effect.gen(function* () { + yield* loadDocsContent; + const root = Command.make("supabase").pipe( + Command.withSubcommands([Command.make("link")]), + Command.withGlobalFlags([ExperimentalFlag]), + ); + const overlays = new Map([["supabase/lonk.md", "## x\n\nBody.\n"]]); + const build = () => + buildDocsSpec({ + root, + version: "0", + overlays, + examples: { "supabase-lonk": [{ id: "a", code: "x" }] }, + }); + // The synthetic tree fails table validation before it reaches content validation; the + // content guard is asserted separately below against the real tree. + expect(build).toThrow(/stale static-table entries/); + const { content } = builtSpec(); + const badExamples = { ...content.examples, "supabase-not-a-command": [{ id: "a" }] }; + expect(() => + buildDocsSpec({ + root: rootCommand, + version: "0", + overlays: content.overlays, + examples: badExamples, + }), + ).toThrow(/content inputs match no command[^]*supabase-not-a-command/); + const badOverlays = new Map(content.overlays); + badOverlays.set("supabase/not-a-real-command.md", "## x\n\nBody.\n"); + expect(() => + buildDocsSpec({ + root: rootCommand, + version: "0", + overlays: badOverlays, + examples: content.examples, + }), + ).toThrow(/content inputs match no command[^]*overlay: "supabase\/not-a-real-command\.md"/); + }), + ); }); describe("readDocsContent rejects malformed examples.yaml", () => { - function withTempDocs(examplesYaml: string): () => void { - const dir = mkdtempSync(path.join(tmpdir(), "docs-spec-test-")); - mkdirSync(path.join(dir, "supabase"), { recursive: true }); - mkdirSync(path.join(dir, "templates"), { recursive: true }); - writeFileSync(path.join(dir, "supabase", "link.md"), "## supabase-link\n\nBody.\n"); - writeFileSync(path.join(dir, "templates", "examples.yaml"), examplesYaml); - return () => { - try { - readDocsContent(dir); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }; - } - - it("rejects a non-mapping document", () => { - expect(withTempDocs("- just\n- a list\n")).toThrow(/must be a mapping of doc ids/); - }); - - it("rejects a nested-array entry (mis-indented list)", () => { - expect(withTempDocs("supabase-link:\n - - id: oops\n")).toThrow(/must be a mapping/); - }); - - it("rejects unknown fields (typo'd keys)", () => { - expect(withTempDocs("supabase-link:\n - titel: typo\n code: x\n")).toThrow( - /unknown field "titel"/, - ); - }); + const readTempDocsError = (examplesYaml: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "docs-spec-test-" }); + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.makeDirectory(path.join(dir, "templates"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "link.md"), + "## supabase-link\n\nBody.\n", + ); + yield* fs.writeFileString(path.join(dir, "templates", "examples.yaml"), examplesYaml); + const error = yield* Effect.flip(readDocsContent(dir)); + expect(error).toBeInstanceOf(DocsExamplesError); + expect(error.cause).toBeInstanceOf(Error); + return error; + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + + it.effect("rejects a non-mapping document", () => + Effect.map(readTempDocsError("- just\n- a list\n"), (error) => + expect(error.message).toMatch(/must be a mapping of doc ids/), + ), + ); + + it.effect("rejects a nested-array entry (mis-indented list)", () => + Effect.map(readTempDocsError("supabase-link:\n - - id: oops\n"), (error) => + expect(error.message).toMatch(/must be a mapping/), + ), + ); + + it.effect("rejects unknown fields (typo'd keys)", () => + Effect.map(readTempDocsError("supabase-link:\n - titel: typo\n code: x\n"), (error) => + expect(error.message).toMatch(/unknown field "titel"/), + ), + ); + + it.effect("rejects non-string field values", () => + Effect.map(readTempDocsError("supabase-link:\n - id: 5\n"), (error) => + expect(error.message).toMatch(/id must be a string/), + ), + ); +}); - it("rejects non-string field values", () => { - expect(withTempDocs("supabase-link:\n - id: 5\n")).toThrow(/id must be a string/); - }); +describe("readDocsContent walk", () => { + it.effect("does not walk symlinked directories or link loops", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "docs-spec-test-" }); + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "docs-spec-test-" }); + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.makeDirectory(path.join(dir, "templates"), { recursive: true }); + yield* fs.writeFileString(path.join(dir, "supabase", "link.md"), "## supabase-link\n"); + yield* fs.writeFileString(path.join(outside, "outside.md"), "## outside\n"); + yield* fs.symlink(outside, path.join(dir, "supabase", "linked")); + yield* fs.symlink("..", path.join(dir, "supabase", "up")); + yield* fs.writeFileString(path.join(dir, "templates", "examples.yaml"), "{}\n"); + const content = yield* readDocsContent(dir); + expect([...content.overlays.keys()]).toEqual(["supabase/link.md"]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); }); describe("DOCS_EXPERIMENTAL mirrors the runtime gate", () => { - it("matches the requireExperimental call sites exactly", () => { - const commandsDir = path.resolve(import.meta.dirname, "../commands"); - const gated = new Set(); - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) walk(entryPath); - else if (entry.name.endsWith(".command.ts")) { - if (readFileSync(entryPath, "utf8").includes("yield* requireExperimental")) { - const relative = path.relative(commandsDir, path.dirname(entryPath)); - gated.add(`supabase-${relative.split(path.sep).join("-")}`); + it.effect("matches the requireExperimental call sites exactly", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const commandsDir = path.resolve(import.meta.dirname, "../commands"); + const gated = new Set(); + const isSymlinkOrDenied = (entryPath: string) => + fs.readLink(entryPath).pipe( + Effect.catchReason("PlatformError", "PermissionDenied", () => Effect.void), + Effect.isSuccess, + ); + const walk = (dir: string): Effect.Effect => + Effect.gen(function* () { + for (const name of yield* fs.readDirectory(dir)) { + const entryPath = path.join(dir, name); + if ( + !(yield* isSymlinkOrDenied(entryPath)) && + (yield* fs.stat(entryPath)).type === "Directory" + ) + yield* walk(entryPath); + else if (name.endsWith(".command.ts")) { + if ((yield* fs.readFileString(entryPath)).includes("yield* requireExperimental")) { + const relative = path.relative(commandsDir, path.dirname(entryPath)); + gated.add(`supabase-${relative.split(path.sep).join("-")}`); + } + } } - } - } - }; - walk(commandsDir); - expect([...gated].sort()).toEqual([...DOCS_EXPERIMENTAL].sort()); - }); + }); + yield* walk(commandsDir); + expect([...gated].sort()).toEqual([...DOCS_EXPERIMENTAL].sort()); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/docs/generate-docs-spec.script.unit.test.ts b/apps/cli/src/docs/generate-docs-spec.script.unit.test.ts index fec21495e2..2915e87c43 100644 --- a/apps/cli/src/docs/generate-docs-spec.script.unit.test.ts +++ b/apps/cli/src/docs/generate-docs-spec.script.unit.test.ts @@ -1,8 +1,8 @@ -import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { parse } from "yaml"; -const cliRoot = path.resolve(import.meta.dirname, "../.."); +const cliRoot = fileURLToPath(new URL("../..", import.meta.url)); /** * Subprocess-level coverage of the `scripts/generate-docs-spec.ts` entrypoint (see diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 64adab686b..d9dbd491c6 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -226,6 +226,7 @@ DockerRemoveAllNetworkPruneError DockerRemoveAllStopError DockerRemoveAllVolumePruneError DockerRunError +DocsExamplesError DomainsCnameError DomainsNetworkError DomainsUnexpectedStatusError From 627362077466705c9317a84207f98ac73c70d954 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:35:06 +0000 Subject: [PATCH 6/7] chore(repo): fix the develop-into-next merge --- apps/cli/src/config/cli-config-flags.unit.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/config/cli-config-flags.unit.test.ts b/apps/cli/src/config/cli-config-flags.unit.test.ts index c4a919a888..2bfe0a9f0a 100644 --- a/apps/cli/src/config/cli-config-flags.unit.test.ts +++ b/apps/cli/src/config/cli-config-flags.unit.test.ts @@ -235,10 +235,8 @@ describe("flag typing", () => { it("leaves CliConfigFlagInputs required until withCliConfigFlags provides it", () => { // @ts-expect-error -- the handler still requires CliConfigFlagInputs - requiresNothing(readInputs); - requiresNothing(readInputs.pipe(withCliConfigFlags(config))); - - expect(true).toBe(true); + expect(requiresNothing(readInputs)).toBeDefined(); + expect(requiresNothing(readInputs.pipe(withCliConfigFlags(config)))).toBeDefined(); }); }); From 76fc6bb185ab55ebef117b9b116fd947fc74caa7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:39:41 +0000 Subject: [PATCH 7/7] chore(repo): repair the develop-into-next sync --- .../src/config/cli-config-codec-schemas.ts | 13 ++++++------ .../config/cli-config-contract.unit.test.ts | 6 +----- .../src/config/cli-config-flags.unit.test.ts | 21 +++++++++++++------ .../src/config/cli-config-key.unit.test.ts | 6 +----- .../src/config/cli-config-keys.unit.test.ts | 4 +++- .../cli-config-values.integration.test.ts | 9 ++++++-- .../telemetry/command-telemetry.unit.test.ts | 16 +++++++------- 7 files changed, 43 insertions(+), 32 deletions(-) diff --git a/apps/cli/src/config/cli-config-codec-schemas.ts b/apps/cli/src/config/cli-config-codec-schemas.ts index 259f0e7c8e..2b4d7908a3 100644 --- a/apps/cli/src/config/cli-config-codec-schemas.ts +++ b/apps/cli/src/config/cli-config-codec-schemas.ts @@ -34,15 +34,15 @@ const parsePort = (raw: string) => const integerFromString = (parse: (raw: string) => number | undefined) => Schema.String.pipe( - Schema.decodeTo(Schema.Number, { + Schema.decodeTo(Schema.Finite, { decode: parsed(parse), encode: SchemaGetter.transform((value: number) => String(value)), }), ); const integerFromNumber = (max: number) => - Schema.Number.pipe( - Schema.decodeTo(Schema.Number, { + Schema.Finite.pipe( + Schema.decodeTo(Schema.Finite, { decode: parsed(integerUpTo(max)), encode: SchemaGetter.passthrough(), }), @@ -66,10 +66,11 @@ const boolFromString = Schema.String.pipe( }), ); -const boolFromNumber = Schema.Number.pipe( +/** Any number, `NaN` and the infinities included, is true unless it is zero. */ +const boolFromNumber = Schema.Unknown.pipe( Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value: number) => value !== 0), - encode: SchemaGetter.transform((value: boolean) => (value ? 1 : 0)), + decode: parsed((value: unknown) => (typeof value === "number" ? value !== 0 : undefined)), + encode: SchemaGetter.transform((value: boolean): unknown => (value ? 1 : 0)), }), ); diff --git a/apps/cli/src/config/cli-config-contract.unit.test.ts b/apps/cli/src/config/cli-config-contract.unit.test.ts index 14a73f092d..742375f0e1 100644 --- a/apps/cli/src/config/cli-config-contract.unit.test.ts +++ b/apps/cli/src/config/cli-config-contract.unit.test.ts @@ -17,11 +17,7 @@ import { pickCliConfigKey, type CliConfigSources } from "./cli-config-key.ts"; import { CliConfigKeys, cliConfigFamilyKey, cliConfigRegistry } from "./cli-config-keys.ts"; import type { AnyCliConfigKey } from "./cli-config-keys.ts"; -const pathService = Effect.runSync( - Effect.gen(function* () { - return yield* Path.Path; - }).pipe(Effect.provide(Path.layer)), -); +const pathService = Effect.runSync(Effect.provide(Path.Path, Path.layer)); const CIPHERTEXT = "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; diff --git a/apps/cli/src/config/cli-config-flags.unit.test.ts b/apps/cli/src/config/cli-config-flags.unit.test.ts index 2bfe0a9f0a..c048c4ccff 100644 --- a/apps/cli/src/config/cli-config-flags.unit.test.ts +++ b/apps/cli/src/config/cli-config-flags.unit.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Console, Context, Effect, Layer, Option } from "effect"; +import { Console, Context, Effect, Layer, Option, Schema } from "effect"; import { CliOutput, Command, Flag } from "effect/cli"; import { fakeConsole } from "../../tests/helpers/mocks.ts"; @@ -17,6 +17,9 @@ import { import { portCodec, requiredCliConfigKey } from "./cli-config-key.ts"; import { CliConfigKeys } from "./cli-config-keys.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + const config = { noSeed: CliConfigKeys.db.seed.enabled.flag({ name: "no-seed", @@ -131,7 +134,7 @@ describe("key.flag", () => { const error = yield* run(["--proxy-port", "70000"]).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "ShowHelp", errors: [{ _tag: "InvalidValue" }] }); - expect(JSON.stringify(error)).toContain("70000"); + expect(yield* encodeJson(error)).toContain("70000"); }), ); @@ -259,10 +262,16 @@ describe("config-bound flags in machine-readable help", () => { ), ); - const help = JSON.parse(calls[0]?.replace(/^log:/, "") ?? "{}"); - const sqlPaths = help.doc.flags.find((flag: { name: string }) => flag.name === "sql-paths"); - expect(help.doc).not.toHaveProperty("annotations"); - expect(sqlPaths).toMatchObject({ required: false }); + const help = yield* decodeJson(calls[0]?.replace(/^log:/, "") ?? "{}"); + expect(help).toHaveProperty("doc.flags"); + expect(help).not.toHaveProperty("doc.annotations"); + expect(help).toMatchObject({ + doc: { + flags: expect.arrayContaining([ + expect.objectContaining({ name: "sql-paths", required: false }), + ]), + }, + }); }).pipe(Effect.scoped), ); }); diff --git a/apps/cli/src/config/cli-config-key.unit.test.ts b/apps/cli/src/config/cli-config-key.unit.test.ts index 3ee4ffd240..ee77e3a540 100644 --- a/apps/cli/src/config/cli-config-key.unit.test.ts +++ b/apps/cli/src/config/cli-config-key.unit.test.ts @@ -20,11 +20,7 @@ import { import { CLI_CONFIG_FAMILIES } from "./cli-config-key-annotations.ts"; import { cliConfigFamilyKey, cliConfigRegistry, type AnyCliConfigKey } from "./cli-config-keys.ts"; -const pathService = Effect.runSync( - Effect.gen(function* () { - return yield* Path.Path; - }).pipe(Effect.provide(Path.layer)), -); +const pathService = Effect.runSync(Effect.provide(Path.Path, Path.layer)); interface SourceParts { readonly flags?: Readonly>; diff --git a/apps/cli/src/config/cli-config-keys.unit.test.ts b/apps/cli/src/config/cli-config-keys.unit.test.ts index 5fb20903bf..dbf451e0fc 100644 --- a/apps/cli/src/config/cli-config-keys.unit.test.ts +++ b/apps/cli/src/config/cli-config-keys.unit.test.ts @@ -1,4 +1,6 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- the source scan runs synchronously at module load, before any test provides services. import { readdirSync, readFileSync } from "node:fs"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- see above. import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -222,7 +224,7 @@ describe("config key registry", () => { it.effect("defaults every key to what the schema decodes from an empty document", () => Effect.gen(function* () { const path = yield* Path.Path; - const decoded = Schema.decodeUnknownSync(CliConfigSchema)({}); + const decoded = yield* Schema.decodeEffect(CliConfigSchema)({}); const context = { workdir: "/work/proj", projectRef: Option.none(), diff --git a/apps/cli/src/config/cli-config-values.integration.test.ts b/apps/cli/src/config/cli-config-values.integration.test.ts index ff08c4c965..2763a6a812 100644 --- a/apps/cli/src/config/cli-config-values.integration.test.ts +++ b/apps/cli/src/config/cli-config-values.integration.test.ts @@ -2,7 +2,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import type { CliConfig } from "@supabase/config"; import { loadCliConfig } from "@supabase/config/internal"; -import { ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path, Schema } from "effect"; import { withConfigEnv } from "../../tests/helpers/command-mocks.ts"; import { definedEnv } from "../../tests/helpers/config-env-pins.ts"; @@ -38,6 +38,8 @@ const CIPHERTEXT = "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; const PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + const familyKey = (id: CliConfigFamilyId, name: string, field: string) => { const family = CLI_CONFIG_FAMILIES.find((candidate) => candidate.id === id); const key = family === undefined ? undefined : cliConfigFamilyKey(family, name, field); @@ -901,7 +903,10 @@ describe("CliConfigValues reads", () => { typeof node === "object" && node !== null ? Reflect.get(node, segment) : undefined, resolvedConfig.materialized.config, ); - if (JSON.stringify(resolved) !== JSON.stringify(materialized)) mismatches.push(key.path); + // Wrapped so an absent value encodes as `{}` rather than failing to encode. + const resolvedJson = yield* encodeJson({ value: resolved }); + const materializedJson = yield* encodeJson({ value: materialized }); + if (resolvedJson !== materializedJson) mismatches.push(key.path); } expect(compared).toBeGreaterThan(100); diff --git a/apps/cli/src/telemetry/command-telemetry.unit.test.ts b/apps/cli/src/telemetry/command-telemetry.unit.test.ts index be23f349c3..840237a252 100644 --- a/apps/cli/src/telemetry/command-telemetry.unit.test.ts +++ b/apps/cli/src/telemetry/command-telemetry.unit.test.ts @@ -1759,15 +1759,17 @@ describe("withCommandTelemetry", () => { ran = true; }).pipe( withCommandTelemetry(), - Effect.provide(Layer.succeed(CreateTicketFlag, true)), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(args) })), Effect.provide( - commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + Layer.mergeAll( + Layer.succeed(CreateTicketFlag, true), + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(args) }), + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + telemetryState.layer, + ), ), - Effect.provide(telemetryState.layer), Effect.exit, ); return { run, analytics, telemetryState, didRun: () => ran };