From 6d0d97ec62b95aaffc63d96fd030332d0835473e Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:54:35 +0530 Subject: [PATCH 1/3] refactor(cli): cover the `upgrade`, `flags` and `filesystem` modules with effect lint (CLI-2648) --- .oxlintrc.effect.json | 42 +- apps/cli/src/command-internal/git-status.ts | 21 +- .../command-internal/git-status.unit.test.ts | 22 +- .../go-struct-output.encoders.ts | 8 +- .../command-internal/make-dir.unit.test.ts | 11 +- .../src/command-internal/pflag-reconcile.ts | 20 +- .../pflag-reconcile.unit.test.ts | 169 ++- .../prompt-yes-no.unit.test.ts | 149 +-- .../service-version-overrides.unit.test.ts | 109 +- .../sql-files-glob.unit.test.ts | 859 +++++------- .../status-values.unit.test.ts | 56 +- .../src/command-internal/string-slice-flag.ts | 12 +- .../command-internal/temp-paths.unit.test.ts | 107 +- .../src/command-internal/timestamp.format.ts | 14 +- .../timestamp.format.unit.test.ts | 15 +- .../upgrade-notice.integration.test.ts | 158 ++- .../upgrade-notice.unit.test.ts | 1189 +++++++++-------- .../commands/sso/add/add.integration.test.ts | 22 +- .../sso/update/update.integration.test.ts | 28 +- apps/cli/src/config/profile-file.ts | 9 - 20 files changed, 1542 insertions(+), 1478 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 46ed7b85b3..1f4dc3a112 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -21,10 +21,13 @@ "!apps/cli/tests/helpers/postgres-config-live.ts", "!apps/cli/tests/helpers/secrets-live.ts", "!apps/cli/tests/helpers/storage-live.ts", + "!apps/cli/src/command-internal/agent-mode.*", "!apps/cli/src/command-internal/api-*", + "!apps/cli/src/command-internal/bitbucket-pipeline.ts", "!apps/cli/src/command-internal/branch-ref.*", "!apps/cli/src/command-internal/branch-target.*", "!apps/cli/src/command-internal/bundled-postgres-client.*", + "!apps/cli/src/command-internal/colors.*", "!apps/cli/src/command-internal/connect-errors.*", "!apps/cli/src/command-internal/container-cli.*", "!apps/cli/src/command-internal/db-*.ts", @@ -36,10 +39,22 @@ "!apps/cli/src/command-internal/drop-objects.ts", "!apps/cli/src/command-internal/edge-runtime-*.ts", "!apps/cli/src/command-internal/ensure-login.*", + "!apps/cli/src/command-internal/error-message.ts", "!apps/cli/src/command-internal/experimental-feature.ts", "!apps/cli/src/command-internal/experimental-gate.*", + "!apps/cli/src/command-internal/fail-on.*", "!apps/cli/src/command-internal/get-api-keys.*", "!apps/cli/src/command-internal/get-tenant-api-keys.*", + "!apps/cli/src/command-internal/git-status.*", + "!apps/cli/src/command-internal/glob.*", + "!apps/cli/src/command-internal/global-flags.*", + "!apps/cli/src/command-internal/go-duration.*", + "!apps/cli/src/command-internal/go-float.*", + "!apps/cli/src/command-internal/go-json.*", + "!apps/cli/src/command-internal/go-output-flag.*", + "!apps/cli/src/command-internal/go-output.encoders.*", + "!apps/cli/src/command-internal/go-quote.ts", + "!apps/cli/src/command-internal/go-struct-output.*", "!apps/cli/src/command-internal/hostname.*", "!apps/cli/src/command-internal/http-*", "!apps/cli/src/command-internal/identity-stitch.*", @@ -48,11 +63,16 @@ "!apps/cli/src/command-internal/linked-state.*", "!apps/cli/src/command-internal/local-project-context.*", "!apps/cli/src/command-internal/login-*", + "!apps/cli/src/command-internal/make-dir.*", "!apps/cli/src/command-internal/management-api-runtime.*", "!apps/cli/src/command-internal/migrate-and-seed.*", "!apps/cli/src/command-internal/migration*", "!apps/cli/src/command-internal/param-introspection.*", "!apps/cli/src/command-internal/parent-project-ref.*", + "!apps/cli/src/command-internal/parse-uint.*", + "!apps/cli/src/command-internal/password-requirements.ts", + "!apps/cli/src/command-internal/path-match.*", + "!apps/cli/src/command-internal/pflag-reconcile.*", "!apps/cli/src/command-internal/pg-dump.*", "!apps/cli/src/command-internal/pg-net-guidance.*", "!apps/cli/src/command-internal/pgdelta*", @@ -66,11 +86,19 @@ "!apps/cli/src/command-internal/project-create-core.*", "!apps/cli/src/command-internal/project-environment.*", "!apps/cli/src/command-internal/project-target.*", + "!apps/cli/src/command-internal/prompt-yes-no.*", "!apps/cli/src/command-internal/raw-http.*", "!apps/cli/src/command-internal/ref-patterns.*", + "!apps/cli/src/command-internal/region.format.*", "!apps/cli/src/command-internal/resolve-token.*", + "!apps/cli/src/command-internal/rune-width.*", "!apps/cli/src/command-internal/schema-flags.*", "!apps/cli/src/command-internal/seed*", + "!apps/cli/src/command-internal/service-catalog.*", + "!apps/cli/src/command-internal/service-version-overrides.*", + "!apps/cli/src/command-internal/shell-quote.ts", + "!apps/cli/src/command-internal/size-units.ts", + "!apps/cli/src/command-internal/sql-files-glob.*", "!apps/cli/src/command-internal/sql-split.*", "!apps/cli/src/command-internal/stack-api.ts", "!apps/cli/src/command-internal/stack-backend.ts", @@ -79,10 +107,22 @@ "!apps/cli/src/command-internal/stack-local-database.ts", "!apps/cli/src/command-internal/stack-shadow.ts", "!apps/cli/src/command-internal/stack-storage.ts", + "!apps/cli/src/command-internal/status-errors.ts", + "!apps/cli/src/command-internal/status-pretty.*", + "!apps/cli/src/command-internal/status-values.*", "!apps/cli/src/command-internal/storage-*.ts", + "!apps/cli/src/command-internal/string-slice-flag.*", + "!apps/cli/src/command-internal/temp-paths.*", "!apps/cli/src/command-internal/tenant-*", + "!apps/cli/src/command-internal/timestamp.format.*", + "!apps/cli/src/command-internal/upgrade-notice.*", + "!apps/cli/src/command-internal/upgrade-suggest.*", "!apps/cli/src/command-internal/vault-decrypt.*", "!apps/cli/src/command-internal/vault.*", - "!apps/cli/src/command-internal/viper-env.*" + "!apps/cli/src/command-internal/viper-env.*", + "!apps/cli/src/command-internal/workdir-project.*", + "!apps/cli/src/command-internal/workdir-search.ts", + "!apps/cli/src/command-internal/workdir-validation.ts", + "apps/cli/src/command-internal/upgrade-notice.ts" ] } diff --git a/apps/cli/src/command-internal/git-status.ts b/apps/cli/src/command-internal/git-status.ts index 7325e456e9..51f4a199f1 100644 --- a/apps/cli/src/command-internal/git-status.ts +++ b/apps/cli/src/command-internal/git-status.ts @@ -1,5 +1,4 @@ -import { basename, dirname } from "node:path"; -import { Effect, Option } from "effect"; +import { Effect, Option, Path } from "effect"; import * as ChildProcess from "effect/process/ChildProcess"; import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; @@ -17,11 +16,10 @@ import { collectText } from "./container-cli.ts"; * Returns `Option.none()` whenever the answer can't be determined (not a git working tree, `git` * missing, a non-zero exit, or any other failure) rather than treating that as "clean". */ -export function pathHasUncommittedChanges( - path: string, -): Effect.Effect, never, ChildProcessSpawner> { - const args = ["status", "--porcelain", "--", basename(path)]; - return withProcessSpan( +export const pathHasUncommittedChanges = Effect.fnUntraced(function* (path: string) { + const pathService = yield* Path.Path; + const args = ["status", "--porcelain", "--", pathService.basename(path)]; + return yield* withProcessSpan( "Git.status", { executable: "git", argCount: args.length }, (traceEnv) => @@ -33,7 +31,12 @@ export function pathHasUncommittedChanges( "git", args, withChildTraceEnv( - { cwd: dirname(path), stdin: "ignore", stdout: "pipe", stderr: "ignore" }, + { + cwd: pathService.dirname(path), + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }, traceEnv, ), ), @@ -52,4 +55,4 @@ export function pathHasUncommittedChanges( ), Effect.orElseSucceed(() => Option.none()), ); -} +}); diff --git a/apps/cli/src/command-internal/git-status.unit.test.ts b/apps/cli/src/command-internal/git-status.unit.test.ts index 94b053c719..21a1ee4cea 100644 --- a/apps/cli/src/command-internal/git-status.unit.test.ts +++ b/apps/cli/src/command-internal/git-status.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Option, PlatformError, Sink, Stream } from "effect"; +import { Deferred, Effect, Option, Path, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/process"; import { pathHasUncommittedChanges } from "./git-status.ts"; @@ -26,14 +26,12 @@ function mockSpawner( spawned.push({ command: cmd, args, cwd }); if (opts.spawnFails === true) { - return yield* Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: `${cmd} not found`, - }), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: `${cmd} not found`, + }); } const exitDeferred = yield* Deferred.make(); @@ -71,6 +69,7 @@ describe("pathHasUncommittedChanges", () => { const mock = mockSpawner({ stdout: " M config.toml\n" }); return pathHasUncommittedChanges("/repo/supabase/config.toml").pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(Path.layer), Effect.map((result) => { expect(Option.isSome(result)).toBe(true); if (Option.isSome(result)) expect(result.value).toBe(true); @@ -82,6 +81,7 @@ describe("pathHasUncommittedChanges", () => { const mock = mockSpawner({ stdout: "" }); return pathHasUncommittedChanges("/repo/supabase/config.toml").pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(Path.layer), Effect.map((result) => { expect(Option.isSome(result)).toBe(true); if (Option.isSome(result)) expect(result.value).toBe(false); @@ -93,6 +93,7 @@ describe("pathHasUncommittedChanges", () => { const mock = mockSpawner({ exitCode: 128, stdout: "" }); return pathHasUncommittedChanges("/repo/supabase/config.toml").pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(Path.layer), Effect.map((result) => { expect(Option.isNone(result)).toBe(true); }), @@ -103,6 +104,7 @@ describe("pathHasUncommittedChanges", () => { const mock = mockSpawner({ spawnFails: true }); return pathHasUncommittedChanges("/repo/supabase/config.toml").pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(Path.layer), Effect.map((result) => { expect(Option.isNone(result)).toBe(true); }), @@ -115,6 +117,7 @@ describe("pathHasUncommittedChanges", () => { const mock = mockSpawner({ stdout: "" }); return pathHasUncommittedChanges("/repo/supabase/config.toml").pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(Path.layer), Effect.map(() => { expect(mock.spawned).toEqual([ { @@ -134,6 +137,7 @@ describe("pathHasUncommittedChanges", () => { const mock = mockSpawner({ stdout: " M migrations/20260101000000_init.sql\n" }); return pathHasUncommittedChanges("/repo/supabase/migrations").pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + Effect.provide(Path.layer), Effect.map((result) => { expect(Option.isSome(result)).toBe(true); if (Option.isSome(result)) expect(result.value).toBe(true); diff --git a/apps/cli/src/command-internal/go-struct-output.encoders.ts b/apps/cli/src/command-internal/go-struct-output.encoders.ts index e8d07e326f..554d655de7 100644 --- a/apps/cli/src/command-internal/go-struct-output.encoders.ts +++ b/apps/cli/src/command-internal/go-struct-output.encoders.ts @@ -1,3 +1,5 @@ +import { Data } from "effect"; + import { actionability, type CliErrorActionabilityDeclaration, @@ -996,11 +998,11 @@ function yamlBlockLiteral(s: string, indent: number): string { * `nullable.Nullable` field (`map[bool]T` has a non-string key type — observed * on `snippets list -o toml`) or a `nil` element inside an inline array. */ -export class GoTomlEncodeError extends Error { +export class GoTomlEncodeError extends Data.Error<{ readonly message: string }> { static readonly [ErrorActionabilityFingerprintId] = "GoTomlEncodeError"; + override readonly name = "GoTomlEncodeError"; constructor(message = "toml: cannot encode a map with non-string key type") { - super(message); - this.name = "GoTomlEncodeError"; + super({ message }); } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { diff --git a/apps/cli/src/command-internal/make-dir.unit.test.ts b/apps/cli/src/command-internal/make-dir.unit.test.ts index e83f8801cd..ab763fb02f 100644 --- a/apps/cli/src/command-internal/make-dir.unit.test.ts +++ b/apps/cli/src/command-internal/make-dir.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect"; +import { Effect, Exit, FileSystem, Layer, PlatformError, Predicate } from "effect"; import { makeDir } from "./make-dir.ts"; @@ -78,13 +78,10 @@ describe("makeDir", () => { it.effect("propagates every other filesystem error", () => { const fs = failingFs("PermissionDenied"); return run(DIR).pipe( - Effect.exit, - Effect.tap((exit) => + Effect.flip, + Effect.tap((error) => Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("PermissionDenied"); - } + expect(Predicate.isTagged(error.reason, "PermissionDenied")).toBe(true); }), ), Effect.provide(fs.layer), diff --git a/apps/cli/src/command-internal/pflag-reconcile.ts b/apps/cli/src/command-internal/pflag-reconcile.ts index 412335c4db..d81324faa0 100644 --- a/apps/cli/src/command-internal/pflag-reconcile.ts +++ b/apps/cli/src/command-internal/pflag-reconcile.ts @@ -3,13 +3,12 @@ import { Data, Effect, FileSystem, Option, Path, Result } from "effect"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { lastExplicitLongFlagValue, type PflagArgvScan } from "../shared/cli/cobra-flag-groups.ts"; import { ProfileFlag, WorkdirFlag } from "./global-flags.ts"; -import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; -import { profileFilePath } from "../config/profile-file.ts"; +import { CommandSettings } from "../config/command-settings.service.ts"; import { loadProfile, type LoadedProfile } from "./profile-load.ts"; import { parseStringSliceFlag } from "./string-slice-flag.ts"; import { validateWorkdirIsDirectory } from "./workdir-validation.ts"; @@ -136,7 +135,8 @@ export const validatePflagWorkdir = Effect.fnUntraced(function* ( // `serviceOption`: absent outside the real CLI tree (handler-level tests // provide argv via `Stdio.layerTest`, not the global flag settings). const parsedWorkdir = Option.flatten(yield* Effect.serviceOption(WorkdirFlag)); - const workdir = pflagWorkdirValue(scan, parsedWorkdir, process.env["SUPABASE_WORKDIR"]); + const envWorkdir = Option.getOrUndefined((yield* CommandSettings).workdirEnvValue); + const workdir = pflagWorkdirValue(scan, parsedWorkdir, envWorkdir); if (Option.isNone(workdir)) { return; } @@ -205,16 +205,17 @@ export function pflagProfileValue( * persisted-file token) or when the resolved token is empty. On every normal invocation the * layer's resolution stands unchanged. * - * `serviceOption` throughout: outside the real CLI tree (handler-level tests provide argv via - * `Stdio.layerTest`) the flag settings and `RuntimeInfo` may be absent, and the reconcile then - * only acts on what the scan itself shows. + * `serviceOption` for all but `CommandSettings`: outside the real CLI tree (handler-level + * tests provide argv via `Stdio.layerTest`) the flag settings may be absent, and the reconcile + * then only acts on what the scan itself shows. */ export const resolvePflagProfile = Effect.fnUntraced(function* ( scan: Pick, ) { const parsedRaw = yield* Effect.serviceOption(ProfileFlag); const parsedProfile = Option.filter(parsedRaw, (value) => value !== "supabase"); - const env = process.env["SUPABASE_PROFILE"]; + const settings = yield* CommandSettings; + const env = Option.getOrUndefined(settings.profileEnvValue); const envProfile = env !== undefined && env.length > 0 ? env : undefined; // The explicit token pflag-equivalent semantics resolve, vs. the one the config layer's own @@ -245,8 +246,7 @@ export const resolvePflagProfile = Effect.fnUntraced(function* ( const fs = yield* Effect.serviceOption(FileSystem.FileSystem); const path = yield* Effect.serviceOption(Path.Path); - const runtimeInfo = yield* Effect.serviceOption(RuntimeInfo); - if (Option.isNone(fs) || Option.isNone(path) || Option.isNone(runtimeInfo)) { + if (Option.isNone(fs) || Option.isNone(path)) { return Option.none(); } @@ -254,7 +254,7 @@ export const resolvePflagProfile = Effect.fnUntraced(function* ( // while the config layer trims and maps empty to the default — a divergence the token // comparison below surfaces (e.g. a trailing newline fails to load as a profile). const fileRaw = yield* fs.value - .readFileString(profileFilePath(path.value, runtimeInfo.value.homeDir)) + .readFileString(path.value.join(settings.supabaseHome, "profile")) .pipe(Effect.option); const goToken = Option.isSome(goExplicit) diff --git a/apps/cli/src/command-internal/pflag-reconcile.unit.test.ts b/apps/cli/src/command-internal/pflag-reconcile.unit.test.ts index 8b3e8f7d88..eb858faf24 100644 --- a/apps/cli/src/command-internal/pflag-reconcile.unit.test.ts +++ b/apps/cli/src/command-internal/pflag-reconcile.unit.test.ts @@ -1,20 +1,17 @@ -import { mkdtempSync, 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, Option, Result } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Predicate, Result } from "effect"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { ProfileFlag } from "./global-flags.ts"; -import { mockRuntimeInfo } from "../../tests/helpers/mocks.ts"; +import { mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; import { pflagBoolValue, pflagEnumValue, pflagProfileValue, pflagWorkdirValue, resolvePflagProfile, + validatePflagWorkdir, } from "./pflag-reconcile.ts"; // Sample enum values for the generic enum-reconciliation helper under test. @@ -316,25 +313,12 @@ describe("pflagProfileValue", () => { }); describe("resolvePflagProfile", () => { - const withEnvProfile = (value: string, effect: Effect.Effect) => { - const prev = process.env["SUPABASE_PROFILE"]; - process.env["SUPABASE_PROFILE"] = value; - return effect.pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_PROFILE"]; - else process.env["SUPABASE_PROFILE"] = prev; - }), - ), - ); - }; - - const services = (args: ReadonlyArray, homeDir: string) => + const services = (args: ReadonlyArray, homeDir: string, profileEnvValue: string) => Layer.mergeAll( BunServices.layer, Layer.succeed(ProfileFlag, "supabase"), Layer.succeed(CliArgs, { args }), - mockRuntimeInfo({ homeDir }), + mockCommandSettings({ workdir: homeDir, profileEnvValue: Option.some(profileEnvValue) }), ); // --domains --profile supabase: the config layer's raw scan shadows the env with the @@ -342,56 +326,109 @@ describe("resolvePflagProfile", () => { // profile instead of returning none. it.effect( "re-loads the env profile when the layer's scan wrongly shadowed a consumed token", - () => { - const dir = mkdtempSync(join(tmpdir(), "supabase-pflag-reconcile-")); - const profilePath = join(dir, "env.yml"); - writeFileSync( - profilePath, - [ - "name: harness", - "api_url: http://127.0.0.1:45555", - "dashboard_url: http://127.0.0.1:45555", - "project_host: localhost", - ].join("\n"), - ); - return withEnvProfile( - profilePath, - Effect.gen(function* () { - const resolved = yield* resolvePflagProfile({ - occurrences: new Map(), - consumedFlagNames: new Set(["profile"]), - prePathOccurrences: new Map(), - }); - expect(Option.isSome(resolved)).toBe(true); - if (Option.isSome(resolved)) { - expect(resolved.value.name).toBe("harness"); - expect(resolved.value.apiUrl).toBe("http://127.0.0.1:45555"); - } + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-pflag-reconcile-" }); + const profilePath = path.join(dir, "env.yml"); + yield* fs.writeFileString( + profilePath, + [ + "name: harness", + "api_url: http://127.0.0.1:45555", + "dashboard_url: http://127.0.0.1:45555", + "project_host: localhost", + ].join("\n"), + ); + const resolved = yield* resolvePflagProfile({ + occurrences: new Map(), + consumedFlagNames: new Set(["profile"]), + prePathOccurrences: new Map(), }).pipe( Effect.provide( - services(["sso", "add", "--type", "saml", "--domains", "--profile", "supabase"], dir), + services( + ["sso", "add", "--type", "saml", "--domains", "--profile", "supabase"], + dir, + profilePath, + ), ), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ), + ); + expect(Option.isSome(resolved)).toBe(true); + if (Option.isSome(resolved)) { + expect(resolved.value.name).toBe("harness"); + expect(resolved.value.apiUrl).toBe("http://127.0.0.1:45555"); + } + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("returns none when the scan and the layer agree on an explicit supabase", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-pflag-reconcile-" }); + const resolved = yield* resolvePflagProfile({ + occurrences: new Map([["profile", ["supabase"]]]), + consumedFlagNames: new Set(), + prePathOccurrences: new Map(), + }).pipe( + Effect.provide(services(["sso", "add", "--profile", "supabase"], dir, "rogue-profile")), ); - }, + expect(Option.isNone(resolved)).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("returns none when the scan and the layer agree on an explicit supabase", () => { - const dir = mkdtempSync(join(tmpdir(), "supabase-pflag-reconcile-")); - return withEnvProfile( - "rogue-profile", - Effect.gen(function* () { - const resolved = yield* resolvePflagProfile({ - occurrences: new Map([["profile", ["supabase"]]]), - consumedFlagNames: new Set(), - prePathOccurrences: new Map(), - }); - expect(Option.isNone(resolved)).toBe(true); + it.effect("fails to load an untrimmed token persisted in the Supabase home", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-pflag-reconcile-" }); + yield* fs.writeFileString(path.join(dir, "profile"), "snap\n"); + const error = yield* resolvePflagProfile({ + occurrences: new Map(), + consumedFlagNames: new Set(), + prePathOccurrences: new Map(), }).pipe( - Effect.provide(services(["sso", "add", "--profile", "supabase"], dir)), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ), - ); - }); + Effect.provide( + mockCommandSettings({ workdir: path.join(dir, "project"), supabaseHome: dir }), + ), + Effect.flip, + ); + expect(error.message).toBe(`failed to read profile: Unsupported Config Type ""`); + }).pipe(Effect.provide(BunServices.layer)), + ); +}); + +describe("validatePflagWorkdir", () => { + const scan = { + occurrences: new Map>(), + consumedFlagNames: new Set(), + prePathOccurrences: new Map>(), + }; + + it.effect("fails with the workdir error when SUPABASE_WORKDIR names a missing directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-pflag-reconcile-" }); + const missing = path.join(dir, "missing"); + const error = yield* validatePflagWorkdir(scan).pipe( + Effect.provide(mockCommandSettings({ workdir: dir, workdirEnvValue: missing })), + Effect.flip, + ); + expect(Predicate.isTagged(error, "PflagWorkdirError")).toBe(true); + expect(error.message).toBe( + `failed to change workdir: chdir ${missing}: no such file or directory`, + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("treats an empty SUPABASE_WORKDIR as unset", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-pflag-reconcile-" }); + yield* validatePflagWorkdir(scan).pipe( + Effect.provide(mockCommandSettings({ workdir: dir, workdirEnvValue: "" })), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts b/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts index 230e9b380d..2af549adfc 100644 --- a/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts +++ b/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer, Option, Stream } from "effect"; import { mockOutput, mockStdin, mockTty } from "../../tests/helpers/mocks.ts"; @@ -29,7 +29,7 @@ describe("parseYesNo", () => { describe("promptYesNo machine consent", () => { for (const format of ["json", "stream-json"] as const) { - it.each([ + it.effect.each([ { readMachineStdin: false, stdinIsTty: false, @@ -70,22 +70,22 @@ describe("promptYesNo machine consent", () => { expected: true, reads: 0, }, - ])(`${format} respects opt-in and input ownership: %j`, async (scenario) => { - const out = mockOutput({ format }); - let reads = 0; - const stdin = Layer.succeed(Stdin, { - isTTY: scenario.stdinIsTty, - readPipedBytes: Effect.die("unexpected whole-stream read"), - pipedBytesStream: Stream.empty, - readPipedText: Effect.die("unexpected whole-stream read"), - readLine: () => - Effect.sync(() => { - reads++; - return Option.some("y"); - }), - }); - const answer = await Effect.runPromise( - Effect.gen(function* () { + ])(`${format} respects opt-in and input ownership: %j`, (scenario) => + Effect.gen(function* () { + const out = mockOutput({ format }); + let reads = 0; + const stdin = Layer.succeed(Stdin, { + isTTY: scenario.stdinIsTty, + readPipedBytes: Effect.die("unexpected whole-stream read"), + pipedBytesStream: Stream.empty, + readPipedText: Effect.die("unexpected whole-stream read"), + readLine: () => + Effect.sync(() => { + reads++; + return Option.some("y"); + }), + }); + const answer = yield* Effect.gen(function* () { const output = yield* Output; return yield* promptYesNo( output, @@ -99,81 +99,80 @@ describe("promptYesNo machine consent", () => { Effect.provide( Layer.mergeAll(out.layer, stdin, mockTty({ stdinIsTty: scenario.stdinIsTty })), ), - ), - ); - expect(answer).toBe(scenario.expected); - expect(reads).toBe(scenario.reads); - expect(out.stderrText).toBe(scenario.reads === 1 ? "Confirm? [y/N] y\n" : ""); - }); + ); + expect(answer).toBe(scenario.expected); + expect(reads).toBe(scenario.reads); + expect(out.stderrText).toBe(scenario.reads === 1 ? "Confirm? [y/N] y\n" : ""); + }), + ); } }); describe("promptYesNo piped text answers", () => { const ask = (piped: string, defaultValue: boolean, interactive = true) => - Effect.runPromise( - Effect.gen(function* () { - const output = yield* Output; - return yield* promptYesNo(output, false, "Confirm?", defaultValue, interactive); - }).pipe( - Effect.provide( - Layer.mergeAll( - mockOutput().layer, - mockStdin(false, piped), - mockTty({ stdinIsTty: false }), - ), - ), + Effect.gen(function* () { + const output = yield* Output; + return yield* promptYesNo(output, false, "Confirm?", defaultValue, interactive); + }).pipe( + Effect.provide( + Layer.mergeAll(mockOutput().layer, mockStdin(false, piped), mockTty({ stdinIsTty: false })), ), ); - it.each(["u", "yess", "nope", " "])( + it.effect.each(["u", "yess", "nope", " "])( "declines the unrecognised answer %j whatever the default", - async (answer) => { - expect(await ask(`${answer}\n`, true)).toBe(false); - expect(await ask(`${answer}\n`, false)).toBe(false); - }, + (answer) => + Effect.gen(function* () { + expect(yield* ask(`${answer}\n`, true)).toBe(false); + expect(yield* ask(`${answer}\n`, false)).toBe(false); + }), ); - it("keeps the default for an unrecognised line under interactive: false", async () => { - expect(await ask("echo next-step\n", true, false)).toBe(true); - expect(await ask("echo next-step\n", false, false)).toBe(false); - expect(await ask(" \n", true, false)).toBe(true); - }); + it.effect("keeps the default for an unrecognised line under interactive: false", () => + Effect.gen(function* () { + expect(yield* ask("echo next-step\n", true, false)).toBe(true); + expect(yield* ask("echo next-step\n", false, false)).toBe(false); + expect(yield* ask(" \n", true, false)).toBe(true); + }), + ); - it.each([true, false])("takes the default %j for an empty line or closed stdin", async (def) => { - for (const interactive of [true, false]) { - expect(await ask("\n", def, interactive)).toBe(def); - expect(await ask("", def, interactive)).toBe(def); - } - }); + it.effect.each([true, false])("takes the default %j for an empty line or closed stdin", (def) => + Effect.gen(function* () { + for (const interactive of [true, false]) { + expect(yield* ask("\n", def, interactive)).toBe(def); + expect(yield* ask("", def, interactive)).toBe(def); + } + }), + ); - it("reads a blank line as present, then the next answer", async () => { - const lines = await Effect.runPromise( - Effect.gen(function* () { - const output = yield* Output; - const { readLine } = yield* Stdin; - const first = yield* promptYesNo(output, false, "Confirm?", true); - const second = yield* promptYesNo(output, false, "Confirm?", true); - return [first, second, yield* readLine(0), yield* readLine(0)]; - }).pipe( - Effect.provide( - Layer.mergeAll( - mockOutput().layer, - mockStdin(false, "\nn\n\n"), - mockTty({ stdinIsTty: false }), - ), + it.effect("reads a blank line as present, then the next answer", () => + Effect.gen(function* () { + const output = yield* Output; + const { readLine } = yield* Stdin; + const first = yield* promptYesNo(output, false, "Confirm?", true); + const second = yield* promptYesNo(output, false, "Confirm?", true); + const lines = [first, second, yield* readLine(0), yield* readLine(0)]; + expect(lines).toEqual([true, false, Option.some(""), Option.none()]); + }).pipe( + Effect.provide( + Layer.mergeAll( + mockOutput().layer, + mockStdin(false, "\nn\n\n"), + mockTty({ stdinIsTty: false }), ), ), - ); - expect(lines).toEqual([true, false, Option.some(""), Option.none()]); - }); + ), + ); - it.each([ + it.effect.each([ ["y", true], ["yes", true], ["n", false], ["no", false], - ] as const)("honours %j over the opposite default", async (answer, expected) => { - expect(await ask(`${answer}\n`, !expected)).toBe(expected); - expect(await ask(`${answer}\n`, !expected, false)).toBe(expected); - }); + ] as const)("honours %j over the opposite default", ([answer, expected]) => + Effect.gen(function* () { + expect(yield* ask(`${answer}\n`, !expected)).toBe(expected); + expect(yield* ask(`${answer}\n`, !expected, false)).toBe(expected); + }), + ); }); diff --git a/apps/cli/src/command-internal/service-version-overrides.unit.test.ts b/apps/cli/src/command-internal/service-version-overrides.unit.test.ts index 5d362c55cf..09fe156176 100644 --- a/apps/cli/src/command-internal/service-version-overrides.unit.test.ts +++ b/apps/cli/src/command-internal/service-version-overrides.unit.test.ts @@ -1,6 +1,3 @@ -import { 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, Exit, FileSystem, Path } from "effect"; @@ -15,79 +12,71 @@ const readPins = (workdir: string) => const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* readServiceVersionOverrides(fs, path, workdir, 17); - }).pipe(Effect.provide(BunServices.layer)); + }); -function writePin(workdir: string, fileName: string, contents: string) { - const tempDir = join(workdir, "supabase", ".temp"); - mkdirSync(tempDir, { recursive: true }); - writeFileSync(join(tempDir, fileName), contents); -} +const makeWorkdir = Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.makeTempDirectoryScoped({ prefix: "service-pins-" }), +); + +const writePin = Effect.fnUntraced(function* (workdir: string, fileName: string, contents: 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, fileName), contents); +}); describe("readServiceVersionOverrides", () => { it.effect("keeps a usable storage pin, including a suffix", () => Effect.gen(function* () { - const workdir = mkdtempSync(join(tmpdir(), "service-pins-")); - try { - writePin(workdir, "storage-version", "v1.77.1-versions\n"); - expect(yield* readPins(workdir)).toEqual({ storage: "v1.77.1-versions" }); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } - }), + const workdir = yield* makeWorkdir; + yield* writePin(workdir, "storage-version", "v1.77.1-versions\n"); + expect(yield* readPins(workdir)).toEqual({ storage: "v1.77.1-versions" }); + }).pipe(Effect.provide(BunServices.layer)), ); it.effect("fails on a doubled-v storage pin without rewriting it", () => Effect.gen(function* () { - const workdir = mkdtempSync(join(tmpdir(), "service-pins-")); - const pinPath = join(workdir, "supabase", ".temp", "storage-version"); - try { - writePin(workdir, "storage-version", "vv1.77.1-versions\n"); - const exit = yield* readPins(workdir).pipe(Effect.exit); - expect(exit).toEqual( - Exit.fail( - new InvalidServiceVersionTagError({ - message: - 'invalid storage image tag "vv1.77.1-versions" in supabase/.temp/storage-version; run supabase link again', - }), - ), - ); - expect(readFileSync(pinPath, "utf8")).toBe("vv1.77.1-versions\n"); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } - }), + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* makeWorkdir; + const pinPath = path.join(workdir, "supabase", ".temp", "storage-version"); + yield* writePin(workdir, "storage-version", "vv1.77.1-versions\n"); + const exit = yield* readPins(workdir).pipe(Effect.exit); + expect(exit).toEqual( + Exit.fail( + new InvalidServiceVersionTagError({ + message: + 'invalid storage image tag "vv1.77.1-versions" in supabase/.temp/storage-version; run supabase link again', + }), + ), + ); + expect(yield* fs.readFileString(pinPath)).toBe("vv1.77.1-versions\n"); + }).pipe(Effect.provide(BunServices.layer)), ); it.effect("fails on a doubled-v postgrest pin", () => Effect.gen(function* () { - const workdir = mkdtempSync(join(tmpdir(), "service-pins-")); - try { - writePin(workdir, "rest-version", "vv12.2.0"); - const exit = yield* readPins(workdir).pipe(Effect.exit); - expect(exit).toEqual( - Exit.fail( - new InvalidServiceVersionTagError({ - message: - 'invalid postgrest image tag "vv12.2.0" in supabase/.temp/rest-version; run supabase link again', - }), - ), - ); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } - }), + const workdir = yield* makeWorkdir; + yield* writePin(workdir, "rest-version", "vv12.2.0"); + const exit = yield* readPins(workdir).pipe(Effect.exit); + expect(exit).toEqual( + Exit.fail( + new InvalidServiceVersionTagError({ + message: + 'invalid postgrest image tag "vv12.2.0" in supabase/.temp/rest-version; run supabase link again', + }), + ), + ); + }).pipe(Effect.provide(BunServices.layer)), ); it.effect("fails when a pin is not a docker tag", () => Effect.gen(function* () { - const workdir = mkdtempSync(join(tmpdir(), "service-pins-")); - try { - writePin(workdir, "storage-version", "v1.77.1 bad"); - const exit = yield* readPins(workdir).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } - }), + const workdir = yield* makeWorkdir; + yield* writePin(workdir, "storage-version", "v1.77.1 bad"); + const exit = yield* readPins(workdir).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), ); }); diff --git a/apps/cli/src/command-internal/sql-files-glob.unit.test.ts b/apps/cli/src/command-internal/sql-files-glob.unit.test.ts index 6d6e904185..2329ad9858 100644 --- a/apps/cli/src/command-internal/sql-files-glob.unit.test.ts +++ b/apps/cli/src/command-internal/sql-files-glob.unit.test.ts @@ -1,9 +1,6 @@ -import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { BunFileSystem, BunPath, BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import { BunPath, BunServices } from "@effect/platform-bun"; +import { expect, layer } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; import { sqlFilesGlob } from "./sql-files-glob.ts"; @@ -12,243 +9,210 @@ const run = (patterns: ReadonlyArray, workdir: string) => const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* sqlFilesGlob(fs, path, patterns, workdir); - }).pipe(Effect.provide(BunServices.layer)); + }); -describe("sqlFilesGlob", () => { - it.effect( - "treats an empty pattern as no match, not the workdir itself (Go fs.Glob parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-empty-")); - return run([""], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toEqual(["no files matched pattern: "]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, +const tempWorkdir = Effect.fnUntraced(function* (prefix: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return { fs, path, dir: yield* fs.makeTempDirectoryScoped({ prefix }) }; +}); + +/** + * Runs `use` with win32 path semantics (`BunPath.layerWin32` + `process.platform`), the same + * branch a real Windows install takes. + */ +const withWin32Path = (use: (path: Path.Path) => Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const platform = process.platform; + Object.defineProperty(process, "platform", { value: "win32" }); + return platform; + }), + () => Effect.flatMap(Path.Path, use).pipe(Effect.provide(BunPath.layerWin32)), + (platform) => + Effect.sync(() => { + Object.defineProperty(process, "platform", { value: platform }); + }), + ); + +/** Runs `body` with `target` at `mode`, then restores it so the temp dir can be removed. */ +const withMode = (target: string, mode: number, body: Effect.Effect) => + Effect.flatMap(FileSystem.FileSystem, (fs) => + Effect.acquireUseRelease( + fs.chmod(target, mode), + () => body, + () => Effect.orDie(fs.chmod(target, 0o755)), + ), + ); + +layer(BunServices.layer)("sqlFilesGlob", (it) => { + it.effect("treats an empty pattern as no match, not the workdir itself (Go fs.Glob parity)", () => + Effect.gen(function* () { + const { dir } = yield* tempWorkdir("sql-glob-empty-"); + const result = yield* run([""], dir); + expect(result.files).toEqual([]); + expect(result.warnings).toEqual(["no files matched pattern: "]); + }), ); it.effect( "does not follow a symlinked .sql file below a matched directory (Go WalkDir parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-symlink-file-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "real.sql"), "select 1;"); - const outsideDir = join(dir, "outside"); - mkdirSync(outsideDir); - writeFileSync(join(outsideDir, "evil.sql"), "select 2;"); - symlinkSync(join(outsideDir, "evil.sql"), join(schemasDir, "linked.sql")); - return run(["schemas"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/real.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-symlink-file-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "real.sql"), "select 1;"); + const outsideDir = path.join(dir, "outside"); + yield* fs.makeDirectory(outsideDir); + yield* fs.writeFileString(path.join(outsideDir, "evil.sql"), "select 2;"); + yield* fs.symlink(path.join(outsideDir, "evil.sql"), path.join(schemasDir, "linked.sql")); + const result = yield* run(["schemas"], dir); + expect(result.files).toEqual(["schemas/real.sql"]); + expect(result.warnings).toEqual([]); + }), ); it.effect( "does not recurse into a symlinked subdirectory below a matched directory (Go WalkDir parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-symlink-dir-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "real.sql"), "select 1;"); - const outsideSubdir = join(dir, "outside-subdir"); - mkdirSync(outsideSubdir); - writeFileSync(join(outsideSubdir, "nested.sql"), "select 3;"); - symlinkSync(outsideSubdir, join(schemasDir, "linked-dir")); - return run(["schemas"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/real.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-symlink-dir-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "real.sql"), "select 1;"); + const outsideSubdir = path.join(dir, "outside-subdir"); + yield* fs.makeDirectory(outsideSubdir); + yield* fs.writeFileString(path.join(outsideSubdir, "nested.sql"), "select 3;"); + yield* fs.symlink(outsideSubdir, path.join(schemasDir, "linked-dir")); + const result = yield* run(["schemas"], dir); + expect(result.files).toEqual(["schemas/real.sql"]); + expect(result.warnings).toEqual([]); + }), ); it.effect( "surfaces a stat failure on a matched file as a warning instead of treating it as a regular file (Go parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-stat-fail-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "good.sql"), "select 1;"); - symlinkSync(join(schemasDir, "does-not-exist.sql"), join(schemasDir, "broken.sql")); - return run(["schemas/*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/good.sql"]); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toMatch(/^failed to stat matched file: /); - expect(result.warnings[0]).toContain("schemas/broken.sql"); - expect(result.warnings[0]).not.toContain(dir); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-stat-fail-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "good.sql"), "select 1;"); + yield* fs.symlink( + path.join(schemasDir, "does-not-exist.sql"), + path.join(schemasDir, "broken.sql"), + ); + const result = yield* run(["schemas/*.sql"], dir); + expect(result.files).toEqual(["schemas/good.sql"]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to stat matched file: /); + expect(result.warnings[0]).toContain("schemas/broken.sql"); + expect(result.warnings[0]).not.toContain(dir); + }), ); it.effect( "surfaces a stat failure for a LITERAL (no-metacharacter) pattern naming a broken symlink, instead of reporting no match (Go afero.Glob Lstat parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-literal-symlink-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - symlinkSync(join(schemasDir, "does-not-exist.sql"), join(schemasDir, "broken.sql")); - return run(["schemas/broken.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toMatch(/^failed to stat matched file: /); - expect(result.warnings[0]).toContain("schemas/broken.sql"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-literal-symlink-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.symlink( + path.join(schemasDir, "does-not-exist.sql"), + path.join(schemasDir, "broken.sql"), + ); + const result = yield* run(["schemas/broken.sql"], dir); + expect(result.files).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to stat matched file: /); + expect(result.warnings[0]).toContain("schemas/broken.sql"); + }), ); it.effect( "keeps a bare root ('/') as the directory when a glob pattern's meta character is in the first path component (Go afero.Glob parity)", - () => { - // The canary file lives in the workdir (never the real "/"), proving this doesn't - // fall back to treating the root component as workdir-relative. - const dir = mkdtempSync(join(tmpdir(), "sql-glob-abs-root-")); - writeFileSync(join(dir, "__legacy_sql_glob_canary__.sql"), "select 1;"); - return run(["/*__legacy_sql_glob_canary__*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toEqual([ - "no files matched pattern: /*__legacy_sql_glob_canary__*.sql", - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + // The canary file lives in the workdir (never the real "/"), proving this doesn't + // fall back to treating the root component as workdir-relative. + const { fs, path, dir } = yield* tempWorkdir("sql-glob-abs-root-"); + yield* fs.writeFileString(path.join(dir, "__legacy_sql_glob_canary__.sql"), "select 1;"); + const result = yield* run(["/*__legacy_sql_glob_canary__*.sql"], dir); + expect(result.files).toEqual([]); + expect(result.warnings).toEqual([ + "no files matched pattern: /*__legacy_sql_glob_canary__*.sql", + ]); + }), ); it.effect( "recurses through a root-anchored directory component without falling back to the workdir (Go afero.Glob parity)", - () => { - // The workdir here contains a subdirectory that would match "foo*" only if the - // recursive call incorrectly fell back to reading the workdir instead of "/". - const dir = mkdtempSync(join(tmpdir(), "sql-glob-abs-root-nested-")); - const canaryDir = join(dir, "__legacy_sql_glob_root_canary_dir__"); - mkdirSync(canaryDir); - writeFileSync(join(canaryDir, "a.sql"), "select 1;"); - return run(["/__legacy_sql_glob_root_canary_dir__*/*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toEqual([ - "no files matched pattern: /__legacy_sql_glob_root_canary_dir__*/*.sql", - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + // The workdir here contains a subdirectory that would match "foo*" only if the + // recursive call incorrectly fell back to reading the workdir instead of "/". + const { fs, path, dir } = yield* tempWorkdir("sql-glob-abs-root-nested-"); + const canaryDir = path.join(dir, "__legacy_sql_glob_root_canary_dir__"); + yield* fs.makeDirectory(canaryDir); + yield* fs.writeFileString(path.join(canaryDir, "a.sql"), "select 1;"); + const result = yield* run(["/__legacy_sql_glob_root_canary_dir__*/*.sql"], dir); + expect(result.files).toEqual([]); + expect(result.warnings).toEqual([ + "no files matched pattern: /__legacy_sql_glob_root_canary_dir__*/*.sql", + ]); + }), ); it.effect( "preserves a Windows drive root ('C:/') as the directory when splitting a glob pattern (Go filepath.Split parity)", - () => { - // Forces win32 path semantics (`BunPath.layerWin32` + `process.platform`) so the test - // exercises the same branch a real Windows install takes. - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { value: "win32" }); - const dir = mkdtempSync(join(tmpdir(), "sql-glob-drive-root-")); - writeFileSync(join(dir, "a.sql"), "select 1;"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; + () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-drive-root-"); + const file = path.join(dir, "a.sql"); + yield* fs.writeFileString(file, "select 1;"); // A "C:/" drive root doesn't exist on this test host, so fake just the two calls that // must resolve against it, reusing a real file's stat info. - const realFileInfo = yield* fs.stat(join(dir, "a.sql")); + const realFileInfo = yield* fs.stat(file); const driveRootFs: FileSystem.FileSystem = { ...fs, readDirectory: (p: string) => p === "C:/" ? Effect.succeed(["a.sql"]) : fs.readDirectory(p), stat: (p: string) => (p === "C:/a.sql" ? Effect.succeed(realFileInfo) : fs.stat(p)), }; - return yield* sqlFilesGlob(driveRootFs, path, ["C:/*.sql"], dir); - }).pipe( - Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layerWin32)), - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["C:/a.sql"]); - expect(result.warnings).toEqual([]); - }), - ), - Effect.ensuring( - Effect.sync(() => { - Object.defineProperty(process, "platform", { value: originalPlatform }); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + const result = yield* withWin32Path((win32Path) => + sqlFilesGlob(driveRootFs, win32Path, ["C:/*.sql"], dir), + ); + expect(result.files).toEqual(["C:/a.sql"]); + expect(result.warnings).toEqual([]); + }), ); it.effect( "reports the raw backslash pattern in a 'no files matched' warning on Windows, not the slashed form used for matching (Go filepath.ToSlash parity)", - () => { - // Forces win32 path semantics, same as the drive-root test above. - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { value: "win32" }); - const dir = mkdtempSync(join(tmpdir(), "sql-glob-win-warn-")); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* sqlFilesGlob(fs, path, ["C:\\schemas\\*.sql"], dir); - }).pipe( - Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layerWin32)), - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toEqual(["no files matched pattern: C:\\schemas\\*.sql"]); - }), - ), - Effect.ensuring( - Effect.sync(() => { - Object.defineProperty(process, "platform", { value: originalPlatform }); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + const { fs, dir } = yield* tempWorkdir("sql-glob-win-warn-"); + const result = yield* withWin32Path((win32Path) => + sqlFilesGlob(fs, win32Path, ["C:\\schemas\\*.sql"], dir), + ); + expect(result.files).toEqual([]); + expect(result.warnings).toEqual(["no files matched pattern: C:\\schemas\\*.sql"]); + }), ); it.effect( "sorts raw backslash-joined Windows matches BEFORE slashing, not after (Go afero.Glob/filepath.ToSlash ordering parity)", - () => { - // For `a\x.sql` vs `a0\x.sql`, sorting the raw backslash bytes puts `a0\x.sql` first - // (`\` is `0x5C` > `0`'s `0x30`), while sorting the slashed form would put `a/x.sql` - // first instead — a fully faked filesystem supplies canned results keyed by the exact - // backslash-joined paths `BunPath.layerWin32`'s `path.join` computes. - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { value: "win32" }); - const scratchDir = mkdtempSync(join(tmpdir(), "sql-glob-win-sort-")); - const canaryFile = join(scratchDir, "canary.sql"); - writeFileSync(canaryFile, "select 1;"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; + () => + Effect.gen(function* () { + // For `a\x.sql` vs `a0\x.sql`, sorting the raw backslash bytes puts `a0\x.sql` first + // (`\` is `0x5C` > `0`'s `0x30`), while sorting the slashed form would put `a/x.sql` + // first instead — a fully faked filesystem supplies canned results keyed by the exact + // backslash-joined paths `BunPath.layerWin32`'s `path.join` computes. + const { fs, path, dir } = yield* tempWorkdir("sql-glob-win-sort-"); + const canaryFile = path.join(dir, "canary.sql"); + yield* fs.writeFileString(canaryFile, "select 1;"); const fileInfo = yield* fs.stat(canaryFile); const workdir = "/workdir"; const winFs: FileSystem.FileSystem = { @@ -263,389 +227,272 @@ describe("sqlFilesGlob", () => { ? Effect.succeed(fileInfo) : fs.stat(p), }; - return yield* sqlFilesGlob(winFs, path, ["a*/x.sql"], workdir); - }).pipe( - Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layerWin32)), - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["a0/x.sql", "a/x.sql"]); - expect(result.warnings).toEqual([]); - }), - ), - Effect.ensuring( - Effect.sync(() => { - Object.defineProperty(process, "platform", { value: originalPlatform }); - rmSync(scratchDir, { recursive: true, force: true }); - }), - ), - ); - }, + const result = yield* withWin32Path((win32Path) => + sqlFilesGlob(winFs, win32Path, ["a*/x.sql"], workdir), + ); + expect(result.files).toEqual(["a0/x.sql", "a/x.sql"]); + expect(result.warnings).toEqual([]); + }), ); it.effect( "normalizes a doubled slash when the matched directory itself has a trailing slash (Go path.Join parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-trailing-slash-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - return run(["schemas/"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/a.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-trailing-slash-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "a.sql"), "select 1;"); + const result = yield* run(["schemas/"], dir); + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + }), ); it.effect( "drops the './' prefix when the matched directory cleans to '.' (Go path.Join parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-dot-root-")); - writeFileSync(join(dir, "a.sql"), "select 1;"); - const nestedDir = join(dir, "nested"); - mkdirSync(nestedDir); - writeFileSync(join(nestedDir, "b.sql"), "select 2;"); - return run(["."], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["a.sql", "nested/b.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-dot-root-"); + yield* fs.writeFileString(path.join(dir, "a.sql"), "select 1;"); + const nestedDir = path.join(dir, "nested"); + yield* fs.makeDirectory(nestedDir); + yield* fs.writeFileString(path.join(nestedDir, "b.sql"), "select 2;"); + const result = yield* run(["."], dir); + expect(result.files).toEqual(["a.sql", "nested/b.sql"]); + expect(result.warnings).toEqual([]); + }), ); it.effect( "cleans a '..'-segment matched directory when walking its children (Go path.Join parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-dotdot-segment-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - return run(["nested/../schemas"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/a.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-dotdot-segment-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "a.sql"), "select 1;"); + const result = yield* run(["nested/../schemas"], dir); + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + }), ); it.effect( "cleans a direct glob match whose directory portion has a '.' segment (Go afero.Glob parity)", - () => { - // Distinct from the walked-child cleaning tests above: the glob metacharacter is in - // the final component, so this matches directly via `globOne`, never `walkSqlFiles`. - const dir = mkdtempSync(join(tmpdir(), "sql-glob-direct-dot-segment-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - return run(["schemas/./*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/a.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + // Distinct from the walked-child cleaning tests above: the glob metacharacter is in + // the final component, so this matches directly via `globOne`, never `walkSqlFiles`. + const { fs, path, dir } = yield* tempWorkdir("sql-glob-direct-dot-segment-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "a.sql"), "select 1;"); + const result = yield* run(["schemas/./*.sql"], dir); + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + }), ); it.effect( "cleans a direct glob match whose directory portion has a '..' segment (Go afero.Glob parity)", - () => { - // Same distinction as above (a direct match via `globOne`), but for an embedded `..` - // segment instead of `.`. - const dir = mkdtempSync(join(tmpdir(), "sql-glob-direct-dotdot-segment-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - return run(["nested/../schemas/*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/a.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + // Same distinction as above (a direct match via `globOne`), but for an embedded `..` + // segment instead of `.`. + const { fs, path, dir } = yield* tempWorkdir("sql-glob-direct-dotdot-segment-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "a.sql"), "select 1;"); + const result = yield* run(["nested/../schemas/*.sql"], dir); + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + }), ); it.effect( "normalizes a doubled slash for a direct glob match under a trailing-slash directory component (Go afero.Glob parity)", - () => { - // `splitPath` on `"schemas//*.sql"` yields `dir: "schemas/"` (the trailing slash - // survives the split), so the join must still collapse the doubled slash. - const dir = mkdtempSync(join(tmpdir(), "sql-glob-direct-doubled-slash-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - return run(["schemas//*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/a.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + // `splitPath` on `"schemas//*.sql"` yields `dir: "schemas/"` (the trailing slash + // survives the split), so the join must still collapse the doubled slash. + const { fs, path, dir } = yield* tempWorkdir("sql-glob-direct-doubled-slash-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "a.sql"), "select 1;"); + const result = yield* run(["schemas//*.sql"], dir); + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + }), ); it.effect( "still includes a '.sql' child whose stat fails after it's already listed (Go WalkDir parity)", - () => { - // Simulates the race by deleting the file the instant after `readDirectory` returns - // it, via a `FileSystem` layer that removes on the first `stat` call for that path. - const dir = mkdtempSync(join(tmpdir(), "sql-glob-stat-race-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - const racyFile = join(schemasDir, "racy.sql"); - writeFileSync(racyFile, "select 1;"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; + () => + Effect.gen(function* () { + // Simulates the race by deleting the file the instant after `readDirectory` returns + // it, via a `FileSystem` layer that removes on the first `stat` call for that path. + const { fs, path, dir } = yield* tempWorkdir("sql-glob-stat-race-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + const racyFile = path.join(schemasDir, "racy.sql"); + yield* fs.writeFileString(racyFile, "select 1;"); const racyFs: FileSystem.FileSystem = { ...fs, stat: (p: string) => - p === racyFile - ? Effect.sync(() => rmSync(racyFile)).pipe(Effect.andThen(fs.stat(p))) - : fs.stat(p), + p === racyFile ? fs.remove(racyFile).pipe(Effect.andThen(fs.stat(p))) : fs.stat(p), }; - return yield* sqlFilesGlob(racyFs, path, ["schemas"], dir); - }).pipe( - Effect.provide(BunServices.layer), - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/racy.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + const result = yield* sqlFilesGlob(racyFs, path, ["schemas"], dir); + expect(result.files).toEqual(["schemas/racy.sql"]); + expect(result.warnings).toEqual([]); + }), ); it.effect( "fails the whole walk when a non-'.sql' child whose stat fails could have been a subdirectory (Go WalkDir parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-dir-race-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - const nestedDir = join(schemasDir, "nested"); - mkdirSync(nestedDir); - writeFileSync(join(nestedDir, "b.sql"), "select 2;"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; + () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-dir-race-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "a.sql"), "select 1;"); + const nestedDir = path.join(schemasDir, "nested"); + yield* fs.makeDirectory(nestedDir); + yield* fs.writeFileString(path.join(nestedDir, "b.sql"), "select 2;"); const racyFs: FileSystem.FileSystem = { ...fs, stat: (p: string) => p === nestedDir - ? Effect.sync(() => rmSync(nestedDir, { recursive: true })).pipe( - Effect.andThen(fs.stat(p)), - ) + ? fs.remove(nestedDir, { recursive: true }).pipe(Effect.andThen(fs.stat(p))) : fs.stat(p), }; - return yield* sqlFilesGlob(racyFs, path, ["schemas"], dir); - }).pipe( - Effect.provide(BunServices.layer), - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); - expect(result.warnings[0]).toContain("schemas/nested"); - expect(result.warnings[0]).not.toContain(dir); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + const result = yield* sqlFilesGlob(racyFs, path, ["schemas"], dir); + expect(result.files).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); + expect(result.warnings[0]).toContain("schemas/nested"); + expect(result.warnings[0]).not.toContain(dir); + }), ); - it.effect("still expands a real (non-symlinked) nested directory recursively", () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-nested-")); - const schemasDir = join(dir, "schemas"); - const nestedDir = join(schemasDir, "nested"); - mkdirSync(nestedDir, { recursive: true }); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - writeFileSync(join(nestedDir, "b.sql"), "select 2;"); - return run(["schemas"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/a.sql", "schemas/nested/b.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("still expands a real (non-symlinked) nested directory recursively", () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-nested-"); + const schemasDir = path.join(dir, "schemas"); + const nestedDir = path.join(schemasDir, "nested"); + yield* fs.makeDirectory(nestedDir, { recursive: true }); + yield* fs.writeFileString(path.join(schemasDir, "a.sql"), "select 1;"); + yield* fs.writeFileString(path.join(nestedDir, "b.sql"), "select 2;"); + const result = yield* run(["schemas"], dir); + expect(result.files).toEqual(["schemas/a.sql", "schemas/nested/b.sql"]); + expect(result.warnings).toEqual([]); + }), + ); const isRoot = typeof process.getuid === "function" && process.getuid() === 0; it.effect.skipIf(isRoot)( "surfaces a directory-read failure during walk as a warning instead of an empty match (Go WalkDir parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-walk-fail-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - chmodSync(schemasDir, 0o000); - return run(["schemas"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); - expect(result.warnings[0]).toContain("schemas"); - expect(result.warnings[0]).not.toContain(dir); - }), - ), - Effect.ensuring( - Effect.sync(() => { - chmodSync(schemasDir, 0o755); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-walk-fail-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "a.sql"), "select 1;"); + const result = yield* withMode(schemasDir, 0o000, run(["schemas"], dir)); + expect(result.files).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); + expect(result.warnings[0]).toContain("schemas"); + expect(result.warnings[0]).not.toContain(dir); + }), ); it.effect.skipIf(isRoot)( "surfaces a NESTED directory-read failure during walk with a workdir-relative path, not the matched root's (Go WalkDir parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-walk-fail-nested-")); - const schemasDir = join(dir, "schemas"); - const nestedDir = join(schemasDir, "nested"); - mkdirSync(nestedDir, { recursive: true }); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - writeFileSync(join(nestedDir, "b.sql"), "select 2;"); - chmodSync(nestedDir, 0o000); - return run(["schemas"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); - expect(result.warnings[0]).toContain("schemas/nested"); - expect(result.warnings[0]).not.toContain(dir); - }), - ), - Effect.ensuring( - Effect.sync(() => { - chmodSync(nestedDir, 0o755); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-walk-fail-nested-"); + const schemasDir = path.join(dir, "schemas"); + const nestedDir = path.join(schemasDir, "nested"); + yield* fs.makeDirectory(nestedDir, { recursive: true }); + yield* fs.writeFileString(path.join(schemasDir, "a.sql"), "select 1;"); + yield* fs.writeFileString(path.join(nestedDir, "b.sql"), "select 2;"); + const result = yield* withMode(nestedDir, 0o000, run(["schemas"], dir)); + expect(result.files).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); + expect(result.warnings[0]).toContain("schemas/nested"); + expect(result.warnings[0]).not.toContain(dir); + }), ); it.effect.skipIf(isRoot)( "keeps files from a sibling pattern when only one matched directory fails to walk", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-walk-fail-partial-")); - const goodDir = join(dir, "good"); - const badDir = join(dir, "bad"); - mkdirSync(goodDir); - mkdirSync(badDir); - writeFileSync(join(goodDir, "a.sql"), "select 1;"); - writeFileSync(join(badDir, "b.sql"), "select 2;"); - chmodSync(badDir, 0o000); - return run(["good", "bad"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["good/a.sql"]); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); - }), - ), - Effect.ensuring( - Effect.sync(() => { - chmodSync(badDir, 0o755); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempWorkdir("sql-glob-walk-fail-partial-"); + const goodDir = path.join(dir, "good"); + const badDir = path.join(dir, "bad"); + yield* fs.makeDirectory(goodDir); + yield* fs.makeDirectory(badDir); + yield* fs.writeFileString(path.join(goodDir, "a.sql"), "select 1;"); + yield* fs.writeFileString(path.join(badDir, "b.sql"), "select 2;"); + const result = yield* withMode(badDir, 0o000, run(["good", "bad"], dir)); + expect(result.files).toEqual(["good/a.sql"]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); + }), ); it.effect.skipIf(isRoot)( "picks the lexically-first failing subdirectory as the fatal error, matching Go's fs.WalkDir sorted-visit order (review CLI-1958)", - () => { - // The fake `readDirectory` returns "schemas"'s children in reverse - // order, to prove the walk sorts them back (`utf8Compare`) before iterating. - const dir = mkdtempSync(join(tmpdir(), "sql-glob-walk-order-")); - const schemasDir = join(dir, "schemas"); - const aaaDir = join(schemasDir, "aaa"); - const bbbDir = join(schemasDir, "bbb"); - mkdirSync(aaaDir, { recursive: true }); - mkdirSync(bbbDir, { recursive: true }); - chmodSync(aaaDir, 0o000); - chmodSync(bbbDir, 0o000); - return Effect.gen(function* () { - const realFs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; + () => + Effect.gen(function* () { + // The fake `readDirectory` returns "schemas"'s children in reverse + // order, to prove the walk sorts them back (`utf8Compare`) before iterating. + const { fs, path, dir } = yield* tempWorkdir("sql-glob-walk-order-"); + const schemasDir = path.join(dir, "schemas"); + const aaaDir = path.join(schemasDir, "aaa"); + const bbbDir = path.join(schemasDir, "bbb"); + yield* fs.makeDirectory(aaaDir, { recursive: true }); + yield* fs.makeDirectory(bbbDir, { recursive: true }); const reorderedFs: FileSystem.FileSystem = { - ...realFs, + ...fs, readDirectory: (p, opts) => - realFs + fs .readDirectory(p, opts) .pipe( Effect.map((names) => (p === schemasDir ? [...names].sort().reverse() : names)), ), }; - const result = yield* sqlFilesGlob(reorderedFs, path, ["schemas"], dir); + const result = yield* withMode( + aaaDir, + 0o000, + withMode(bbbDir, 0o000, sqlFilesGlob(reorderedFs, path, ["schemas"], dir)), + ); expect(result.files).toEqual([]); expect(result.warnings).toHaveLength(1); expect(result.warnings[0]).toContain("schemas/aaa"); expect(result.warnings[0]).not.toContain("schemas/bbb"); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring( - Effect.sync(() => { - chmodSync(aaaDir, 0o755); - chmodSync(bbbDir, 0o755); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + }), ); it.effect( "sorts direct wildcard matches by UTF-8 byte order, not UTF-16 code units (Go sort.Strings parity)", - () => { - // The emoji (4-byte UTF-8, lead byte 0xF0) sorts after the fullwidth exclamation mark - // (3-byte UTF-8, lead byte 0xEF) in byte order, but before it in UTF-16 code-unit - // order (0xD83D surrogate lead vs 0xFF01) — these two characters expose the difference. - const dir = mkdtempSync(join(tmpdir(), "sql-glob-utf8-sort-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "\u{1F600}.sql"), "select 1;"); // 😀 - writeFileSync(join(schemasDir, "!.sql"), "select 2;"); // ! - return run(["schemas/*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/!.sql", "schemas/\u{1F600}.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + // The emoji (4-byte UTF-8, lead byte 0xF0) sorts after the fullwidth exclamation mark + // (3-byte UTF-8, lead byte 0xEF) in byte order, but before it in UTF-16 code-unit + // order (0xD83D surrogate lead vs 0xFF01) — these two characters expose the difference. + const { fs, path, dir } = yield* tempWorkdir("sql-glob-utf8-sort-"); + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir); + yield* fs.writeFileString(path.join(schemasDir, "\u{1F600}.sql"), "select 1;"); // 😀 + yield* fs.writeFileString(path.join(schemasDir, "!.sql"), "select 2;"); // ! + const result = yield* run(["schemas/*.sql"], dir); + expect(result.files).toEqual(["schemas/!.sql", "schemas/\u{1F600}.sql"]); + expect(result.warnings).toEqual([]); + }), ); }); diff --git a/apps/cli/src/command-internal/status-values.unit.test.ts b/apps/cli/src/command-internal/status-values.unit.test.ts index a5793494f8..2ef704e793 100644 --- a/apps/cli/src/command-internal/status-values.unit.test.ts +++ b/apps/cli/src/command-internal/status-values.unit.test.ts @@ -1,6 +1,6 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; import { CliConfigSchema, type CliConfig } from "@supabase/config"; -import { Schema } from "effect"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { Effect, Schema } from "effect"; import { shortContainerImageName, @@ -711,31 +711,33 @@ describe("--exclude image short names under SUPABASE_USE_SLIM_IMAGES", () => { vi.resetModules(); }); - it("keeps matching the docker.io short names", async () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - vi.resetModules(); - const slimModule = await import("./status-values.ts"); - - for (const [excluded, omitted] of [ - ["gotrue", "ANON_KEY"], - ["storage-api", "STORAGE_S3_URL"], - ["kong", "API_URL"], - ["mailpit", "MAILPIT_URL"], - ["postgrest", "REST_URL"], - ["studio", "STUDIO_URL"], - ["edge-runtime", "FUNCTIONS_URL"], - ] as const) { - const { values } = slimModule.statusValues( - baseConfig(), - CONTAINER_IDS, - HOSTNAME, - [excluded], - NO_OVERRIDES, - WORKDIR, - ); - expect(values[omitted], `--exclude ${excluded}`).toBeUndefined(); - } - }); + it.effect("keeps matching the docker.io short names", () => + Effect.gen(function* () { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + vi.resetModules(); + const slimModule = yield* Effect.promise(() => import("./status-values.ts")); + + for (const [excluded, omitted] of [ + ["gotrue", "ANON_KEY"], + ["storage-api", "STORAGE_S3_URL"], + ["kong", "API_URL"], + ["mailpit", "MAILPIT_URL"], + ["postgrest", "REST_URL"], + ["studio", "STUDIO_URL"], + ["edge-runtime", "FUNCTIONS_URL"], + ] as const) { + const { values } = slimModule.statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [excluded], + NO_OVERRIDES, + WORKDIR, + ); + expect(values[omitted], `--exclude ${excluded}`).toBeUndefined(); + } + }), + ); }); describe("shortContainerImageName", () => { diff --git a/apps/cli/src/command-internal/string-slice-flag.ts b/apps/cli/src/command-internal/string-slice-flag.ts index bff8a3827b..26b91b3e85 100644 --- a/apps/cli/src/command-internal/string-slice-flag.ts +++ b/apps/cli/src/command-internal/string-slice-flag.ts @@ -12,6 +12,7 @@ import { * * Whitespace is not trimmed and empty fields are not dropped. */ +import { Data } from "effect"; import { Flag } from "effect/cli"; const QUOTE = 0x22; // " @@ -31,13 +32,14 @@ const lengthNL = (b: Uint8Array): number => (b.length > 0 && b[b.length - 1] === * (with a `record on line N; ` prefix when the record started on an earlier * line), or the literal `EOF` when the value is only blank lines. */ -export class StringSliceFlagParseError extends Error { - static readonly [ErrorActionabilityFingerprintId] = "StringSliceFlagParseError"; +export class StringSliceFlagParseError extends Data.Error<{ readonly value: string; + readonly message: string; +}> { + static readonly [ErrorActionabilityFingerprintId] = "StringSliceFlagParseError"; + override readonly name = "StringSliceFlagParseError"; private constructor(value: string, message: string) { - super(message); - this.name = "StringSliceFlagParseError"; - this.value = value; + super({ value, message }); } /** Line/column are 1-based; column is a byte offset within the physical line. */ static parse( diff --git a/apps/cli/src/command-internal/temp-paths.unit.test.ts b/apps/cli/src/command-internal/temp-paths.unit.test.ts index b217f3281c..7b2bc740d6 100644 --- a/apps/cli/src/command-internal/temp-paths.unit.test.ts +++ b/apps/cli/src/command-internal/temp-paths.unit.test.ts @@ -1,9 +1,6 @@ -import { mkdirSync, mkdtempSync, 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, Exit, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; import { classifyCliErrorActionability } from "../shared/telemetry/error-actionability.ts"; import { ProjectRefReadError, readProjectRefFile, tempPaths } from "./temp-paths.ts"; @@ -13,7 +10,7 @@ const readRef = (workdir: string) => const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* readProjectRefFile(fs, path, workdir); - }).pipe(Effect.provide(BunServices.layer)); + }); const REF = "abcdefghijklmnopqrst"; @@ -50,68 +47,52 @@ describe("tempPaths", () => { }); describe("readProjectRefFile", () => { - it.effect("returns None when the project-ref file is absent (not linked)", () => { - const dir = mkdtempSync(join(tmpdir(), "ref-")); - return readRef(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(Option.isNone(v)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); + const workdir = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "ref-" }); + const tempDir = path.join(dir, "supabase", ".temp"); + return { fs, dir, tempDir, refPath: path.join(tempDir, "project-ref") }; }); - it.effect("returns the trimmed ref when the file holds a value", () => { - const dir = mkdtempSync(join(tmpdir(), "ref-")); - mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(dir, "supabase", ".temp", "project-ref"), ` ${REF}\n`); - return readRef(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v)).toBe(REF); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("returns None when the project-ref file is absent (not linked)", () => + Effect.gen(function* () { + const { dir } = yield* workdir; + expect(Option.isNone(yield* readRef(dir))).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.effect("treats a blank project-ref file as None", () => { - const dir = mkdtempSync(join(tmpdir(), "ref-")); - mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(dir, "supabase", ".temp", "project-ref"), " \n"); - return readRef(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(Option.isNone(v)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("returns the trimmed ref when the file holds a value", () => + Effect.gen(function* () { + const { fs, dir, tempDir, refPath } = yield* workdir; + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString(refPath, ` ${REF}\n`); + expect(Option.getOrNull(yield* readRef(dir))).toBe(REF); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.effect("fails with ProjectRefReadError when the ref path is unreadable", () => { - // Returns `failed to load project ref` for a non-not-exist - // read error. Seeding project-ref as a DIRECTORY makes the - // read fail with EISDIR (a non-NotFound PlatformError), so it must surface, not - // collapse to "unlinked". - const dir = mkdtempSync(join(tmpdir(), "ref-")); - mkdirSync(join(dir, "supabase", ".temp", "project-ref"), { recursive: true }); - return readRef(dir).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); - expect(json).toContain("ProjectRefReadError"); - expect(json).toContain("failed to load project ref"); - } - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("treats a blank project-ref file as None", () => + Effect.gen(function* () { + const { fs, dir, tempDir, refPath } = yield* workdir; + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString(refPath, " \n"); + expect(Option.isNone(yield* readRef(dir))).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("fails with ProjectRefReadError when the ref path is unreadable", () => + Effect.gen(function* () { + // Returns `failed to load project ref` for a non-not-exist + // read error. Seeding project-ref as a DIRECTORY makes the + // read fail with EISDIR (a non-NotFound PlatformError), so it must surface, not + // collapse to "unlinked". + const { fs, dir, refPath } = yield* workdir; + yield* fs.makeDirectory(refPath, { recursive: true }); + const error = yield* Effect.flip(readRef(dir)); + expect(error).toBeInstanceOf(ProjectRefReadError); + expect(error.message).toMatch(/^failed to load project ref: /); + }).pipe(Effect.provide(BunServices.layer)), + ); it("classifies an unreadable ref file as permission without an unrelated command", () => { const result = classifyCliErrorActionability( diff --git a/apps/cli/src/command-internal/timestamp.format.ts b/apps/cli/src/command-internal/timestamp.format.ts index 00d7d4bd42..1e5ee35e3e 100644 --- a/apps/cli/src/command-internal/timestamp.format.ts +++ b/apps/cli/src/command-internal/timestamp.format.ts @@ -1,3 +1,5 @@ +import { DateTime, Option } from "effect"; + function pad2(value: number): string { return value.toString().padStart(2, "0"); } @@ -14,11 +16,9 @@ export function formatTimestamp(value: string): string { if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) { return value; } - const parsed = Date.parse(value); - if (Number.isNaN(parsed)) return value; - const date = new Date(parsed); - return ( - `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` + - `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}` - ); + /* `Date.parse` reads a zone-less value as local time; `DateTime.make(value)` would read it as UTC. */ + const parsed = DateTime.make(Date.parse(value)); + if (Option.isNone(parsed)) return value; + const { year, month, day, hour, minute, second } = DateTime.toPartsUtc(parsed.value); + return `${year}-${pad2(month)}-${pad2(day)} ${pad2(hour)}:${pad2(minute)}:${pad2(second)}`; } diff --git a/apps/cli/src/command-internal/timestamp.format.unit.test.ts b/apps/cli/src/command-internal/timestamp.format.unit.test.ts index c7efdef426..c8bd2c1af8 100644 --- a/apps/cli/src/command-internal/timestamp.format.unit.test.ts +++ b/apps/cli/src/command-internal/timestamp.format.unit.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { withEnvVar } from "../../tests/helpers/command-mocks.ts"; import { formatTimestamp } from "./timestamp.format.ts"; describe("formatTimestamp", () => { @@ -18,8 +20,19 @@ describe("formatTimestamp", () => { it("falls back for malformed input", () => { expect(formatTimestamp("not-a-timestamp")).toBe("not-a-timestamp"); + expect(formatTimestamp("2024-13-01T00:00:00Z")).toBe("2024-13-01T00:00:00Z"); }); + it.effect("reads a zone-less value as local time", () => + withEnvVar( + "TZ", + "Asia/Kolkata", + Effect.sync(() => { + expect(formatTimestamp("2026-02-08T16:44:07")).toBe("2026-02-08 11:14:07"); + }), + ), + ); + it("returns empty string unchanged", () => { expect(formatTimestamp("")).toBe(""); }); diff --git a/apps/cli/src/command-internal/upgrade-notice.integration.test.ts b/apps/cli/src/command-internal/upgrade-notice.integration.test.ts index 75dbb060d4..e835ce5fa4 100644 --- a/apps/cli/src/command-internal/upgrade-notice.integration.test.ts +++ b/apps/cli/src/command-internal/upgrade-notice.integration.test.ts @@ -1,94 +1,106 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { stripVTControlCharacters } from "node:util"; -import { Effect } from "effect"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Clock, DateTime, Effect, FileSystem, Path } from "effect"; +import { withEnvVar } from "../../tests/helpers/command-mocks.ts"; import { upgradeNoticeHook } from "./upgrade-notice.ts"; -describe("upgrade notice user-level cache", () => { - let root: string; - let workdir: string; - let supabaseHome: string; - let cacheFile: string; +const captureStderr = (body: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const written: Array = []; + const realWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: unknown) => { + written.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + return { written, realWrite }; + }), + ({ written }) => Effect.as(body, written), + ({ realWrite }) => + Effect.sync(() => { + process.stderr.write = realWrite; + }), + ); - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), "supabase-upgrade-notice-home-")); - workdir = join(root, "outside-project"); - supabaseHome = join(root, "supabase-home"); - cacheFile = join(supabaseHome, "cli-latest"); - mkdirSync(workdir); +describe("upgrade notice user-level cache", () => { + const userCache = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-upgrade-notice-home-" }); + const workdir = path.join(root, "outside-project"); + const supabaseHome = path.join(root, "supabase-home"); + yield* fs.makeDirectory(workdir); + return { fs, workdir, supabaseHome, cacheFile: path.join(supabaseHome, "cli-latest") }; }); - afterEach(() => { - rmSync(root, { recursive: true, force: true }); + /** Ages the cache against the live clock, which the hook's freshness check reads. */ + const writeUserCache = Effect.fnUntraced(function* ( + { fs, supabaseHome, cacheFile }: Effect.Success, + tag: string, + ageMs: number, + ) { + yield* fs.makeDirectory(supabaseHome, { recursive: true }); + yield* fs.writeFileString(cacheFile, tag); + const then = DateTime.toDateUtc(DateTime.makeUnsafe((yield* Clock.currentTimeMillis) - ageMs)); + yield* fs.utimes(cacheFile, then, then); }); - function writeUserCache(tag: string, ageMs: number) { - mkdirSync(supabaseHome, { recursive: true }); - writeFileSync(cacheFile, tag); - const then = new Date(Date.now() - ageMs); - utimesSync(cacheFile, then, then); - } - /** Runs the production hook with real `SUPABASE_HOME` resolution and a stub release fetch. */ - async function runHook(latestTag: string) { + const runHook = Effect.fnUntraced(function* ( + { workdir, supabaseHome }: Effect.Success, + latestTag: string, + ) { let fetchCalls = 0; - const written: Array = []; - const realWrite = process.stderr.write.bind(process.stderr); - const realEnv = { - SUPABASE_HOME: process.env["SUPABASE_HOME"], - SUPABASE_NO_UPDATE_NOTIFIER: process.env["SUPABASE_NO_UPDATE_NOTIFIER"], - }; - process.env["SUPABASE_HOME"] = supabaseHome; - process.env["SUPABASE_NO_UPDATE_NOTIFIER"] = "0"; - process.stderr.write = ((chunk: unknown) => { - written.push(String(chunk)); - return true; - }) as typeof process.stderr.write; - try { - await Effect.runPromise( - upgradeNoticeHook( - ["projects", "list"], - { - cleanShowHelp: false, - delegatedToGo: false, - workingDirectory: workdir, - isValueTakingFlagToken: () => false, - }, - () => { - fetchCalls += 1; - return Promise.resolve(latestTag); - }, + const written = yield* captureStderr( + withEnvVar( + "SUPABASE_HOME", + supabaseHome, + withEnvVar( + "SUPABASE_NO_UPDATE_NOTIFIER", + "0", + upgradeNoticeHook( + ["projects", "list"], + { + cleanShowHelp: false, + delegatedToGo: false, + workingDirectory: workdir, + isValueTakingFlagToken: () => false, + }, + () => { + fetchCalls += 1; + return Promise.resolve(latestTag); + }, + ), ), - ); - } finally { - process.stderr.write = realWrite; - for (const [key, value] of Object.entries(realEnv)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - } + ), + ); return { fetchCalls, stderr: stripVTControlCharacters(written.join("")) }; - } + }); - it("serves a fresh SUPABASE_HOME cache outside a project without fetching", async () => { - writeUserCache("v99.99.99", 60_000); + it.live("serves a fresh SUPABASE_HOME cache outside a project without fetching", () => + Effect.gen(function* () { + const ctx = yield* userCache; + yield* writeUserCache(ctx, "v99.99.99", 60_000); - const { fetchCalls, stderr } = await runHook("v99.99.100"); + const { fetchCalls, stderr } = yield* runHook(ctx, "v99.99.100"); - expect(fetchCalls).toBe(0); - expect(stderr).toContain("A new version of Supabase CLI is available: v99.99.99"); - }); + expect(fetchCalls).toBe(0); + expect(stderr).toContain("A new version of Supabase CLI is available: v99.99.99"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("refetches a stale SUPABASE_HOME cache and records the new tag", async () => { - writeUserCache("v2.100.0", 11 * 60 * 60 * 1000); + it.live("refetches a stale SUPABASE_HOME cache and records the new tag", () => + Effect.gen(function* () { + const ctx = yield* userCache; + yield* writeUserCache(ctx, "v2.100.0", 11 * 60 * 60 * 1000); - const { fetchCalls } = await runHook("v99.99.100"); + const { fetchCalls } = yield* runHook(ctx, "v99.99.100"); - expect(fetchCalls).toBe(1); - expect(readFileSync(cacheFile, "utf8")).toBe("v99.99.100"); - }); + expect(fetchCalls).toBe(1); + expect(yield* ctx.fs.readFileString(ctx.cacheFile)).toBe("v99.99.100"); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/command-internal/upgrade-notice.unit.test.ts b/apps/cli/src/command-internal/upgrade-notice.unit.test.ts index 89458d139b..91ac43e622 100644 --- a/apps/cli/src/command-internal/upgrade-notice.unit.test.ts +++ b/apps/cli/src/command-internal/upgrade-notice.unit.test.ts @@ -1,23 +1,11 @@ -import { - chmodSync, - lstatSync, - mkdirSync, - readdirSync, - mkdtempSync, - readFileSync, - rmSync, - statSync, - symlinkSync, - utimesSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { stripVTControlCharacters } from "node:util"; -import { describe, expect, it } from "vitest"; -import { Effect, Option, Tracer } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it, layer } from "@effect/vitest"; +import { Clock, DateTime, Effect, FileSystem, Option, Path, Tracer } from "effect"; +import { TestClock } from "effect/testing"; +import { withEnvVar } from "../../tests/helpers/command-mocks.ts"; import { type UpgradeNoticeDeps, formatUpgradeNotice, @@ -82,10 +70,27 @@ describe("formatUpgradeNotice", () => { }); }); -describe("runUpgradeNotice", () => { - let workdir: string; +const isRoot = process.getuid?.() === 0; + +const notice = (deps: UpgradeNoticeDeps) => Effect.promise(() => runUpgradeNotice(deps)); + +/** Runs `body` with `target` at `mode`, then restores `restore` so the temp dir can be removed. */ +const withMode = ( + target: string, + mode: number, + restore: number, + body: Effect.Effect, +) => + Effect.flatMap(FileSystem.FileSystem, (fs) => + Effect.acquireUseRelease( + fs.chmod(target, mode), + () => body, + () => Effect.orDie(fs.chmod(target, restore)), + ), + ); - function setup(opts: { +layer(BunServices.layer)("runUpgradeNotice", (it) => { + const setup = Effect.fnUntraced(function* (opts: { readonly env?: Record; readonly args?: ReadonlyArray; readonly latestTag?: string; @@ -95,25 +100,36 @@ describe("runUpgradeNotice", () => { readonly cacheAgeMs?: number; readonly currentVersion?: string; }) { - workdir = mkdtempSync(join(tmpdir(), "supabase-upgrade-notice-")); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + /* Caches the notice itself writes carry real mtimes, which this earlier + clock always sees as fresh. */ + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe("2020-01-01T00:00:00Z"))); + const clock = yield* Clock.Clock; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-upgrade-notice-" }); if (opts.project !== false) { - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), 'project_id = "demo"\n'); + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "config.toml"), + 'project_id = "demo"\n', + ); } + const cachePath = path.join(workdir, "supabase", ".temp", "cli-latest"); if (opts.cacheContent !== undefined) { - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - const cacheFile = join(workdir, "supabase", ".temp", "cli-latest"); - writeFileSync(cacheFile, opts.cacheContent); + yield* fs.makeDirectory(path.join(workdir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString(cachePath, opts.cacheContent); if (opts.cacheAgeMs !== undefined) { - const then = new Date(Date.now() - opts.cacheAgeMs); - utimesSync(cacheFile, then, then); + const then = DateTime.toDateUtc( + DateTime.makeUnsafe(clock.currentTimeMillisUnsafe() - opts.cacheAgeMs), + ); + yield* fs.utimes(cachePath, then, then); } } // A symlinked SUPABASE_HOME disables the user-level cache, so runs outside a // project record no backoff. - const supabaseHome = join(workdir, "home"); - mkdirSync(join(workdir, "home-target")); - symlinkSync(join(workdir, "home-target"), supabaseHome); + const supabaseHome = path.join(workdir, "home"); + yield* fs.makeDirectory(path.join(workdir, "home-target")); + yield* fs.symlink(path.join(workdir, "home-target"), supabaseHome); let fetchCalls = 0; const stderr: Array = []; const deps: UpgradeNoticeDeps = { @@ -122,7 +138,7 @@ describe("runUpgradeNotice", () => { cwd: workdir, currentVersion: opts.currentVersion ?? "2.113.0", supabaseHome, - now: Date.now, + now: () => clock.currentTimeMillisUnsafe(), fetchLatestTag: () => { fetchCalls += 1; return opts.fetchFails === true @@ -134,6 +150,9 @@ describe("runUpgradeNotice", () => { }, }; return { + fs, + path, + workdir, deps, get fetchCalls() { return fetchCalls; @@ -141,511 +160,643 @@ describe("runUpgradeNotice", () => { get stderr() { return stripVTControlCharacters(stderr.join("")); }, - cachePath: join(workdir, "supabase", ".temp", "cli-latest"), - cleanup: () => rmSync(workdir, { recursive: true, force: true }), + cachePath, }; - } - - it("prints the notice and caches the tag when a newer release exists", async () => { - const ctx = setup({}); - await runUpgradeNotice(ctx.deps); - expect(ctx.fetchCalls).toBe(1); - expect(ctx.stderr).toContain("A new version of Supabase CLI is available: v2.114.0"); - expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.114.0"); - ctx.cleanup(); }); - it("SUPABASE_NO_UPDATE_NOTIFIER=1 skips the fetch and the notice entirely", async () => { - const ctx = setup({ env: { SUPABASE_NO_UPDATE_NOTIFIER: "1" } }); - await runUpgradeNotice(ctx.deps); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toBe(""); - ctx.cleanup(); - }); + it.effect("prints the notice and caches the tag when a newer release exists", () => + Effect.gen(function* () { + const ctx = yield* setup({}); + yield* notice(ctx.deps); + expect(ctx.fetchCalls).toBe(1); + expect(ctx.stderr).toContain("A new version of Supabase CLI is available: v2.114.0"); + expect(yield* ctx.fs.readFileString(ctx.cachePath)).toBe("v2.114.0"); + }), + ); - it("stays silent when already on the latest release", async () => { - const ctx = setup({ latestTag: "v2.113.0" }); - await runUpgradeNotice(ctx.deps); - expect(ctx.stderr).toBe(""); - ctx.cleanup(); - }); + it.effect("SUPABASE_NO_UPDATE_NOTIFIER=1 skips the fetch and the notice entirely", () => + Effect.gen(function* () { + const ctx = yield* setup({ env: { SUPABASE_NO_UPDATE_NOTIFIER: "1" } }); + yield* notice(ctx.deps); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toBe(""); + }), + ); - it("reads a fresh cache instead of fetching", async () => { - const ctx = setup({ cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); - await runUpgradeNotice(ctx.deps); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toContain("v2.115.0"); - ctx.cleanup(); - }); + it.effect("stays silent when already on the latest release", () => + Effect.gen(function* () { + const ctx = yield* setup({ latestTag: "v2.113.0" }); + yield* notice(ctx.deps); + expect(ctx.stderr).toBe(""); + }), + ); - it("keeps a cache fresh at the exact ten-hour boundary", async () => { - const ctx = setup({ cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); - await runUpgradeNotice({ - ...ctx.deps, - now: () => statSync(ctx.cachePath).mtime.getTime() + 10 * 60 * 60 * 1000, - }); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toContain("v2.115.0"); - ctx.cleanup(); - }); + it.effect("reads a fresh cache instead of fetching", () => + Effect.gen(function* () { + const ctx = yield* setup({ cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); + yield* notice(ctx.deps); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toContain("v2.115.0"); + }), + ); - it("refetches when the cache is older than ten hours", async () => { - const ctx = setup({ cacheContent: "v2.115.0", cacheAgeMs: 11 * 60 * 60 * 1000 }); - await runUpgradeNotice(ctx.deps); - expect(ctx.fetchCalls).toBe(1); - expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.114.0"); - ctx.cleanup(); - }); + it.effect("keeps a cache fresh at the exact ten-hour boundary", () => + Effect.gen(function* () { + const ctx = yield* setup({ cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); + const mtime = Option.getOrThrow((yield* ctx.fs.stat(ctx.cachePath)).mtime).getTime(); + yield* notice({ ...ctx.deps, now: () => mtime + 10 * 60 * 60 * 1000 }); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toContain("v2.115.0"); + }), + ); - it("--version reads a fresh cache instead of fetching", async () => { - const ctx = setup({ args: ["--version"], cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); - await runUpgradeNotice(ctx.deps); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toContain("v2.115.0"); - ctx.cleanup(); - }); + it.effect("refetches when the cache is older than ten hours", () => + Effect.gen(function* () { + const ctx = yield* setup({ cacheContent: "v2.115.0", cacheAgeMs: 11 * 60 * 60 * 1000 }); + yield* notice(ctx.deps); + expect(ctx.fetchCalls).toBe(1); + expect(yield* ctx.fs.readFileString(ctx.cachePath)).toBe("v2.114.0"); + }), + ); - it("SUPABASE_DEBUG surfaces fetch failures without the --debug flag, like viper's AutomaticEnv", async () => { - const ctx = setup({ fetchFails: true, project: false, env: { SUPABASE_DEBUG: "1" } }); - await runUpgradeNotice(ctx.deps); - expect(ctx.stderr).toContain("Failed to fetch latest release"); - ctx.cleanup(); - }); + it.effect("--version reads a fresh cache instead of fetching", () => + Effect.gen(function* () { + const ctx = yield* setup({ + args: ["--version"], + cacheContent: "v2.115.0", + cacheAgeMs: 60_000, + }); + yield* notice(ctx.deps); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toContain("v2.115.0"); + }), + ); - it("--debug=true surfaces fetch failures and --debug=false silences them, like pflag", async () => { - const on = setup({ fetchFails: true, project: false, args: ["db", "start", "--debug=true"] }); - await runUpgradeNotice(on.deps); - expect(on.stderr).toContain("Failed to fetch latest release"); - on.cleanup(); - - // A set flag (`--debug=false`) beats SUPABASE_DEBUG. - const off = setup({ - fetchFails: true, - project: false, - args: ["db", "start", "--debug=false"], - env: { SUPABASE_DEBUG: "1" }, - }); - await runUpgradeNotice(off.deps); - expect(off.stderr).toBe(""); - off.cleanup(); - }); + it.effect( + "SUPABASE_DEBUG surfaces fetch failures without the --debug flag, like viper's AutomaticEnv", + () => + Effect.gen(function* () { + const ctx = yield* setup({ + fetchFails: true, + project: false, + env: { SUPABASE_DEBUG: "1" }, + }); + yield* notice(ctx.deps); + expect(ctx.stderr).toContain("Failed to fetch latest release"); + }), + ); - it("a built-in ignores SUPABASE_DEBUG but still honors the --debug flag, like cobra's init order", async () => { - const viaEnv = setup({ - fetchFails: true, - project: false, - env: { SUPABASE_DEBUG: "1" }, - args: ["--version"], - }); - await runUpgradeNotice(viaEnv.deps); - expect(viaEnv.stderr).toBe(""); - viaEnv.cleanup(); - - const viaFlag = setup({ fetchFails: true, project: false, args: ["--version", "--debug"] }); - await runUpgradeNotice(viaFlag.deps); - expect(viaFlag.stderr).toContain("Failed to fetch latest release: offline"); - viaFlag.cleanup(); - }); + it.effect( + "--debug=true surfaces fetch failures and --debug=false silences them, like pflag", + () => + Effect.gen(function* () { + const on = yield* setup({ + fetchFails: true, + project: false, + args: ["db", "start", "--debug=true"], + }); + yield* notice(on.deps); + expect(on.stderr).toContain("Failed to fetch latest release"); + + // A set flag (`--debug=false`) beats SUPABASE_DEBUG. + const off = yield* setup({ + fetchFails: true, + project: false, + args: ["db", "start", "--debug=false"], + env: { SUPABASE_DEBUG: "1" }, + }); + yield* notice(off.deps); + expect(off.stderr).toBe(""); + }), + ); - it("a failed fetch stays silent and writes an empty cache to back off", async () => { - const ctx = setup({ fetchFails: true }); - await runUpgradeNotice(ctx.deps); - expect(ctx.stderr).toBe(""); - expect(readFileSync(ctx.cachePath, "utf8")).toBe(""); - ctx.cleanup(); - }); + it.effect( + "a built-in ignores SUPABASE_DEBUG but still honors the --debug flag, like cobra's init order", + () => + Effect.gen(function* () { + const viaEnv = yield* setup({ + fetchFails: true, + project: false, + env: { SUPABASE_DEBUG: "1" }, + args: ["--version"], + }); + yield* notice(viaEnv.deps); + expect(viaEnv.stderr).toBe(""); + + const viaFlag = yield* setup({ + fetchFails: true, + project: false, + args: ["--version", "--debug"], + }); + yield* notice(viaFlag.deps); + expect(viaFlag.stderr).toContain("Failed to fetch latest release: offline"); + }), + ); - it("a failed fetch surfaces its error under --debug when no cache can record the backoff", async () => { - const ctx = setup({ fetchFails: true, project: false, args: ["db", "start", "--debug"] }); - await runUpgradeNotice(ctx.deps); - expect(ctx.stderr).toContain("Failed to fetch latest release"); - expect(ctx.stderr).not.toContain("A new version of Supabase CLI is available"); - ctx.cleanup(); - }); + it.effect("a failed fetch stays silent and writes an empty cache to back off", () => + Effect.gen(function* () { + const ctx = yield* setup({ fetchFails: true }); + yield* notice(ctx.deps); + expect(ctx.stderr).toBe(""); + expect(yield* ctx.fs.readFileString(ctx.cachePath)).toBe(""); + }), + ); - it("a --debug operand after -- or consumed by a value flag is not the debug flag, like pflag", async () => { - const afterTerminator = setup({ - fetchFails: true, - project: false, - args: ["db", "start", "--", "--debug"], - }); - await runUpgradeNotice(afterTerminator.deps); - expect(afterTerminator.stderr).toBe(""); - afterTerminator.cleanup(); - - const consumedValue = setup({ - fetchFails: true, - project: false, - args: ["--profile", "--debug", "db", "start"], - }); - await runUpgradeNotice(consumedValue.deps); - expect(consumedValue.stderr).toBe(""); - consumedValue.cleanup(); - }); + it.effect( + "a failed fetch surfaces its error under --debug when no cache can record the backoff", + () => + Effect.gen(function* () { + const ctx = yield* setup({ + fetchFails: true, + project: false, + args: ["db", "start", "--debug"], + }); + yield* notice(ctx.deps); + expect(ctx.stderr).toContain("Failed to fetch latest release"); + expect(ctx.stderr).not.toContain("A new version of Supabase CLI is available"); + }), + ); + + it.effect( + "a --debug operand after -- or consumed by a value flag is not the debug flag, like pflag", + () => + Effect.gen(function* () { + const afterTerminator = yield* setup({ + fetchFails: true, + project: false, + args: ["db", "start", "--", "--debug"], + }); + yield* notice(afterTerminator.deps); + expect(afterTerminator.stderr).toBe(""); + + const consumedValue = yield* setup({ + fetchFails: true, + project: false, + args: ["--profile", "--debug", "db", "start"], + }); + yield* notice(consumedValue.deps); + expect(consumedValue.stderr).toBe(""); + }), + ); - it.skipIf(process.getuid?.() === 0)( - "a cache write failure reports the stable cli-latest path", - async () => { - const ctx = setup({ args: ["db", "start", "--debug"] }); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - chmodSync(join(workdir, "supabase", ".temp"), 0o555); - await runUpgradeNotice(ctx.deps); - chmodSync(join(workdir, "supabase", ".temp"), 0o755); + it.effect.skipIf(isRoot)("a cache write failure reports the stable cli-latest path", () => + Effect.gen(function* () { + const ctx = yield* setup({ args: ["db", "start", "--debug"] }); + const tempDir = ctx.path.join(ctx.workdir, "supabase", ".temp"); + yield* ctx.fs.makeDirectory(tempDir, { recursive: true }); + yield* withMode(tempDir, 0o555, 0o755, notice(ctx.deps)); expect(ctx.stderr).toContain("failed to write file"); expect(ctx.stderr).toContain("cli-latest"); - ctx.cleanup(); - }, + }), ); - it.skipIf(process.platform === "win32")( + it.effect.skipIf(process.platform === "win32")( "creates the cache directory and file with Go-compatible modes", - async () => { - const ctx = setup({}); - const previousUmask = process.umask(0); - try { - await runUpgradeNotice(ctx.deps); - } finally { - process.umask(previousUmask); - } - expect(statSync(join(workdir, "supabase", ".temp")).mode & 0o777).toBe(0o755); - expect(statSync(ctx.cachePath).mode & 0o777).toBe(0o644); - ctx.cleanup(); - }, + () => + Effect.gen(function* () { + const ctx = yield* setup({}); + yield* Effect.acquireUseRelease( + Effect.sync(() => process.umask(0)), + () => notice(ctx.deps), + (previousUmask) => Effect.sync(() => process.umask(previousUmask)), + ); + const tempDir = ctx.path.join(ctx.workdir, "supabase", ".temp"); + expect((yield* ctx.fs.stat(tempDir)).mode & 0o777).toBe(0o755); + expect((yield* ctx.fs.stat(ctx.cachePath)).mode & 0o777).toBe(0o644); + }), ); - it.skipIf(process.platform === "win32")( + it.effect.skipIf(process.platform === "win32")( "updates a writable cache without requiring write access to its directory", - async () => { - const ctx = setup({ - cacheContent: "v2.115.0", - cacheAgeMs: 11 * 60 * 60 * 1000, - }); - const tempDir = join(workdir, "supabase", ".temp"); - chmodSync(tempDir, 0o555); - try { - await runUpgradeNotice(ctx.deps); - } finally { - chmodSync(tempDir, 0o755); - } - expect(ctx.stderr).not.toContain("failed to write file"); - expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.114.0"); - ctx.cleanup(); - }, + () => + Effect.gen(function* () { + const ctx = yield* setup({ + cacheContent: "v2.115.0", + cacheAgeMs: 11 * 60 * 60 * 1000, + }); + const tempDir = ctx.path.join(ctx.workdir, "supabase", ".temp"); + yield* withMode(tempDir, 0o555, 0o755, notice(ctx.deps)); + expect(ctx.stderr).not.toContain("failed to write file"); + expect(yield* ctx.fs.readFileString(ctx.cachePath)).toBe("v2.114.0"); + }), ); - it("a --debug consumed by the leaf command's own value flag is not the debug flag, like pflag", async () => { - // `login --name --debug`: `--debug` is consumed as `--name`'s value; the - // real CLI passes the resolved leaf's value-flag predicate into the hook. - const ctx = setup({ fetchFails: true, project: false, args: ["login", "--name", "--debug"] }); - await runUpgradeNotice({ - ...ctx.deps, - isValueTakingFlagToken: (token) => token === "--name", - }); - expect(ctx.stderr).toBe(""); - ctx.cleanup(); - }); - - it("a false root version flag before a leaf runs the normal path, workdir included", async () => { - // `--version=false ` is not the version built-in, so the cache - // resolves under `--workdir`. - const ctx = setup({ project: false }); - const flagged = join(workdir, "flagged"); - mkdirSync(join(flagged, "supabase"), { recursive: true }); - await runUpgradeNotice({ - ...ctx.deps, - args: ["--workdir", flagged, "--version=false", "db", "push"], - }); - expect(readFileSync(join(flagged, "supabase", ".temp", "cli-latest"), "utf8")).toBe("v2.114.0"); - ctx.cleanup(); - }); + it.effect( + "a --debug consumed by the leaf command's own value flag is not the debug flag, like pflag", + () => + Effect.gen(function* () { + // `login --name --debug`: `--debug` is consumed as `--name`'s value; the + // real CLI passes the resolved leaf's value-flag predicate into the hook. + const ctx = yield* setup({ + fetchFails: true, + project: false, + args: ["login", "--name", "--debug"], + }); + yield* notice({ ...ctx.deps, isValueTakingFlagToken: (token) => token === "--name" }); + expect(ctx.stderr).toBe(""); + }), + ); - it.skipIf(process.getuid?.() === 0)( - "an existing read-only cache file fails the backoff write, like Go's direct open", - async () => { - const ctx = setup({ - args: ["db", "start", "--debug"], - cacheContent: "v2.115.0", - cacheAgeMs: 11 * 60 * 60 * 1000, + it.effect("a false root version flag before a leaf runs the normal path, workdir included", () => + Effect.gen(function* () { + // `--version=false ` is not the version built-in, so the cache + // resolves under `--workdir`. + const ctx = yield* setup({ project: false }); + const flagged = ctx.path.join(ctx.workdir, "flagged"); + yield* ctx.fs.makeDirectory(ctx.path.join(flagged, "supabase"), { recursive: true }); + yield* notice({ + ...ctx.deps, + args: ["--workdir", flagged, "--version=false", "db", "push"], }); - chmodSync(ctx.cachePath, 0o444); - await runUpgradeNotice(ctx.deps); - chmodSync(ctx.cachePath, 0o644); - expect(ctx.stderr).toContain("failed to write file"); - expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.115.0"); - ctx.cleanup(); - }, + expect( + yield* ctx.fs.readFileString(ctx.path.join(flagged, "supabase", ".temp", "cli-latest")), + ).toBe("v2.114.0"); + }), ); - it("project dotenv SUPABASE_DEBUG surfaces diagnostics, like godotenv before the Execute tail", async () => { - // The symlinked .temp disables the backoff write, so the fetch error is - // what remains to log. - const ctx = setup({ fetchFails: true }); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_DEBUG=1\n"); - mkdirSync(join(workdir, "elsewhere"), { recursive: true }); - symlinkSync(join(workdir, "elsewhere"), join(workdir, "supabase", ".temp")); - await runUpgradeNotice(ctx.deps); - expect(ctx.stderr).toContain("Failed to fetch latest release"); - ctx.cleanup(); - - // A shell env that defines the key blocks the chain. - const blocked = setup({ fetchFails: true, env: { SUPABASE_DEBUG: "" } }); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_DEBUG=1\n"); - mkdirSync(join(workdir, "elsewhere"), { recursive: true }); - symlinkSync(join(workdir, "elsewhere"), join(workdir, "supabase", ".temp")); - await runUpgradeNotice(blocked.deps); - expect(blocked.stderr).toBe(""); - blocked.cleanup(); - }); - - it("a failed fetch inside a project stays silent under --debug, matching Go's backoff", async () => { - const ctx = setup({ fetchFails: true, args: ["db", "start", "--debug"] }); - await runUpgradeNotice(ctx.deps); - // The empty-cache backoff write succeeds, so no debug line is emitted. - expect(ctx.stderr).toBe(""); - expect(readFileSync(ctx.cachePath, "utf8")).toBe(""); - ctx.cleanup(); - }); - - it("resolves the cache under --workdir, then SUPABASE_WORKDIR, ahead of the cwd walk", async () => { - const ctx = setup({ project: false }); - const flagDir = join(workdir, "flag-project"); - const envDir = join(workdir, "env-project"); - for (const dir of [flagDir, envDir]) { - mkdirSync(join(dir, "supabase"), { recursive: true }); - } - const flagCtx = { ...ctx.deps, args: ["db", "start", "--workdir", flagDir] }; - await runUpgradeNotice(flagCtx); - expect(readFileSync(join(flagDir, "supabase", ".temp", "cli-latest"), "utf8")).toBe("v2.114.0"); - - const lastWinsCtx = { - ...ctx.deps, - args: ["db", "start", "--workdir", join(workdir, "ignored"), `--workdir=${flagDir}`], - }; - await runUpgradeNotice(lastWinsCtx); - expect(readFileSync(join(flagDir, "supabase", ".temp", "cli-latest"), "utf8")).toBe("v2.114.0"); - - const envCtx = { ...ctx.deps, env: { SUPABASE_WORKDIR: envDir } }; - await runUpgradeNotice(envCtx); - expect(readFileSync(join(envDir, "supabase", ".temp", "cli-latest"), "utf8")).toBe("v2.114.0"); - ctx.cleanup(); - }); + it.effect.skipIf(isRoot)( + "an existing read-only cache file fails the backoff write, like Go's direct open", + () => + Effect.gen(function* () { + const ctx = yield* setup({ + args: ["db", "start", "--debug"], + cacheContent: "v2.115.0", + cacheAgeMs: 11 * 60 * 60 * 1000, + }); + yield* withMode(ctx.cachePath, 0o444, 0o644, notice(ctx.deps)); + expect(ctx.stderr).toContain("failed to write file"); + expect(yield* ctx.fs.readFileString(ctx.cachePath)).toBe("v2.115.0"); + }), + ); - it("uses the successful command's resolved working directory without re-resolving flags", async () => { - const ctx = setup({ project: false, args: ["bootstrap", "--workdir", "prompted"] }); - const prompted = join(workdir, "prompted"); - mkdirSync(join(prompted, "supabase"), { recursive: true }); + it.effect( + "project dotenv SUPABASE_DEBUG surfaces diagnostics, like godotenv before the Execute tail", + () => + Effect.gen(function* () { + // The symlinked .temp disables the backoff write, so the fetch error is + // what remains to log. + const ctx = yield* setup({ fetchFails: true }); + const { fs, path } = ctx; + yield* fs.writeFileString(path.join(ctx.workdir, "supabase", ".env"), "SUPABASE_DEBUG=1\n"); + yield* fs.makeDirectory(path.join(ctx.workdir, "elsewhere"), { recursive: true }); + yield* fs.symlink( + path.join(ctx.workdir, "elsewhere"), + path.join(ctx.workdir, "supabase", ".temp"), + ); + yield* notice(ctx.deps); + expect(ctx.stderr).toContain("Failed to fetch latest release"); + + // A shell env that defines the key blocks the chain. + const blocked = yield* setup({ fetchFails: true, env: { SUPABASE_DEBUG: "" } }); + yield* fs.writeFileString( + path.join(blocked.workdir, "supabase", ".env"), + "SUPABASE_DEBUG=1\n", + ); + yield* fs.makeDirectory(path.join(blocked.workdir, "elsewhere"), { recursive: true }); + yield* fs.symlink( + path.join(blocked.workdir, "elsewhere"), + path.join(blocked.workdir, "supabase", ".temp"), + ); + yield* notice(blocked.deps); + expect(blocked.stderr).toBe(""); + }), + ); - await runUpgradeNotice({ ...ctx.deps, resolvedCwd: prompted }); + it.effect( + "a failed fetch inside a project stays silent under --debug, matching Go's backoff", + () => + Effect.gen(function* () { + const ctx = yield* setup({ fetchFails: true, args: ["db", "start", "--debug"] }); + yield* notice(ctx.deps); + // The empty-cache backoff write succeeds, so no debug line is emitted. + expect(ctx.stderr).toBe(""); + expect(yield* ctx.fs.readFileString(ctx.cachePath)).toBe(""); + }), + ); - expect(readFileSync(join(prompted, "supabase", ".temp", "cli-latest"), "utf8")).toBe( - "v2.114.0", - ); - expect(() => readFileSync(ctx.cachePath, "utf8")).toThrow(); - ctx.cleanup(); - }); + it.effect( + "resolves the cache under --workdir, then SUPABASE_WORKDIR, ahead of the cwd walk", + () => + Effect.gen(function* () { + const ctx = yield* setup({ project: false }); + const { fs, path } = ctx; + const flagDir = path.join(ctx.workdir, "flag-project"); + const envDir = path.join(ctx.workdir, "env-project"); + for (const dir of [flagDir, envDir]) { + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + } + const flagCtx = { ...ctx.deps, args: ["db", "start", "--workdir", flagDir] }; + yield* notice(flagCtx); + expect( + yield* fs.readFileString(path.join(flagDir, "supabase", ".temp", "cli-latest")), + ).toBe("v2.114.0"); + + const lastWinsCtx = { + ...ctx.deps, + args: [ + "db", + "start", + "--workdir", + path.join(ctx.workdir, "ignored"), + `--workdir=${flagDir}`, + ], + }; + yield* notice(lastWinsCtx); + expect( + yield* fs.readFileString(path.join(flagDir, "supabase", ".temp", "cli-latest")), + ).toBe("v2.114.0"); + + const envCtx = { ...ctx.deps, env: { SUPABASE_WORKDIR: envDir } }; + yield* notice(envCtx); + expect(yield* fs.readFileString(path.join(envDir, "supabase", ".temp", "cli-latest"))).toBe( + "v2.114.0", + ); + }), + ); - it("an explicit empty --workdir beats SUPABASE_WORKDIR and falls back to the walk, like viper", async () => { - const ctx = setup({}); - const envDir = join(workdir, "env-project"); - mkdirSync(join(envDir, "supabase"), { recursive: true }); - await runUpgradeNotice({ - ...ctx.deps, - args: ["db", "start", "--workdir="], - env: { SUPABASE_WORKDIR: envDir }, - }); - // The set-but-empty flag suppresses the env, so the walk finds the real project. - expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.114.0"); - expect(() => readFileSync(join(envDir, "supabase", ".temp", "cli-latest"), "utf8")).toThrow(); - ctx.cleanup(); - }); + it.effect( + "uses the successful command's resolved working directory without re-resolving flags", + () => + Effect.gen(function* () { + const ctx = yield* setup({ project: false, args: ["bootstrap", "--workdir", "prompted"] }); + const prompted = ctx.path.join(ctx.workdir, "prompted"); + yield* ctx.fs.makeDirectory(ctx.path.join(prompted, "supabase"), { recursive: true }); + + yield* notice({ ...ctx.deps, resolvedCwd: prompted }); + + expect( + yield* ctx.fs.readFileString(ctx.path.join(prompted, "supabase", ".temp", "cli-latest")), + ).toBe("v2.114.0"); + expect(yield* ctx.fs.exists(ctx.cachePath)).toBe(false); + }), + ); - it("a project dotenv SUPABASE_NO_UPDATE_NOTIFIER opt-out suppresses the notice, like godotenv", async () => { - const ctx = setup({}); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_NO_UPDATE_NOTIFIER=1\n"); - await runUpgradeNotice(ctx.deps); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toBe(""); - ctx.cleanup(); - }); + it.effect( + "an explicit empty --workdir beats SUPABASE_WORKDIR and falls back to the walk, like viper", + () => + Effect.gen(function* () { + const ctx = yield* setup({}); + const envDir = ctx.path.join(ctx.workdir, "env-project"); + yield* ctx.fs.makeDirectory(ctx.path.join(envDir, "supabase"), { recursive: true }); + yield* notice({ + ...ctx.deps, + args: ["db", "start", "--workdir="], + env: { SUPABASE_WORKDIR: envDir }, + }); + // The set-but-empty flag suppresses the env, so the walk finds the real project. + expect(yield* ctx.fs.readFileString(ctx.cachePath)).toBe("v2.114.0"); + expect(yield* ctx.fs.exists(ctx.path.join(envDir, "supabase", ".temp", "cli-latest"))).toBe( + false, + ); + }), + ); - it("a shell env that defines the key beats the project dotenv, like godotenv's no-override", async () => { - // Defined-but-unparseable in the shell env still blocks the project - // dotenv from overriding, and an empty value keeps the notifier on. - const ctx = setup({ env: { SUPABASE_NO_UPDATE_NOTIFIER: "" } }); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_NO_UPDATE_NOTIFIER=1\n"); - await runUpgradeNotice(ctx.deps); - expect(ctx.stderr).toContain("A new version of Supabase CLI is available"); - ctx.cleanup(); - }); + it.effect( + "a project dotenv SUPABASE_NO_UPDATE_NOTIFIER opt-out suppresses the notice, like godotenv", + () => + Effect.gen(function* () { + const ctx = yield* setup({}); + yield* ctx.fs.writeFileString( + ctx.path.join(ctx.workdir, "supabase", ".env"), + "SUPABASE_NO_UPDATE_NOTIFIER=1\n", + ); + yield* notice(ctx.deps); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toBe(""); + }), + ); - it("--help ignores the project dotenv opt-out — Go never loads config for the built-ins", async () => { - const ctx = setup({ args: ["--help"] }); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_NO_UPDATE_NOTIFIER=1\n"); - await runUpgradeNotice(ctx.deps); - expect(ctx.stderr).toContain("A new version of Supabase CLI is available"); - ctx.cleanup(); - }); + it.effect( + "a shell env that defines the key beats the project dotenv, like godotenv's no-override", + () => + Effect.gen(function* () { + // Defined-but-unparseable in the shell env still blocks the project + // dotenv from overriding, and an empty value keeps the notifier on. + const ctx = yield* setup({ env: { SUPABASE_NO_UPDATE_NOTIFIER: "" } }); + yield* ctx.fs.writeFileString( + ctx.path.join(ctx.workdir, "supabase", ".env"), + "SUPABASE_NO_UPDATE_NOTIFIER=1\n", + ); + yield* notice(ctx.deps); + expect(ctx.stderr).toContain("A new version of Supabase CLI is available"); + }), + ); - it("resolves --help and --version against the bare cwd, ignoring --workdir, like Go", async () => { - const ctx = setup({ project: false }); - const flagged = join(workdir, "flagged"); - mkdirSync(join(flagged, "supabase"), { recursive: true }); - for (const args of [ - ["--workdir", flagged, "--help"], - ["--workdir", flagged, "--version"], - // Valued spellings request the same built-ins; even a false value does, - // since the non-runnable root/group serves help before running anything. - ["--workdir", flagged, "--help=true"], - ["branches", "--workdir", flagged, "-h=1"], - ["--workdir", flagged, "--version=true"], - ["branches", "--workdir", flagged, "--help=true", "--help=false"], - // The space-form operand never stops the root version built-in. - ["--workdir", flagged, "--version", "true"], - ]) { - await runUpgradeNotice({ ...ctx.deps, args }); - } - expect(() => readFileSync(join(flagged, "supabase", ".temp", "cli-latest"), "utf8")).toThrow(); - // A subcommand's own value-taking --version still resolves the project. - await runUpgradeNotice({ - ...ctx.deps, - args: ["db", "reset", "--workdir", flagged, "--version", "20240101000000"], - }); - expect(readFileSync(join(flagged, "supabase", ".temp", "cli-latest"), "utf8")).toBe("v2.114.0"); - ctx.cleanup(); - }); + it.effect( + "--help ignores the project dotenv opt-out — Go never loads config for the built-ins", + () => + Effect.gen(function* () { + const ctx = yield* setup({ args: ["--help"] }); + yield* ctx.fs.writeFileString( + ctx.path.join(ctx.workdir, "supabase", ".env"), + "SUPABASE_NO_UPDATE_NOTIFIER=1\n", + ); + yield* notice(ctx.deps); + expect(ctx.stderr).toContain("A new version of Supabase CLI is available"); + }), + ); - it("does not treat Effect's -v shorthand as Go's root version flag", async () => { - const ctx = setup({ project: false }); - const flagged = join(workdir, "flagged"); - const cacheFile = join(flagged, "supabase", ".temp", "cli-latest"); - mkdirSync(join(flagged, "supabase", ".temp"), { recursive: true }); - writeFileSync(cacheFile, "v2.115.0"); + it.effect("resolves --help and --version against the bare cwd, ignoring --workdir, like Go", () => + Effect.gen(function* () { + const ctx = yield* setup({ project: false }); + const flagged = ctx.path.join(ctx.workdir, "flagged"); + const flaggedCache = ctx.path.join(flagged, "supabase", ".temp", "cli-latest"); + yield* ctx.fs.makeDirectory(ctx.path.join(flagged, "supabase"), { recursive: true }); + for (const args of [ + ["--workdir", flagged, "--help"], + ["--workdir", flagged, "--version"], + // Valued spellings request the same built-ins; even a false value does, + // since the non-runnable root/group serves help before running anything. + ["--workdir", flagged, "--help=true"], + ["branches", "--workdir", flagged, "-h=1"], + ["--workdir", flagged, "--version=true"], + ["branches", "--workdir", flagged, "--help=true", "--help=false"], + // The space-form operand never stops the root version built-in. + ["--workdir", flagged, "--version", "true"], + ]) { + yield* notice({ ...ctx.deps, args }); + } + expect(yield* ctx.fs.exists(flaggedCache)).toBe(false); + // A subcommand's own value-taking --version still resolves the project. + yield* notice({ + ...ctx.deps, + args: ["db", "reset", "--workdir", flagged, "--version", "20240101000000"], + }); + expect(yield* ctx.fs.readFileString(flaggedCache)).toBe("v2.114.0"); + }), + ); - await runUpgradeNotice({ - ...ctx.deps, - args: ["--workdir", flagged, "-v"], - }); + it.effect("does not treat Effect's -v shorthand as Go's root version flag", () => + Effect.gen(function* () { + const ctx = yield* setup({ project: false }); + const flagged = ctx.path.join(ctx.workdir, "flagged"); + const cacheFile = ctx.path.join(flagged, "supabase", ".temp", "cli-latest"); + yield* ctx.fs.makeDirectory(ctx.path.join(flagged, "supabase", ".temp"), { recursive: true }); + yield* ctx.fs.writeFileString(cacheFile, "v2.115.0"); + + yield* notice({ + ...ctx.deps, + args: ["--workdir", flagged, "-v"], + }); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toContain("v2.115.0"); - ctx.cleanup(); - }); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toContain("v2.115.0"); + }), + ); - it("a clean group-help exit resolves against the bare cwd, ignoring --workdir", async () => { - const ctx = setup({ project: false }); - const flagged = join(workdir, "flagged"); - mkdirSync(join(flagged, "supabase"), { recursive: true }); - await runUpgradeNotice({ - ...ctx.deps, - args: ["branches", "--workdir", flagged], - cleanShowHelp: true, - }); - expect(() => readFileSync(join(flagged, "supabase", ".temp", "cli-latest"), "utf8")).toThrow(); - expect(ctx.stderr).toContain("v2.114.0"); - ctx.cleanup(); - }); + it.effect("a clean group-help exit resolves against the bare cwd, ignoring --workdir", () => + Effect.gen(function* () { + const ctx = yield* setup({ project: false }); + const flagged = ctx.path.join(ctx.workdir, "flagged"); + yield* ctx.fs.makeDirectory(ctx.path.join(flagged, "supabase"), { recursive: true }); + yield* notice({ + ...ctx.deps, + args: ["branches", "--workdir", flagged], + cleanShowHelp: true, + }); + expect(yield* ctx.fs.exists(ctx.path.join(flagged, "supabase", ".temp", "cli-latest"))).toBe( + false, + ); + expect(ctx.stderr).toContain("v2.114.0"); + }), + ); - it("ignores a --workdir operand after the -- terminator, like cobra", async () => { - const ctx = setup({}); - const elsewhere = join(workdir, "elsewhere"); - mkdirSync(join(elsewhere, "supabase"), { recursive: true }); - const opCtx = { ...ctx.deps, args: ["db", "start", "--", "--workdir", elsewhere] }; - await runUpgradeNotice(opCtx); - expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.114.0"); - expect(() => - readFileSync(join(elsewhere, "supabase", ".temp", "cli-latest"), "utf8"), - ).toThrow(); - ctx.cleanup(); - }); + it.effect("ignores a --workdir operand after the -- terminator, like cobra", () => + Effect.gen(function* () { + const ctx = yield* setup({}); + const elsewhere = ctx.path.join(ctx.workdir, "elsewhere"); + yield* ctx.fs.makeDirectory(ctx.path.join(elsewhere, "supabase"), { recursive: true }); + const opCtx = { ...ctx.deps, args: ["db", "start", "--", "--workdir", elsewhere] }; + yield* notice(opCtx); + expect(yield* ctx.fs.readFileString(ctx.cachePath)).toBe("v2.114.0"); + expect( + yield* ctx.fs.exists(ctx.path.join(elsewhere, "supabase", ".temp", "cli-latest")), + ).toBe(false); + }), + ); - it("never reads through or writes through a symlinked cache file", async () => { - const ctx = setup({}); - const victim = join(workdir, "victim.txt"); - writeFileSync(victim, "v9.9.9"); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - symlinkSync(victim, ctx.cachePath); - await runUpgradeNotice(ctx.deps); - expect(ctx.fetchCalls).toBe(1); - expect(ctx.stderr).toContain("v2.114.0"); - expect(ctx.stderr).not.toContain("v9.9.9"); - expect(readFileSync(victim, "utf8")).toBe("v9.9.9"); - expect(lstatSync(ctx.cachePath).isSymbolicLink()).toBe(true); - ctx.cleanup(); - }); + it.effect("never reads through or writes through a symlinked cache file", () => + Effect.gen(function* () { + const ctx = yield* setup({}); + const victim = ctx.path.join(ctx.workdir, "victim.txt"); + yield* ctx.fs.writeFileString(victim, "v9.9.9"); + yield* ctx.fs.makeDirectory(ctx.path.join(ctx.workdir, "supabase", ".temp"), { + recursive: true, + }); + yield* ctx.fs.symlink(victim, ctx.cachePath); + yield* notice(ctx.deps); + expect(ctx.fetchCalls).toBe(1); + expect(ctx.stderr).toContain("v2.114.0"); + expect(ctx.stderr).not.toContain("v9.9.9"); + expect(yield* ctx.fs.readFileString(victim)).toBe("v9.9.9"); + expect(yield* ctx.fs.readLink(ctx.cachePath)).toBe(victim); + }), + ); - it("never writes through a cache symlink planted during the release fetch", async () => { - const ctx = setup({}); - const victim = join(workdir, "victim.txt"); - writeFileSync(victim, "v9.9.9"); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - - // Planting the symlink inside `fetchLatestTag` simulates a concurrent - // swap landing in the check-then-write window; `O_NOFOLLOW` makes the - // open fail with ELOOP instead of following it. - await runUpgradeNotice({ - ...ctx.deps, - fetchLatestTag: () => { - symlinkSync(victim, ctx.cachePath); - return Promise.resolve("v2.114.0"); - }, - }); + it.effect("never writes through a cache symlink planted during the release fetch", () => + Effect.gen(function* () { + const ctx = yield* setup({}); + const victim = ctx.path.join(ctx.workdir, "victim.txt"); + yield* ctx.fs.writeFileString(victim, "v9.9.9"); + yield* ctx.fs.makeDirectory(ctx.path.join(ctx.workdir, "supabase", ".temp"), { + recursive: true, + }); + const runPromise = Effect.runPromiseWith(yield* Effect.context()); + + // Planting the symlink inside `fetchLatestTag` simulates a concurrent + // swap landing in the check-then-write window; `O_NOFOLLOW` makes the + // open fail with ELOOP instead of following it. + yield* notice({ + ...ctx.deps, + fetchLatestTag: () => + runPromise(ctx.fs.symlink(victim, ctx.cachePath).pipe(Effect.as("v2.114.0"))), + }); - expect(ctx.stderr).toContain("A new version of Supabase CLI is available: v2.114.0"); - expect(readFileSync(victim, "utf8")).toBe("v9.9.9"); - expect(lstatSync(ctx.cachePath).isSymbolicLink()).toBe(true); - ctx.cleanup(); - }); + expect(ctx.stderr).toContain("A new version of Supabase CLI is available: v2.114.0"); + expect(yield* ctx.fs.readFileString(victim)).toBe("v9.9.9"); + expect(yield* ctx.fs.readLink(ctx.cachePath)).toBe(victim); + }), + ); - it.each(["supabase", "supabase/.temp"])( + it.effect.each(["supabase", "supabase/.temp"])( "never writes through a symlinked %s directory", - async (linked) => { - const ctx = setup({ project: false }); - const outside = join(workdir, "outside"); - mkdirSync(outside, { recursive: true }); - if (linked !== "supabase") mkdirSync(join(workdir, "supabase"), { recursive: true }); - symlinkSync(outside, join(workdir, linked)); - - await runUpgradeNotice(ctx.deps); - - expect(ctx.stderr).toContain("v2.114.0"); - expect(readdirSync(outside)).toEqual([]); - ctx.cleanup(); - }, + (linked) => + Effect.gen(function* () { + const ctx = yield* setup({ project: false }); + const outside = ctx.path.join(ctx.workdir, "outside"); + yield* ctx.fs.makeDirectory(outside, { recursive: true }); + if (linked !== "supabase") { + yield* ctx.fs.makeDirectory(ctx.path.join(ctx.workdir, "supabase"), { recursive: true }); + } + yield* ctx.fs.symlink(outside, ctx.path.join(ctx.workdir, linked)); + + yield* notice(ctx.deps); + + expect(ctx.stderr).toContain("v2.114.0"); + expect(yield* ctx.fs.readDirectory(outside)).toEqual([]); + }), ); - it.each(["", "/", "/."])( + it.effect.each(["", "/", "/."])( "never writes through a symlinked SUPABASE_HOME spelled with suffix %j", - async (suffix) => { - const ctx = setup({ project: false }); - await runUpgradeNotice({ ...ctx.deps, supabaseHome: `${ctx.deps.supabaseHome}${suffix}` }); - expect(readdirSync(join(workdir, "home-target"))).toEqual([]); - ctx.cleanup(); - }, + (suffix) => + Effect.gen(function* () { + const ctx = yield* setup({ project: false }); + yield* notice({ ...ctx.deps, supabaseHome: `${ctx.deps.supabaseHome}${suffix}` }); + expect(yield* ctx.fs.readDirectory(ctx.path.join(ctx.workdir, "home-target"))).toEqual([]); + }), ); - it("validates exact cache bytes and rejects embedded escape bytes", async () => { - for (const cacheContent of ["v2.115.0\n", "v2.115.0\r\n", "v2.115.0\u001b[31mboo"]) { - const ctx = setup({ cacheContent, cacheAgeMs: 60_000 }); - await runUpgradeNotice(ctx.deps); - expect(ctx.stderr).toBe(""); - ctx.cleanup(); - } - }); + it.effect("validates exact cache bytes and rejects embedded escape bytes", () => + Effect.gen(function* () { + for (const cacheContent of ["v2.115.0\n", "v2.115.0\r\n", "v2.115.0\u001b[31mboo"]) { + const ctx = yield* setup({ cacheContent, cacheAgeMs: 60_000 }); + yield* notice(ctx.deps); + expect(ctx.stderr).toBe(""); + } + }), + ); }); +const captureStderr = (body: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const written: Array = []; + const realWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: unknown) => { + written.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + return { written, realWrite }; + }), + ({ written }) => Effect.as(body, written), + ({ realWrite }) => + Effect.sync(() => { + process.stderr.write = realWrite; + }), + ); + /** * `delegatedToGo` is asserted directly rather than via a real proxied * command, so this doesn't depend on which commands still delegate to Go — * that set only shrinks (`docs/go-cli-porting-status.md`), and a test pinned * to one command would break the moment it's ported. */ -describe("upgradeNoticeHook", () => { - async function runHook(opts: { readonly delegatedToGo?: boolean; readonly cached?: boolean }) { - const workdir = mkdtempSync(join(tmpdir(), "supabase-upgrade-notice-hook-")); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), 'project_id = "demo"\n'); +layer(BunServices.layer)("upgradeNoticeHook", (it) => { + const runHook = Effect.fnUntraced(function* (opts: { + readonly delegatedToGo?: boolean; + readonly cached?: boolean; + }) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-upgrade-notice-hook-" }); + yield* fs.makeDirectory(path.join(workdir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "config.toml"), + 'project_id = "demo"\n', + ); if (opts.cached !== false) { - writeFileSync(join(workdir, "supabase", ".temp", "cli-latest"), "v99.99.99"); + yield* fs.writeFileString(path.join(workdir, "supabase", ".temp", "cli-latest"), "v99.99.99"); } const spans: Array = []; const tracer = Tracer.make({ @@ -656,17 +807,10 @@ describe("upgradeNoticeHook", () => { }, }); - const written: Array = []; - const realWrite = process.stderr.write.bind(process.stderr); - const realOptOut = process.env["SUPABASE_NO_UPDATE_NOTIFIER"]; - process.env["SUPABASE_NO_UPDATE_NOTIFIER"] = "0"; - process.stderr.write = ((chunk: unknown) => { - written.push(String(chunk)); - return true; - }) as typeof process.stderr.write; - - try { - await Effect.runPromise( + const written = yield* captureStderr( + withEnvVar( + "SUPABASE_NO_UPDATE_NOTIFIER", + "0", upgradeNoticeHook( ["db", "branch", "list"], { @@ -677,44 +821,47 @@ describe("upgradeNoticeHook", () => { }, () => Promise.resolve("v99.99.99"), ).pipe(Effect.withTracer(tracer), Effect.withTracerEnabled(true)), - ); - } finally { - process.stderr.write = realWrite; - if (realOptOut === undefined) delete process.env["SUPABASE_NO_UPDATE_NOTIFIER"]; - else process.env["SUPABASE_NO_UPDATE_NOTIFIER"] = realOptOut; - rmSync(workdir, { recursive: true, force: true }); - } + ), + ); const span = (name: string) => spans.find((candidate) => candidate.name === name); return { stderr: stripVTControlCharacters(written.join("")), span }; - } - - it("prints the notice for a natively handled command", async () => { - expect((await runHook({})).stderr).toContain( - "A new version of Supabase CLI is available: v99.99.99", - ); }); - it("stays silent when the run delegated to Go, which printed its own notice", async () => { - expect((await runHook({ delegatedToGo: true })).stderr).toBe(""); - }); + it.effect("prints the notice for a natively handled command", () => + Effect.gen(function* () { + expect((yield* runHook({})).stderr).toContain( + "A new version of Supabase CLI is available: v99.99.99", + ); + }), + ); - it("traces a cache hit on the check span without a fetch span", async () => { - const { span } = await runHook({}); - expect(Object.fromEntries(span("UpgradeNotice.check")!.attributes)).toEqual({ - "cache.location": "project", - "cache.hit": true, - }); - expect(span("UpgradeNotice.fetch")).toBeUndefined(); - }); + it.effect("stays silent when the run delegated to Go, which printed its own notice", () => + Effect.gen(function* () { + expect((yield* runHook({ delegatedToGo: true })).stderr).toBe(""); + }), + ); - it("traces a cache miss with the release fetch as a child of the check span", async () => { - const { span } = await runHook({ cached: false }); - const check = span("UpgradeNotice.check")!; - expect(Object.fromEntries(check.attributes)).toEqual({ - "cache.location": "project", - "cache.hit": false, - }); - const parent = span("UpgradeNotice.fetch")!.parent; - expect(Option.isSome(parent) ? parent.value.spanId : undefined).toBe(check.spanId); - }); + it.effect("traces a cache hit on the check span without a fetch span", () => + Effect.gen(function* () { + const { span } = yield* runHook({}); + expect(Object.fromEntries(span("UpgradeNotice.check")!.attributes)).toEqual({ + "cache.location": "project", + "cache.hit": true, + }); + expect(span("UpgradeNotice.fetch")).toBeUndefined(); + }), + ); + + it.effect("traces a cache miss with the release fetch as a child of the check span", () => + Effect.gen(function* () { + const { span } = yield* runHook({ cached: false }); + const check = span("UpgradeNotice.check")!; + expect(Object.fromEntries(check.attributes)).toEqual({ + "cache.location": "project", + "cache.hit": false, + }); + const parent = span("UpgradeNotice.fetch")!.parent; + expect(Option.isSome(parent) ? parent.value.spanId : undefined).toBe(check.spanId); + }), + ); }); diff --git a/apps/cli/src/commands/sso/add/add.integration.test.ts b/apps/cli/src/commands/sso/add/add.integration.test.ts index be7cb4b06c..5813fdbe16 100644 --- a/apps/cli/src/commands/sso/add/add.integration.test.ts +++ b/apps/cli/src/commands/sso/add/add.integration.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Predicate, Stdio } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, @@ -52,6 +51,8 @@ interface SetupOpts { * carries a `--profile` the parser would have consumed. */ profileFlag?: string; + /** The `SUPABASE_PROFILE` value `CommandSettings` captured. */ + profileEnv?: string; } function jsonResponse( @@ -144,7 +145,10 @@ function setup(opts: SetupOpts = {}) { }, }); - const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + profileEnvValue: Option.fromUndefinedOr(opts.profileEnv), + }); const layer = Layer.mergeAll( buildTestRuntime({ out, @@ -1042,9 +1046,6 @@ describe("sso add integration", () => { return path; }); - const withProfileEnv = (value: string | undefined, body: Effect.Effect) => - withEnvVar("SUPABASE_PROFILE", value, body); - it.effect( "profile emulation: --domains consuming --profile POSTs to the env profile's host, not the parsed file's", () => @@ -1054,6 +1055,7 @@ describe("sso add integration", () => { const { layer, api, cache } = setup({ cliArgs: ["sso", "add", "--type", "saml", "--domains", "--profile", alternate], profileFlag: alternate, + profileEnv: envProfile, }); return yield* Effect.gen(function* () { yield* ssoAdd(defaultFlags); @@ -1064,7 +1066,7 @@ describe("sso add integration", () => { ); expect(posts[0]?.body).toHaveProperty("domains", ["--profile"]); expect(cache.cachedApiUrl).toBe("http://reconciled.example"); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(envProfile, body)); + }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1096,7 +1098,7 @@ describe("sso add integration", () => { expect(dump).toContain(`failed to read profile: Unsupported Config Type ""`); } expect(api.requests.length).toBe(0); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); + }).pipe(Effect.provide(layer)); }, ); @@ -1115,7 +1117,7 @@ describe("sso add integration", () => { expect(posts[0]?.url).toBe( `http://second.example/v1/projects/${VALID_REF}/config/auth/sso/providers`, ); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); + }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1164,7 +1166,7 @@ describe("sso add integration", () => { ).toBe(false); } expect(api.requests.length).toBe(0); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); + }).pipe(Effect.provide(layer)); }, ); @@ -1184,7 +1186,7 @@ describe("sso add integration", () => { expect(posts[0]?.url).toBe( `${DEFAULT_API_URL}/v1/projects/${VALID_REF}/config/auth/sso/providers`, ); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); + }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(BunServices.layer)), ); }); diff --git a/apps/cli/src/commands/sso/update/update.integration.test.ts b/apps/cli/src/commands/sso/update/update.integration.test.ts index 83f008b70a..02ab712b94 100644 --- a/apps/cli/src/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/commands/sso/update/update.integration.test.ts @@ -16,7 +16,6 @@ import { } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; import { mockAnalytics, mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, @@ -1416,9 +1415,6 @@ describe("sso update integration", () => { return path; }); - const withProfileEnv = (value: string | undefined, body: Effect.Effect) => - withEnvVar("SUPABASE_PROFILE", value, body); - it.effect( "profile emulation: repeated --profile resolves last-wins — GET and PUT both target the last file's host", () => @@ -1442,7 +1438,7 @@ describe("sso update integration", () => { expect(api.requests.some((r) => r.url.startsWith("http://first.example/"))).toBe(false); expect(testSetup.stitchedResponses).toBeGreaterThan(0); expect(cache.cachedApiUrl).toBe("http://second.example"); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); + }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1469,7 +1465,7 @@ describe("sso update integration", () => { ); } expect(api.requests.some((r) => r.method === "PUT")).toBe(false); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); + }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1494,7 +1490,7 @@ describe("sso update integration", () => { expect(dump).toContain("unexpected error fetching identity provider:"); } expect(api.requests.some((r) => r.method === "PUT")).toBe(false); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); + }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1531,7 +1527,7 @@ describe("sso update integration", () => { const { domains } = yield* decodePutDomains(put?.body); expect([...domains].sort()).toEqual(["new.com", "old1.com"]); expect(cache.cachedAccessToken).toBeDefined(); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); + }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1563,7 +1559,7 @@ describe("sso update integration", () => { expect(dump).toContain("Access token not provided. Supply an access token by running"); } expect(api.requests).toHaveLength(0); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); + }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1599,7 +1595,7 @@ describe("sso update integration", () => { expect(dump).not.toContain("Access token not provided"); } expect(api.requests).toHaveLength(0); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); + }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1626,7 +1622,7 @@ describe("sso update integration", () => { yield* ssoUpdate({ ...defaultFlags, addDomains: ["new.com"] }); const put = api.requests.find((r) => r.method === "PUT"); expect(put?.body).toHaveProperty("domains", ["new.com"]); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); + }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1645,7 +1641,7 @@ describe("sso update integration", () => { expect(dump).toContain(`failed to read profile: Unsupported Config Type ""`); } expect(api.requests.length).toBe(0); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); + }).pipe(Effect.provide(layer)); }, ); @@ -1673,7 +1669,7 @@ describe("sso update integration", () => { expect(classified.error_fingerprint).toBe("tag:SsoUpdateNetworkError:api_response"); } expect(api.requests.some((r) => r.method === "PUT")).toBe(false); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); + }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1697,7 +1693,7 @@ describe("sso update integration", () => { expect(dump).toContain("unexpected error fetching identity provider: plain text body"); } expect(api.requests.some((r) => r.method === "PUT")).toBe(false); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); + }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1730,7 +1726,7 @@ describe("sso update integration", () => { "http://second.example/v1/organizations/acme/entitlements", ); expect(api.requests.some((r) => r.url.startsWith("http://first.example/"))).toBe(false); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); + }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1751,6 +1747,6 @@ describe("sso update integration", () => { ).toBe(false); } expect(api.requests.length).toBe(0); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); + }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/config/profile-file.ts b/apps/cli/src/config/profile-file.ts index 20a0c7187c..e3f69faa24 100644 --- a/apps/cli/src/config/profile-file.ts +++ b/apps/cli/src/config/profile-file.ts @@ -1,5 +1,4 @@ import { Data, Effect, FileSystem, Path } from "effect"; -import { resolveSupabaseHome } from "../shared/config/supabase-home.ts"; import { actionability, type CliErrorActionabilityDeclaration, @@ -24,14 +23,6 @@ export class ProfileSaveError extends Data.TaggedError("ProfileSaveError")<{ } } -export function profileFilePath( - path: Path.Path, - homeDir: string, - env?: Readonly>, -): string { - return path.join(resolveSupabaseHome(path, env ?? process.env, homeDir), "profile"); -} - /** Writes the profile name to the resolved profile path. Fatal on failure. */ export const saveProfileName = ( fs: FileSystem.FileSystem, From 2722eff68521911a8f4423d1f0d7882cda6ab78d Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:23:21 +0530 Subject: [PATCH 2/3] nit --- apps/cli/src/command-internal/pflag-reconcile.unit.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/cli/src/command-internal/pflag-reconcile.unit.test.ts b/apps/cli/src/command-internal/pflag-reconcile.unit.test.ts index eb858faf24..edd37913ab 100644 --- a/apps/cli/src/command-internal/pflag-reconcile.unit.test.ts +++ b/apps/cli/src/command-internal/pflag-reconcile.unit.test.ts @@ -315,7 +315,6 @@ describe("pflagProfileValue", () => { describe("resolvePflagProfile", () => { const services = (args: ReadonlyArray, homeDir: string, profileEnvValue: string) => Layer.mergeAll( - BunServices.layer, Layer.succeed(ProfileFlag, "supabase"), Layer.succeed(CliArgs, { args }), mockCommandSettings({ workdir: homeDir, profileEnvValue: Option.some(profileEnvValue) }), From e1d9730eaedf5e6b2d66068d991a64c174abc60a Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:44:12 +0530 Subject: [PATCH 3/3] chore(cli): include upgrade-notice in its follow-up --- .oxlintrc.effect.json | 4 +- .../upgrade-notice.integration.test.ts | 158 +-- .../upgrade-notice.unit.test.ts | 1189 ++++++++--------- 3 files changed, 595 insertions(+), 756 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 1f4dc3a112..e046862971 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -115,14 +115,12 @@ "!apps/cli/src/command-internal/temp-paths.*", "!apps/cli/src/command-internal/tenant-*", "!apps/cli/src/command-internal/timestamp.format.*", - "!apps/cli/src/command-internal/upgrade-notice.*", "!apps/cli/src/command-internal/upgrade-suggest.*", "!apps/cli/src/command-internal/vault-decrypt.*", "!apps/cli/src/command-internal/vault.*", "!apps/cli/src/command-internal/viper-env.*", "!apps/cli/src/command-internal/workdir-project.*", "!apps/cli/src/command-internal/workdir-search.ts", - "!apps/cli/src/command-internal/workdir-validation.ts", - "apps/cli/src/command-internal/upgrade-notice.ts" + "!apps/cli/src/command-internal/workdir-validation.ts" ] } diff --git a/apps/cli/src/command-internal/upgrade-notice.integration.test.ts b/apps/cli/src/command-internal/upgrade-notice.integration.test.ts index e835ce5fa4..75dbb060d4 100644 --- a/apps/cli/src/command-internal/upgrade-notice.integration.test.ts +++ b/apps/cli/src/command-internal/upgrade-notice.integration.test.ts @@ -1,106 +1,94 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { stripVTControlCharacters } from "node:util"; -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Clock, DateTime, Effect, FileSystem, Path } from "effect"; +import { Effect } from "effect"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { withEnvVar } from "../../tests/helpers/command-mocks.ts"; import { upgradeNoticeHook } from "./upgrade-notice.ts"; -const captureStderr = (body: Effect.Effect) => - Effect.acquireUseRelease( - Effect.sync(() => { - const written: Array = []; - const realWrite = process.stderr.write.bind(process.stderr); - process.stderr.write = ((chunk: unknown) => { - written.push(String(chunk)); - return true; - }) as typeof process.stderr.write; - return { written, realWrite }; - }), - ({ written }) => Effect.as(body, written), - ({ realWrite }) => - Effect.sync(() => { - process.stderr.write = realWrite; - }), - ); - describe("upgrade notice user-level cache", () => { - const userCache = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-upgrade-notice-home-" }); - const workdir = path.join(root, "outside-project"); - const supabaseHome = path.join(root, "supabase-home"); - yield* fs.makeDirectory(workdir); - return { fs, workdir, supabaseHome, cacheFile: path.join(supabaseHome, "cli-latest") }; + let root: string; + let workdir: string; + let supabaseHome: string; + let cacheFile: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "supabase-upgrade-notice-home-")); + workdir = join(root, "outside-project"); + supabaseHome = join(root, "supabase-home"); + cacheFile = join(supabaseHome, "cli-latest"); + mkdirSync(workdir); }); - /** Ages the cache against the live clock, which the hook's freshness check reads. */ - const writeUserCache = Effect.fnUntraced(function* ( - { fs, supabaseHome, cacheFile }: Effect.Success, - tag: string, - ageMs: number, - ) { - yield* fs.makeDirectory(supabaseHome, { recursive: true }); - yield* fs.writeFileString(cacheFile, tag); - const then = DateTime.toDateUtc(DateTime.makeUnsafe((yield* Clock.currentTimeMillis) - ageMs)); - yield* fs.utimes(cacheFile, then, then); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); }); + function writeUserCache(tag: string, ageMs: number) { + mkdirSync(supabaseHome, { recursive: true }); + writeFileSync(cacheFile, tag); + const then = new Date(Date.now() - ageMs); + utimesSync(cacheFile, then, then); + } + /** Runs the production hook with real `SUPABASE_HOME` resolution and a stub release fetch. */ - const runHook = Effect.fnUntraced(function* ( - { workdir, supabaseHome }: Effect.Success, - latestTag: string, - ) { + async function runHook(latestTag: string) { let fetchCalls = 0; - const written = yield* captureStderr( - withEnvVar( - "SUPABASE_HOME", - supabaseHome, - withEnvVar( - "SUPABASE_NO_UPDATE_NOTIFIER", - "0", - upgradeNoticeHook( - ["projects", "list"], - { - cleanShowHelp: false, - delegatedToGo: false, - workingDirectory: workdir, - isValueTakingFlagToken: () => false, - }, - () => { - fetchCalls += 1; - return Promise.resolve(latestTag); - }, - ), + const written: Array = []; + const realWrite = process.stderr.write.bind(process.stderr); + const realEnv = { + SUPABASE_HOME: process.env["SUPABASE_HOME"], + SUPABASE_NO_UPDATE_NOTIFIER: process.env["SUPABASE_NO_UPDATE_NOTIFIER"], + }; + process.env["SUPABASE_HOME"] = supabaseHome; + process.env["SUPABASE_NO_UPDATE_NOTIFIER"] = "0"; + process.stderr.write = ((chunk: unknown) => { + written.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + await Effect.runPromise( + upgradeNoticeHook( + ["projects", "list"], + { + cleanShowHelp: false, + delegatedToGo: false, + workingDirectory: workdir, + isValueTakingFlagToken: () => false, + }, + () => { + fetchCalls += 1; + return Promise.resolve(latestTag); + }, ), - ), - ); + ); + } finally { + process.stderr.write = realWrite; + for (const [key, value] of Object.entries(realEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } return { fetchCalls, stderr: stripVTControlCharacters(written.join("")) }; - }); + } - it.live("serves a fresh SUPABASE_HOME cache outside a project without fetching", () => - Effect.gen(function* () { - const ctx = yield* userCache; - yield* writeUserCache(ctx, "v99.99.99", 60_000); + it("serves a fresh SUPABASE_HOME cache outside a project without fetching", async () => { + writeUserCache("v99.99.99", 60_000); - const { fetchCalls, stderr } = yield* runHook(ctx, "v99.99.100"); + const { fetchCalls, stderr } = await runHook("v99.99.100"); - expect(fetchCalls).toBe(0); - expect(stderr).toContain("A new version of Supabase CLI is available: v99.99.99"); - }).pipe(Effect.provide(BunServices.layer)), - ); + expect(fetchCalls).toBe(0); + expect(stderr).toContain("A new version of Supabase CLI is available: v99.99.99"); + }); - it.live("refetches a stale SUPABASE_HOME cache and records the new tag", () => - Effect.gen(function* () { - const ctx = yield* userCache; - yield* writeUserCache(ctx, "v2.100.0", 11 * 60 * 60 * 1000); + it("refetches a stale SUPABASE_HOME cache and records the new tag", async () => { + writeUserCache("v2.100.0", 11 * 60 * 60 * 1000); - const { fetchCalls } = yield* runHook(ctx, "v99.99.100"); + const { fetchCalls } = await runHook("v99.99.100"); - expect(fetchCalls).toBe(1); - expect(yield* ctx.fs.readFileString(ctx.cacheFile)).toBe("v99.99.100"); - }).pipe(Effect.provide(BunServices.layer)), - ); + expect(fetchCalls).toBe(1); + expect(readFileSync(cacheFile, "utf8")).toBe("v99.99.100"); + }); }); diff --git a/apps/cli/src/command-internal/upgrade-notice.unit.test.ts b/apps/cli/src/command-internal/upgrade-notice.unit.test.ts index 91ac43e622..89458d139b 100644 --- a/apps/cli/src/command-internal/upgrade-notice.unit.test.ts +++ b/apps/cli/src/command-internal/upgrade-notice.unit.test.ts @@ -1,11 +1,23 @@ +import { + chmodSync, + lstatSync, + mkdirSync, + readdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { stripVTControlCharacters } from "node:util"; -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it, layer } from "@effect/vitest"; -import { Clock, DateTime, Effect, FileSystem, Option, Path, Tracer } from "effect"; -import { TestClock } from "effect/testing"; +import { describe, expect, it } from "vitest"; +import { Effect, Option, Tracer } from "effect"; -import { withEnvVar } from "../../tests/helpers/command-mocks.ts"; import { type UpgradeNoticeDeps, formatUpgradeNotice, @@ -70,27 +82,10 @@ describe("formatUpgradeNotice", () => { }); }); -const isRoot = process.getuid?.() === 0; - -const notice = (deps: UpgradeNoticeDeps) => Effect.promise(() => runUpgradeNotice(deps)); - -/** Runs `body` with `target` at `mode`, then restores `restore` so the temp dir can be removed. */ -const withMode = ( - target: string, - mode: number, - restore: number, - body: Effect.Effect, -) => - Effect.flatMap(FileSystem.FileSystem, (fs) => - Effect.acquireUseRelease( - fs.chmod(target, mode), - () => body, - () => Effect.orDie(fs.chmod(target, restore)), - ), - ); +describe("runUpgradeNotice", () => { + let workdir: string; -layer(BunServices.layer)("runUpgradeNotice", (it) => { - const setup = Effect.fnUntraced(function* (opts: { + function setup(opts: { readonly env?: Record; readonly args?: ReadonlyArray; readonly latestTag?: string; @@ -100,36 +95,25 @@ layer(BunServices.layer)("runUpgradeNotice", (it) => { readonly cacheAgeMs?: number; readonly currentVersion?: string; }) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - /* Caches the notice itself writes carry real mtimes, which this earlier - clock always sees as fresh. */ - yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe("2020-01-01T00:00:00Z"))); - const clock = yield* Clock.Clock; - const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-upgrade-notice-" }); + workdir = mkdtempSync(join(tmpdir(), "supabase-upgrade-notice-")); if (opts.project !== false) { - yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); - yield* fs.writeFileString( - path.join(workdir, "supabase", "config.toml"), - 'project_id = "demo"\n', - ); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), 'project_id = "demo"\n'); } - const cachePath = path.join(workdir, "supabase", ".temp", "cli-latest"); if (opts.cacheContent !== undefined) { - yield* fs.makeDirectory(path.join(workdir, "supabase", ".temp"), { recursive: true }); - yield* fs.writeFileString(cachePath, opts.cacheContent); + mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); + const cacheFile = join(workdir, "supabase", ".temp", "cli-latest"); + writeFileSync(cacheFile, opts.cacheContent); if (opts.cacheAgeMs !== undefined) { - const then = DateTime.toDateUtc( - DateTime.makeUnsafe(clock.currentTimeMillisUnsafe() - opts.cacheAgeMs), - ); - yield* fs.utimes(cachePath, then, then); + const then = new Date(Date.now() - opts.cacheAgeMs); + utimesSync(cacheFile, then, then); } } // A symlinked SUPABASE_HOME disables the user-level cache, so runs outside a // project record no backoff. - const supabaseHome = path.join(workdir, "home"); - yield* fs.makeDirectory(path.join(workdir, "home-target")); - yield* fs.symlink(path.join(workdir, "home-target"), supabaseHome); + const supabaseHome = join(workdir, "home"); + mkdirSync(join(workdir, "home-target")); + symlinkSync(join(workdir, "home-target"), supabaseHome); let fetchCalls = 0; const stderr: Array = []; const deps: UpgradeNoticeDeps = { @@ -138,7 +122,7 @@ layer(BunServices.layer)("runUpgradeNotice", (it) => { cwd: workdir, currentVersion: opts.currentVersion ?? "2.113.0", supabaseHome, - now: () => clock.currentTimeMillisUnsafe(), + now: Date.now, fetchLatestTag: () => { fetchCalls += 1; return opts.fetchFails === true @@ -150,9 +134,6 @@ layer(BunServices.layer)("runUpgradeNotice", (it) => { }, }; return { - fs, - path, - workdir, deps, get fetchCalls() { return fetchCalls; @@ -160,643 +141,511 @@ layer(BunServices.layer)("runUpgradeNotice", (it) => { get stderr() { return stripVTControlCharacters(stderr.join("")); }, - cachePath, + cachePath: join(workdir, "supabase", ".temp", "cli-latest"), + cleanup: () => rmSync(workdir, { recursive: true, force: true }), }; + } + + it("prints the notice and caches the tag when a newer release exists", async () => { + const ctx = setup({}); + await runUpgradeNotice(ctx.deps); + expect(ctx.fetchCalls).toBe(1); + expect(ctx.stderr).toContain("A new version of Supabase CLI is available: v2.114.0"); + expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.114.0"); + ctx.cleanup(); }); - it.effect("prints the notice and caches the tag when a newer release exists", () => - Effect.gen(function* () { - const ctx = yield* setup({}); - yield* notice(ctx.deps); - expect(ctx.fetchCalls).toBe(1); - expect(ctx.stderr).toContain("A new version of Supabase CLI is available: v2.114.0"); - expect(yield* ctx.fs.readFileString(ctx.cachePath)).toBe("v2.114.0"); - }), - ); - - it.effect("SUPABASE_NO_UPDATE_NOTIFIER=1 skips the fetch and the notice entirely", () => - Effect.gen(function* () { - const ctx = yield* setup({ env: { SUPABASE_NO_UPDATE_NOTIFIER: "1" } }); - yield* notice(ctx.deps); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toBe(""); - }), - ); + it("SUPABASE_NO_UPDATE_NOTIFIER=1 skips the fetch and the notice entirely", async () => { + const ctx = setup({ env: { SUPABASE_NO_UPDATE_NOTIFIER: "1" } }); + await runUpgradeNotice(ctx.deps); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toBe(""); + ctx.cleanup(); + }); - it.effect("stays silent when already on the latest release", () => - Effect.gen(function* () { - const ctx = yield* setup({ latestTag: "v2.113.0" }); - yield* notice(ctx.deps); - expect(ctx.stderr).toBe(""); - }), - ); + it("stays silent when already on the latest release", async () => { + const ctx = setup({ latestTag: "v2.113.0" }); + await runUpgradeNotice(ctx.deps); + expect(ctx.stderr).toBe(""); + ctx.cleanup(); + }); - it.effect("reads a fresh cache instead of fetching", () => - Effect.gen(function* () { - const ctx = yield* setup({ cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); - yield* notice(ctx.deps); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toContain("v2.115.0"); - }), - ); + it("reads a fresh cache instead of fetching", async () => { + const ctx = setup({ cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); + await runUpgradeNotice(ctx.deps); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toContain("v2.115.0"); + ctx.cleanup(); + }); - it.effect("keeps a cache fresh at the exact ten-hour boundary", () => - Effect.gen(function* () { - const ctx = yield* setup({ cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); - const mtime = Option.getOrThrow((yield* ctx.fs.stat(ctx.cachePath)).mtime).getTime(); - yield* notice({ ...ctx.deps, now: () => mtime + 10 * 60 * 60 * 1000 }); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toContain("v2.115.0"); - }), - ); + it("keeps a cache fresh at the exact ten-hour boundary", async () => { + const ctx = setup({ cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); + await runUpgradeNotice({ + ...ctx.deps, + now: () => statSync(ctx.cachePath).mtime.getTime() + 10 * 60 * 60 * 1000, + }); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toContain("v2.115.0"); + ctx.cleanup(); + }); - it.effect("refetches when the cache is older than ten hours", () => - Effect.gen(function* () { - const ctx = yield* setup({ cacheContent: "v2.115.0", cacheAgeMs: 11 * 60 * 60 * 1000 }); - yield* notice(ctx.deps); - expect(ctx.fetchCalls).toBe(1); - expect(yield* ctx.fs.readFileString(ctx.cachePath)).toBe("v2.114.0"); - }), - ); + it("refetches when the cache is older than ten hours", async () => { + const ctx = setup({ cacheContent: "v2.115.0", cacheAgeMs: 11 * 60 * 60 * 1000 }); + await runUpgradeNotice(ctx.deps); + expect(ctx.fetchCalls).toBe(1); + expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.114.0"); + ctx.cleanup(); + }); - it.effect("--version reads a fresh cache instead of fetching", () => - Effect.gen(function* () { - const ctx = yield* setup({ - args: ["--version"], - cacheContent: "v2.115.0", - cacheAgeMs: 60_000, - }); - yield* notice(ctx.deps); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toContain("v2.115.0"); - }), - ); + it("--version reads a fresh cache instead of fetching", async () => { + const ctx = setup({ args: ["--version"], cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); + await runUpgradeNotice(ctx.deps); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toContain("v2.115.0"); + ctx.cleanup(); + }); - it.effect( - "SUPABASE_DEBUG surfaces fetch failures without the --debug flag, like viper's AutomaticEnv", - () => - Effect.gen(function* () { - const ctx = yield* setup({ - fetchFails: true, - project: false, - env: { SUPABASE_DEBUG: "1" }, - }); - yield* notice(ctx.deps); - expect(ctx.stderr).toContain("Failed to fetch latest release"); - }), - ); + it("SUPABASE_DEBUG surfaces fetch failures without the --debug flag, like viper's AutomaticEnv", async () => { + const ctx = setup({ fetchFails: true, project: false, env: { SUPABASE_DEBUG: "1" } }); + await runUpgradeNotice(ctx.deps); + expect(ctx.stderr).toContain("Failed to fetch latest release"); + ctx.cleanup(); + }); - it.effect( - "--debug=true surfaces fetch failures and --debug=false silences them, like pflag", - () => - Effect.gen(function* () { - const on = yield* setup({ - fetchFails: true, - project: false, - args: ["db", "start", "--debug=true"], - }); - yield* notice(on.deps); - expect(on.stderr).toContain("Failed to fetch latest release"); - - // A set flag (`--debug=false`) beats SUPABASE_DEBUG. - const off = yield* setup({ - fetchFails: true, - project: false, - args: ["db", "start", "--debug=false"], - env: { SUPABASE_DEBUG: "1" }, - }); - yield* notice(off.deps); - expect(off.stderr).toBe(""); - }), - ); + it("--debug=true surfaces fetch failures and --debug=false silences them, like pflag", async () => { + const on = setup({ fetchFails: true, project: false, args: ["db", "start", "--debug=true"] }); + await runUpgradeNotice(on.deps); + expect(on.stderr).toContain("Failed to fetch latest release"); + on.cleanup(); + + // A set flag (`--debug=false`) beats SUPABASE_DEBUG. + const off = setup({ + fetchFails: true, + project: false, + args: ["db", "start", "--debug=false"], + env: { SUPABASE_DEBUG: "1" }, + }); + await runUpgradeNotice(off.deps); + expect(off.stderr).toBe(""); + off.cleanup(); + }); - it.effect( - "a built-in ignores SUPABASE_DEBUG but still honors the --debug flag, like cobra's init order", - () => - Effect.gen(function* () { - const viaEnv = yield* setup({ - fetchFails: true, - project: false, - env: { SUPABASE_DEBUG: "1" }, - args: ["--version"], - }); - yield* notice(viaEnv.deps); - expect(viaEnv.stderr).toBe(""); - - const viaFlag = yield* setup({ - fetchFails: true, - project: false, - args: ["--version", "--debug"], - }); - yield* notice(viaFlag.deps); - expect(viaFlag.stderr).toContain("Failed to fetch latest release: offline"); - }), - ); + it("a built-in ignores SUPABASE_DEBUG but still honors the --debug flag, like cobra's init order", async () => { + const viaEnv = setup({ + fetchFails: true, + project: false, + env: { SUPABASE_DEBUG: "1" }, + args: ["--version"], + }); + await runUpgradeNotice(viaEnv.deps); + expect(viaEnv.stderr).toBe(""); + viaEnv.cleanup(); + + const viaFlag = setup({ fetchFails: true, project: false, args: ["--version", "--debug"] }); + await runUpgradeNotice(viaFlag.deps); + expect(viaFlag.stderr).toContain("Failed to fetch latest release: offline"); + viaFlag.cleanup(); + }); - it.effect("a failed fetch stays silent and writes an empty cache to back off", () => - Effect.gen(function* () { - const ctx = yield* setup({ fetchFails: true }); - yield* notice(ctx.deps); - expect(ctx.stderr).toBe(""); - expect(yield* ctx.fs.readFileString(ctx.cachePath)).toBe(""); - }), - ); + it("a failed fetch stays silent and writes an empty cache to back off", async () => { + const ctx = setup({ fetchFails: true }); + await runUpgradeNotice(ctx.deps); + expect(ctx.stderr).toBe(""); + expect(readFileSync(ctx.cachePath, "utf8")).toBe(""); + ctx.cleanup(); + }); - it.effect( - "a failed fetch surfaces its error under --debug when no cache can record the backoff", - () => - Effect.gen(function* () { - const ctx = yield* setup({ - fetchFails: true, - project: false, - args: ["db", "start", "--debug"], - }); - yield* notice(ctx.deps); - expect(ctx.stderr).toContain("Failed to fetch latest release"); - expect(ctx.stderr).not.toContain("A new version of Supabase CLI is available"); - }), - ); + it("a failed fetch surfaces its error under --debug when no cache can record the backoff", async () => { + const ctx = setup({ fetchFails: true, project: false, args: ["db", "start", "--debug"] }); + await runUpgradeNotice(ctx.deps); + expect(ctx.stderr).toContain("Failed to fetch latest release"); + expect(ctx.stderr).not.toContain("A new version of Supabase CLI is available"); + ctx.cleanup(); + }); - it.effect( - "a --debug operand after -- or consumed by a value flag is not the debug flag, like pflag", - () => - Effect.gen(function* () { - const afterTerminator = yield* setup({ - fetchFails: true, - project: false, - args: ["db", "start", "--", "--debug"], - }); - yield* notice(afterTerminator.deps); - expect(afterTerminator.stderr).toBe(""); - - const consumedValue = yield* setup({ - fetchFails: true, - project: false, - args: ["--profile", "--debug", "db", "start"], - }); - yield* notice(consumedValue.deps); - expect(consumedValue.stderr).toBe(""); - }), - ); + it("a --debug operand after -- or consumed by a value flag is not the debug flag, like pflag", async () => { + const afterTerminator = setup({ + fetchFails: true, + project: false, + args: ["db", "start", "--", "--debug"], + }); + await runUpgradeNotice(afterTerminator.deps); + expect(afterTerminator.stderr).toBe(""); + afterTerminator.cleanup(); + + const consumedValue = setup({ + fetchFails: true, + project: false, + args: ["--profile", "--debug", "db", "start"], + }); + await runUpgradeNotice(consumedValue.deps); + expect(consumedValue.stderr).toBe(""); + consumedValue.cleanup(); + }); - it.effect.skipIf(isRoot)("a cache write failure reports the stable cli-latest path", () => - Effect.gen(function* () { - const ctx = yield* setup({ args: ["db", "start", "--debug"] }); - const tempDir = ctx.path.join(ctx.workdir, "supabase", ".temp"); - yield* ctx.fs.makeDirectory(tempDir, { recursive: true }); - yield* withMode(tempDir, 0o555, 0o755, notice(ctx.deps)); + it.skipIf(process.getuid?.() === 0)( + "a cache write failure reports the stable cli-latest path", + async () => { + const ctx = setup({ args: ["db", "start", "--debug"] }); + mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); + chmodSync(join(workdir, "supabase", ".temp"), 0o555); + await runUpgradeNotice(ctx.deps); + chmodSync(join(workdir, "supabase", ".temp"), 0o755); expect(ctx.stderr).toContain("failed to write file"); expect(ctx.stderr).toContain("cli-latest"); - }), + ctx.cleanup(); + }, ); - it.effect.skipIf(process.platform === "win32")( + it.skipIf(process.platform === "win32")( "creates the cache directory and file with Go-compatible modes", - () => - Effect.gen(function* () { - const ctx = yield* setup({}); - yield* Effect.acquireUseRelease( - Effect.sync(() => process.umask(0)), - () => notice(ctx.deps), - (previousUmask) => Effect.sync(() => process.umask(previousUmask)), - ); - const tempDir = ctx.path.join(ctx.workdir, "supabase", ".temp"); - expect((yield* ctx.fs.stat(tempDir)).mode & 0o777).toBe(0o755); - expect((yield* ctx.fs.stat(ctx.cachePath)).mode & 0o777).toBe(0o644); - }), + async () => { + const ctx = setup({}); + const previousUmask = process.umask(0); + try { + await runUpgradeNotice(ctx.deps); + } finally { + process.umask(previousUmask); + } + expect(statSync(join(workdir, "supabase", ".temp")).mode & 0o777).toBe(0o755); + expect(statSync(ctx.cachePath).mode & 0o777).toBe(0o644); + ctx.cleanup(); + }, ); - it.effect.skipIf(process.platform === "win32")( + it.skipIf(process.platform === "win32")( "updates a writable cache without requiring write access to its directory", - () => - Effect.gen(function* () { - const ctx = yield* setup({ - cacheContent: "v2.115.0", - cacheAgeMs: 11 * 60 * 60 * 1000, - }); - const tempDir = ctx.path.join(ctx.workdir, "supabase", ".temp"); - yield* withMode(tempDir, 0o555, 0o755, notice(ctx.deps)); - expect(ctx.stderr).not.toContain("failed to write file"); - expect(yield* ctx.fs.readFileString(ctx.cachePath)).toBe("v2.114.0"); - }), + async () => { + const ctx = setup({ + cacheContent: "v2.115.0", + cacheAgeMs: 11 * 60 * 60 * 1000, + }); + const tempDir = join(workdir, "supabase", ".temp"); + chmodSync(tempDir, 0o555); + try { + await runUpgradeNotice(ctx.deps); + } finally { + chmodSync(tempDir, 0o755); + } + expect(ctx.stderr).not.toContain("failed to write file"); + expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.114.0"); + ctx.cleanup(); + }, ); - it.effect( - "a --debug consumed by the leaf command's own value flag is not the debug flag, like pflag", - () => - Effect.gen(function* () { - // `login --name --debug`: `--debug` is consumed as `--name`'s value; the - // real CLI passes the resolved leaf's value-flag predicate into the hook. - const ctx = yield* setup({ - fetchFails: true, - project: false, - args: ["login", "--name", "--debug"], - }); - yield* notice({ ...ctx.deps, isValueTakingFlagToken: (token) => token === "--name" }); - expect(ctx.stderr).toBe(""); - }), - ); + it("a --debug consumed by the leaf command's own value flag is not the debug flag, like pflag", async () => { + // `login --name --debug`: `--debug` is consumed as `--name`'s value; the + // real CLI passes the resolved leaf's value-flag predicate into the hook. + const ctx = setup({ fetchFails: true, project: false, args: ["login", "--name", "--debug"] }); + await runUpgradeNotice({ + ...ctx.deps, + isValueTakingFlagToken: (token) => token === "--name", + }); + expect(ctx.stderr).toBe(""); + ctx.cleanup(); + }); - it.effect("a false root version flag before a leaf runs the normal path, workdir included", () => - Effect.gen(function* () { - // `--version=false ` is not the version built-in, so the cache - // resolves under `--workdir`. - const ctx = yield* setup({ project: false }); - const flagged = ctx.path.join(ctx.workdir, "flagged"); - yield* ctx.fs.makeDirectory(ctx.path.join(flagged, "supabase"), { recursive: true }); - yield* notice({ - ...ctx.deps, - args: ["--workdir", flagged, "--version=false", "db", "push"], - }); - expect( - yield* ctx.fs.readFileString(ctx.path.join(flagged, "supabase", ".temp", "cli-latest")), - ).toBe("v2.114.0"); - }), - ); + it("a false root version flag before a leaf runs the normal path, workdir included", async () => { + // `--version=false ` is not the version built-in, so the cache + // resolves under `--workdir`. + const ctx = setup({ project: false }); + const flagged = join(workdir, "flagged"); + mkdirSync(join(flagged, "supabase"), { recursive: true }); + await runUpgradeNotice({ + ...ctx.deps, + args: ["--workdir", flagged, "--version=false", "db", "push"], + }); + expect(readFileSync(join(flagged, "supabase", ".temp", "cli-latest"), "utf8")).toBe("v2.114.0"); + ctx.cleanup(); + }); - it.effect.skipIf(isRoot)( + it.skipIf(process.getuid?.() === 0)( "an existing read-only cache file fails the backoff write, like Go's direct open", - () => - Effect.gen(function* () { - const ctx = yield* setup({ - args: ["db", "start", "--debug"], - cacheContent: "v2.115.0", - cacheAgeMs: 11 * 60 * 60 * 1000, - }); - yield* withMode(ctx.cachePath, 0o444, 0o644, notice(ctx.deps)); - expect(ctx.stderr).toContain("failed to write file"); - expect(yield* ctx.fs.readFileString(ctx.cachePath)).toBe("v2.115.0"); - }), + async () => { + const ctx = setup({ + args: ["db", "start", "--debug"], + cacheContent: "v2.115.0", + cacheAgeMs: 11 * 60 * 60 * 1000, + }); + chmodSync(ctx.cachePath, 0o444); + await runUpgradeNotice(ctx.deps); + chmodSync(ctx.cachePath, 0o644); + expect(ctx.stderr).toContain("failed to write file"); + expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.115.0"); + ctx.cleanup(); + }, ); - it.effect( - "project dotenv SUPABASE_DEBUG surfaces diagnostics, like godotenv before the Execute tail", - () => - Effect.gen(function* () { - // The symlinked .temp disables the backoff write, so the fetch error is - // what remains to log. - const ctx = yield* setup({ fetchFails: true }); - const { fs, path } = ctx; - yield* fs.writeFileString(path.join(ctx.workdir, "supabase", ".env"), "SUPABASE_DEBUG=1\n"); - yield* fs.makeDirectory(path.join(ctx.workdir, "elsewhere"), { recursive: true }); - yield* fs.symlink( - path.join(ctx.workdir, "elsewhere"), - path.join(ctx.workdir, "supabase", ".temp"), - ); - yield* notice(ctx.deps); - expect(ctx.stderr).toContain("Failed to fetch latest release"); - - // A shell env that defines the key blocks the chain. - const blocked = yield* setup({ fetchFails: true, env: { SUPABASE_DEBUG: "" } }); - yield* fs.writeFileString( - path.join(blocked.workdir, "supabase", ".env"), - "SUPABASE_DEBUG=1\n", - ); - yield* fs.makeDirectory(path.join(blocked.workdir, "elsewhere"), { recursive: true }); - yield* fs.symlink( - path.join(blocked.workdir, "elsewhere"), - path.join(blocked.workdir, "supabase", ".temp"), - ); - yield* notice(blocked.deps); - expect(blocked.stderr).toBe(""); - }), - ); + it("project dotenv SUPABASE_DEBUG surfaces diagnostics, like godotenv before the Execute tail", async () => { + // The symlinked .temp disables the backoff write, so the fetch error is + // what remains to log. + const ctx = setup({ fetchFails: true }); + writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_DEBUG=1\n"); + mkdirSync(join(workdir, "elsewhere"), { recursive: true }); + symlinkSync(join(workdir, "elsewhere"), join(workdir, "supabase", ".temp")); + await runUpgradeNotice(ctx.deps); + expect(ctx.stderr).toContain("Failed to fetch latest release"); + ctx.cleanup(); + + // A shell env that defines the key blocks the chain. + const blocked = setup({ fetchFails: true, env: { SUPABASE_DEBUG: "" } }); + writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_DEBUG=1\n"); + mkdirSync(join(workdir, "elsewhere"), { recursive: true }); + symlinkSync(join(workdir, "elsewhere"), join(workdir, "supabase", ".temp")); + await runUpgradeNotice(blocked.deps); + expect(blocked.stderr).toBe(""); + blocked.cleanup(); + }); - it.effect( - "a failed fetch inside a project stays silent under --debug, matching Go's backoff", - () => - Effect.gen(function* () { - const ctx = yield* setup({ fetchFails: true, args: ["db", "start", "--debug"] }); - yield* notice(ctx.deps); - // The empty-cache backoff write succeeds, so no debug line is emitted. - expect(ctx.stderr).toBe(""); - expect(yield* ctx.fs.readFileString(ctx.cachePath)).toBe(""); - }), - ); + it("a failed fetch inside a project stays silent under --debug, matching Go's backoff", async () => { + const ctx = setup({ fetchFails: true, args: ["db", "start", "--debug"] }); + await runUpgradeNotice(ctx.deps); + // The empty-cache backoff write succeeds, so no debug line is emitted. + expect(ctx.stderr).toBe(""); + expect(readFileSync(ctx.cachePath, "utf8")).toBe(""); + ctx.cleanup(); + }); - it.effect( - "resolves the cache under --workdir, then SUPABASE_WORKDIR, ahead of the cwd walk", - () => - Effect.gen(function* () { - const ctx = yield* setup({ project: false }); - const { fs, path } = ctx; - const flagDir = path.join(ctx.workdir, "flag-project"); - const envDir = path.join(ctx.workdir, "env-project"); - for (const dir of [flagDir, envDir]) { - yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); - } - const flagCtx = { ...ctx.deps, args: ["db", "start", "--workdir", flagDir] }; - yield* notice(flagCtx); - expect( - yield* fs.readFileString(path.join(flagDir, "supabase", ".temp", "cli-latest")), - ).toBe("v2.114.0"); - - const lastWinsCtx = { - ...ctx.deps, - args: [ - "db", - "start", - "--workdir", - path.join(ctx.workdir, "ignored"), - `--workdir=${flagDir}`, - ], - }; - yield* notice(lastWinsCtx); - expect( - yield* fs.readFileString(path.join(flagDir, "supabase", ".temp", "cli-latest")), - ).toBe("v2.114.0"); - - const envCtx = { ...ctx.deps, env: { SUPABASE_WORKDIR: envDir } }; - yield* notice(envCtx); - expect(yield* fs.readFileString(path.join(envDir, "supabase", ".temp", "cli-latest"))).toBe( - "v2.114.0", - ); - }), - ); + it("resolves the cache under --workdir, then SUPABASE_WORKDIR, ahead of the cwd walk", async () => { + const ctx = setup({ project: false }); + const flagDir = join(workdir, "flag-project"); + const envDir = join(workdir, "env-project"); + for (const dir of [flagDir, envDir]) { + mkdirSync(join(dir, "supabase"), { recursive: true }); + } + const flagCtx = { ...ctx.deps, args: ["db", "start", "--workdir", flagDir] }; + await runUpgradeNotice(flagCtx); + expect(readFileSync(join(flagDir, "supabase", ".temp", "cli-latest"), "utf8")).toBe("v2.114.0"); - it.effect( - "uses the successful command's resolved working directory without re-resolving flags", - () => - Effect.gen(function* () { - const ctx = yield* setup({ project: false, args: ["bootstrap", "--workdir", "prompted"] }); - const prompted = ctx.path.join(ctx.workdir, "prompted"); - yield* ctx.fs.makeDirectory(ctx.path.join(prompted, "supabase"), { recursive: true }); - - yield* notice({ ...ctx.deps, resolvedCwd: prompted }); - - expect( - yield* ctx.fs.readFileString(ctx.path.join(prompted, "supabase", ".temp", "cli-latest")), - ).toBe("v2.114.0"); - expect(yield* ctx.fs.exists(ctx.cachePath)).toBe(false); - }), - ); + const lastWinsCtx = { + ...ctx.deps, + args: ["db", "start", "--workdir", join(workdir, "ignored"), `--workdir=${flagDir}`], + }; + await runUpgradeNotice(lastWinsCtx); + expect(readFileSync(join(flagDir, "supabase", ".temp", "cli-latest"), "utf8")).toBe("v2.114.0"); - it.effect( - "an explicit empty --workdir beats SUPABASE_WORKDIR and falls back to the walk, like viper", - () => - Effect.gen(function* () { - const ctx = yield* setup({}); - const envDir = ctx.path.join(ctx.workdir, "env-project"); - yield* ctx.fs.makeDirectory(ctx.path.join(envDir, "supabase"), { recursive: true }); - yield* notice({ - ...ctx.deps, - args: ["db", "start", "--workdir="], - env: { SUPABASE_WORKDIR: envDir }, - }); - // The set-but-empty flag suppresses the env, so the walk finds the real project. - expect(yield* ctx.fs.readFileString(ctx.cachePath)).toBe("v2.114.0"); - expect(yield* ctx.fs.exists(ctx.path.join(envDir, "supabase", ".temp", "cli-latest"))).toBe( - false, - ); - }), - ); + const envCtx = { ...ctx.deps, env: { SUPABASE_WORKDIR: envDir } }; + await runUpgradeNotice(envCtx); + expect(readFileSync(join(envDir, "supabase", ".temp", "cli-latest"), "utf8")).toBe("v2.114.0"); + ctx.cleanup(); + }); - it.effect( - "a project dotenv SUPABASE_NO_UPDATE_NOTIFIER opt-out suppresses the notice, like godotenv", - () => - Effect.gen(function* () { - const ctx = yield* setup({}); - yield* ctx.fs.writeFileString( - ctx.path.join(ctx.workdir, "supabase", ".env"), - "SUPABASE_NO_UPDATE_NOTIFIER=1\n", - ); - yield* notice(ctx.deps); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toBe(""); - }), - ); + it("uses the successful command's resolved working directory without re-resolving flags", async () => { + const ctx = setup({ project: false, args: ["bootstrap", "--workdir", "prompted"] }); + const prompted = join(workdir, "prompted"); + mkdirSync(join(prompted, "supabase"), { recursive: true }); - it.effect( - "a shell env that defines the key beats the project dotenv, like godotenv's no-override", - () => - Effect.gen(function* () { - // Defined-but-unparseable in the shell env still blocks the project - // dotenv from overriding, and an empty value keeps the notifier on. - const ctx = yield* setup({ env: { SUPABASE_NO_UPDATE_NOTIFIER: "" } }); - yield* ctx.fs.writeFileString( - ctx.path.join(ctx.workdir, "supabase", ".env"), - "SUPABASE_NO_UPDATE_NOTIFIER=1\n", - ); - yield* notice(ctx.deps); - expect(ctx.stderr).toContain("A new version of Supabase CLI is available"); - }), - ); + await runUpgradeNotice({ ...ctx.deps, resolvedCwd: prompted }); - it.effect( - "--help ignores the project dotenv opt-out — Go never loads config for the built-ins", - () => - Effect.gen(function* () { - const ctx = yield* setup({ args: ["--help"] }); - yield* ctx.fs.writeFileString( - ctx.path.join(ctx.workdir, "supabase", ".env"), - "SUPABASE_NO_UPDATE_NOTIFIER=1\n", - ); - yield* notice(ctx.deps); - expect(ctx.stderr).toContain("A new version of Supabase CLI is available"); - }), - ); + expect(readFileSync(join(prompted, "supabase", ".temp", "cli-latest"), "utf8")).toBe( + "v2.114.0", + ); + expect(() => readFileSync(ctx.cachePath, "utf8")).toThrow(); + ctx.cleanup(); + }); - it.effect("resolves --help and --version against the bare cwd, ignoring --workdir, like Go", () => - Effect.gen(function* () { - const ctx = yield* setup({ project: false }); - const flagged = ctx.path.join(ctx.workdir, "flagged"); - const flaggedCache = ctx.path.join(flagged, "supabase", ".temp", "cli-latest"); - yield* ctx.fs.makeDirectory(ctx.path.join(flagged, "supabase"), { recursive: true }); - for (const args of [ - ["--workdir", flagged, "--help"], - ["--workdir", flagged, "--version"], - // Valued spellings request the same built-ins; even a false value does, - // since the non-runnable root/group serves help before running anything. - ["--workdir", flagged, "--help=true"], - ["branches", "--workdir", flagged, "-h=1"], - ["--workdir", flagged, "--version=true"], - ["branches", "--workdir", flagged, "--help=true", "--help=false"], - // The space-form operand never stops the root version built-in. - ["--workdir", flagged, "--version", "true"], - ]) { - yield* notice({ ...ctx.deps, args }); - } - expect(yield* ctx.fs.exists(flaggedCache)).toBe(false); - // A subcommand's own value-taking --version still resolves the project. - yield* notice({ - ...ctx.deps, - args: ["db", "reset", "--workdir", flagged, "--version", "20240101000000"], - }); - expect(yield* ctx.fs.readFileString(flaggedCache)).toBe("v2.114.0"); - }), - ); + it("an explicit empty --workdir beats SUPABASE_WORKDIR and falls back to the walk, like viper", async () => { + const ctx = setup({}); + const envDir = join(workdir, "env-project"); + mkdirSync(join(envDir, "supabase"), { recursive: true }); + await runUpgradeNotice({ + ...ctx.deps, + args: ["db", "start", "--workdir="], + env: { SUPABASE_WORKDIR: envDir }, + }); + // The set-but-empty flag suppresses the env, so the walk finds the real project. + expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.114.0"); + expect(() => readFileSync(join(envDir, "supabase", ".temp", "cli-latest"), "utf8")).toThrow(); + ctx.cleanup(); + }); - it.effect("does not treat Effect's -v shorthand as Go's root version flag", () => - Effect.gen(function* () { - const ctx = yield* setup({ project: false }); - const flagged = ctx.path.join(ctx.workdir, "flagged"); - const cacheFile = ctx.path.join(flagged, "supabase", ".temp", "cli-latest"); - yield* ctx.fs.makeDirectory(ctx.path.join(flagged, "supabase", ".temp"), { recursive: true }); - yield* ctx.fs.writeFileString(cacheFile, "v2.115.0"); - - yield* notice({ - ...ctx.deps, - args: ["--workdir", flagged, "-v"], - }); + it("a project dotenv SUPABASE_NO_UPDATE_NOTIFIER opt-out suppresses the notice, like godotenv", async () => { + const ctx = setup({}); + writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_NO_UPDATE_NOTIFIER=1\n"); + await runUpgradeNotice(ctx.deps); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toBe(""); + ctx.cleanup(); + }); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toContain("v2.115.0"); - }), - ); + it("a shell env that defines the key beats the project dotenv, like godotenv's no-override", async () => { + // Defined-but-unparseable in the shell env still blocks the project + // dotenv from overriding, and an empty value keeps the notifier on. + const ctx = setup({ env: { SUPABASE_NO_UPDATE_NOTIFIER: "" } }); + writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_NO_UPDATE_NOTIFIER=1\n"); + await runUpgradeNotice(ctx.deps); + expect(ctx.stderr).toContain("A new version of Supabase CLI is available"); + ctx.cleanup(); + }); - it.effect("a clean group-help exit resolves against the bare cwd, ignoring --workdir", () => - Effect.gen(function* () { - const ctx = yield* setup({ project: false }); - const flagged = ctx.path.join(ctx.workdir, "flagged"); - yield* ctx.fs.makeDirectory(ctx.path.join(flagged, "supabase"), { recursive: true }); - yield* notice({ - ...ctx.deps, - args: ["branches", "--workdir", flagged], - cleanShowHelp: true, - }); - expect(yield* ctx.fs.exists(ctx.path.join(flagged, "supabase", ".temp", "cli-latest"))).toBe( - false, - ); - expect(ctx.stderr).toContain("v2.114.0"); - }), - ); + it("--help ignores the project dotenv opt-out — Go never loads config for the built-ins", async () => { + const ctx = setup({ args: ["--help"] }); + writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_NO_UPDATE_NOTIFIER=1\n"); + await runUpgradeNotice(ctx.deps); + expect(ctx.stderr).toContain("A new version of Supabase CLI is available"); + ctx.cleanup(); + }); - it.effect("ignores a --workdir operand after the -- terminator, like cobra", () => - Effect.gen(function* () { - const ctx = yield* setup({}); - const elsewhere = ctx.path.join(ctx.workdir, "elsewhere"); - yield* ctx.fs.makeDirectory(ctx.path.join(elsewhere, "supabase"), { recursive: true }); - const opCtx = { ...ctx.deps, args: ["db", "start", "--", "--workdir", elsewhere] }; - yield* notice(opCtx); - expect(yield* ctx.fs.readFileString(ctx.cachePath)).toBe("v2.114.0"); - expect( - yield* ctx.fs.exists(ctx.path.join(elsewhere, "supabase", ".temp", "cli-latest")), - ).toBe(false); - }), - ); + it("resolves --help and --version against the bare cwd, ignoring --workdir, like Go", async () => { + const ctx = setup({ project: false }); + const flagged = join(workdir, "flagged"); + mkdirSync(join(flagged, "supabase"), { recursive: true }); + for (const args of [ + ["--workdir", flagged, "--help"], + ["--workdir", flagged, "--version"], + // Valued spellings request the same built-ins; even a false value does, + // since the non-runnable root/group serves help before running anything. + ["--workdir", flagged, "--help=true"], + ["branches", "--workdir", flagged, "-h=1"], + ["--workdir", flagged, "--version=true"], + ["branches", "--workdir", flagged, "--help=true", "--help=false"], + // The space-form operand never stops the root version built-in. + ["--workdir", flagged, "--version", "true"], + ]) { + await runUpgradeNotice({ ...ctx.deps, args }); + } + expect(() => readFileSync(join(flagged, "supabase", ".temp", "cli-latest"), "utf8")).toThrow(); + // A subcommand's own value-taking --version still resolves the project. + await runUpgradeNotice({ + ...ctx.deps, + args: ["db", "reset", "--workdir", flagged, "--version", "20240101000000"], + }); + expect(readFileSync(join(flagged, "supabase", ".temp", "cli-latest"), "utf8")).toBe("v2.114.0"); + ctx.cleanup(); + }); - it.effect("never reads through or writes through a symlinked cache file", () => - Effect.gen(function* () { - const ctx = yield* setup({}); - const victim = ctx.path.join(ctx.workdir, "victim.txt"); - yield* ctx.fs.writeFileString(victim, "v9.9.9"); - yield* ctx.fs.makeDirectory(ctx.path.join(ctx.workdir, "supabase", ".temp"), { - recursive: true, - }); - yield* ctx.fs.symlink(victim, ctx.cachePath); - yield* notice(ctx.deps); - expect(ctx.fetchCalls).toBe(1); - expect(ctx.stderr).toContain("v2.114.0"); - expect(ctx.stderr).not.toContain("v9.9.9"); - expect(yield* ctx.fs.readFileString(victim)).toBe("v9.9.9"); - expect(yield* ctx.fs.readLink(ctx.cachePath)).toBe(victim); - }), - ); + it("does not treat Effect's -v shorthand as Go's root version flag", async () => { + const ctx = setup({ project: false }); + const flagged = join(workdir, "flagged"); + const cacheFile = join(flagged, "supabase", ".temp", "cli-latest"); + mkdirSync(join(flagged, "supabase", ".temp"), { recursive: true }); + writeFileSync(cacheFile, "v2.115.0"); - it.effect("never writes through a cache symlink planted during the release fetch", () => - Effect.gen(function* () { - const ctx = yield* setup({}); - const victim = ctx.path.join(ctx.workdir, "victim.txt"); - yield* ctx.fs.writeFileString(victim, "v9.9.9"); - yield* ctx.fs.makeDirectory(ctx.path.join(ctx.workdir, "supabase", ".temp"), { - recursive: true, - }); - const runPromise = Effect.runPromiseWith(yield* Effect.context()); - - // Planting the symlink inside `fetchLatestTag` simulates a concurrent - // swap landing in the check-then-write window; `O_NOFOLLOW` makes the - // open fail with ELOOP instead of following it. - yield* notice({ - ...ctx.deps, - fetchLatestTag: () => - runPromise(ctx.fs.symlink(victim, ctx.cachePath).pipe(Effect.as("v2.114.0"))), - }); + await runUpgradeNotice({ + ...ctx.deps, + args: ["--workdir", flagged, "-v"], + }); - expect(ctx.stderr).toContain("A new version of Supabase CLI is available: v2.114.0"); - expect(yield* ctx.fs.readFileString(victim)).toBe("v9.9.9"); - expect(yield* ctx.fs.readLink(ctx.cachePath)).toBe(victim); - }), - ); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toContain("v2.115.0"); + ctx.cleanup(); + }); + + it("a clean group-help exit resolves against the bare cwd, ignoring --workdir", async () => { + const ctx = setup({ project: false }); + const flagged = join(workdir, "flagged"); + mkdirSync(join(flagged, "supabase"), { recursive: true }); + await runUpgradeNotice({ + ...ctx.deps, + args: ["branches", "--workdir", flagged], + cleanShowHelp: true, + }); + expect(() => readFileSync(join(flagged, "supabase", ".temp", "cli-latest"), "utf8")).toThrow(); + expect(ctx.stderr).toContain("v2.114.0"); + ctx.cleanup(); + }); - it.effect.each(["supabase", "supabase/.temp"])( + it("ignores a --workdir operand after the -- terminator, like cobra", async () => { + const ctx = setup({}); + const elsewhere = join(workdir, "elsewhere"); + mkdirSync(join(elsewhere, "supabase"), { recursive: true }); + const opCtx = { ...ctx.deps, args: ["db", "start", "--", "--workdir", elsewhere] }; + await runUpgradeNotice(opCtx); + expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.114.0"); + expect(() => + readFileSync(join(elsewhere, "supabase", ".temp", "cli-latest"), "utf8"), + ).toThrow(); + ctx.cleanup(); + }); + + it("never reads through or writes through a symlinked cache file", async () => { + const ctx = setup({}); + const victim = join(workdir, "victim.txt"); + writeFileSync(victim, "v9.9.9"); + mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); + symlinkSync(victim, ctx.cachePath); + await runUpgradeNotice(ctx.deps); + expect(ctx.fetchCalls).toBe(1); + expect(ctx.stderr).toContain("v2.114.0"); + expect(ctx.stderr).not.toContain("v9.9.9"); + expect(readFileSync(victim, "utf8")).toBe("v9.9.9"); + expect(lstatSync(ctx.cachePath).isSymbolicLink()).toBe(true); + ctx.cleanup(); + }); + + it("never writes through a cache symlink planted during the release fetch", async () => { + const ctx = setup({}); + const victim = join(workdir, "victim.txt"); + writeFileSync(victim, "v9.9.9"); + mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); + + // Planting the symlink inside `fetchLatestTag` simulates a concurrent + // swap landing in the check-then-write window; `O_NOFOLLOW` makes the + // open fail with ELOOP instead of following it. + await runUpgradeNotice({ + ...ctx.deps, + fetchLatestTag: () => { + symlinkSync(victim, ctx.cachePath); + return Promise.resolve("v2.114.0"); + }, + }); + + expect(ctx.stderr).toContain("A new version of Supabase CLI is available: v2.114.0"); + expect(readFileSync(victim, "utf8")).toBe("v9.9.9"); + expect(lstatSync(ctx.cachePath).isSymbolicLink()).toBe(true); + ctx.cleanup(); + }); + + it.each(["supabase", "supabase/.temp"])( "never writes through a symlinked %s directory", - (linked) => - Effect.gen(function* () { - const ctx = yield* setup({ project: false }); - const outside = ctx.path.join(ctx.workdir, "outside"); - yield* ctx.fs.makeDirectory(outside, { recursive: true }); - if (linked !== "supabase") { - yield* ctx.fs.makeDirectory(ctx.path.join(ctx.workdir, "supabase"), { recursive: true }); - } - yield* ctx.fs.symlink(outside, ctx.path.join(ctx.workdir, linked)); - - yield* notice(ctx.deps); - - expect(ctx.stderr).toContain("v2.114.0"); - expect(yield* ctx.fs.readDirectory(outside)).toEqual([]); - }), + async (linked) => { + const ctx = setup({ project: false }); + const outside = join(workdir, "outside"); + mkdirSync(outside, { recursive: true }); + if (linked !== "supabase") mkdirSync(join(workdir, "supabase"), { recursive: true }); + symlinkSync(outside, join(workdir, linked)); + + await runUpgradeNotice(ctx.deps); + + expect(ctx.stderr).toContain("v2.114.0"); + expect(readdirSync(outside)).toEqual([]); + ctx.cleanup(); + }, ); - it.effect.each(["", "/", "/."])( + it.each(["", "/", "/."])( "never writes through a symlinked SUPABASE_HOME spelled with suffix %j", - (suffix) => - Effect.gen(function* () { - const ctx = yield* setup({ project: false }); - yield* notice({ ...ctx.deps, supabaseHome: `${ctx.deps.supabaseHome}${suffix}` }); - expect(yield* ctx.fs.readDirectory(ctx.path.join(ctx.workdir, "home-target"))).toEqual([]); - }), + async (suffix) => { + const ctx = setup({ project: false }); + await runUpgradeNotice({ ...ctx.deps, supabaseHome: `${ctx.deps.supabaseHome}${suffix}` }); + expect(readdirSync(join(workdir, "home-target"))).toEqual([]); + ctx.cleanup(); + }, ); - it.effect("validates exact cache bytes and rejects embedded escape bytes", () => - Effect.gen(function* () { - for (const cacheContent of ["v2.115.0\n", "v2.115.0\r\n", "v2.115.0\u001b[31mboo"]) { - const ctx = yield* setup({ cacheContent, cacheAgeMs: 60_000 }); - yield* notice(ctx.deps); - expect(ctx.stderr).toBe(""); - } - }), - ); + it("validates exact cache bytes and rejects embedded escape bytes", async () => { + for (const cacheContent of ["v2.115.0\n", "v2.115.0\r\n", "v2.115.0\u001b[31mboo"]) { + const ctx = setup({ cacheContent, cacheAgeMs: 60_000 }); + await runUpgradeNotice(ctx.deps); + expect(ctx.stderr).toBe(""); + ctx.cleanup(); + } + }); }); -const captureStderr = (body: Effect.Effect) => - Effect.acquireUseRelease( - Effect.sync(() => { - const written: Array = []; - const realWrite = process.stderr.write.bind(process.stderr); - process.stderr.write = ((chunk: unknown) => { - written.push(String(chunk)); - return true; - }) as typeof process.stderr.write; - return { written, realWrite }; - }), - ({ written }) => Effect.as(body, written), - ({ realWrite }) => - Effect.sync(() => { - process.stderr.write = realWrite; - }), - ); - /** * `delegatedToGo` is asserted directly rather than via a real proxied * command, so this doesn't depend on which commands still delegate to Go — * that set only shrinks (`docs/go-cli-porting-status.md`), and a test pinned * to one command would break the moment it's ported. */ -layer(BunServices.layer)("upgradeNoticeHook", (it) => { - const runHook = Effect.fnUntraced(function* (opts: { - readonly delegatedToGo?: boolean; - readonly cached?: boolean; - }) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-upgrade-notice-hook-" }); - yield* fs.makeDirectory(path.join(workdir, "supabase", ".temp"), { recursive: true }); - yield* fs.writeFileString( - path.join(workdir, "supabase", "config.toml"), - 'project_id = "demo"\n', - ); +describe("upgradeNoticeHook", () => { + async function runHook(opts: { readonly delegatedToGo?: boolean; readonly cached?: boolean }) { + const workdir = mkdtempSync(join(tmpdir(), "supabase-upgrade-notice-hook-")); + mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), 'project_id = "demo"\n'); if (opts.cached !== false) { - yield* fs.writeFileString(path.join(workdir, "supabase", ".temp", "cli-latest"), "v99.99.99"); + writeFileSync(join(workdir, "supabase", ".temp", "cli-latest"), "v99.99.99"); } const spans: Array = []; const tracer = Tracer.make({ @@ -807,10 +656,17 @@ layer(BunServices.layer)("upgradeNoticeHook", (it) => { }, }); - const written = yield* captureStderr( - withEnvVar( - "SUPABASE_NO_UPDATE_NOTIFIER", - "0", + const written: Array = []; + const realWrite = process.stderr.write.bind(process.stderr); + const realOptOut = process.env["SUPABASE_NO_UPDATE_NOTIFIER"]; + process.env["SUPABASE_NO_UPDATE_NOTIFIER"] = "0"; + process.stderr.write = ((chunk: unknown) => { + written.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + try { + await Effect.runPromise( upgradeNoticeHook( ["db", "branch", "list"], { @@ -821,47 +677,44 @@ layer(BunServices.layer)("upgradeNoticeHook", (it) => { }, () => Promise.resolve("v99.99.99"), ).pipe(Effect.withTracer(tracer), Effect.withTracerEnabled(true)), - ), - ); + ); + } finally { + process.stderr.write = realWrite; + if (realOptOut === undefined) delete process.env["SUPABASE_NO_UPDATE_NOTIFIER"]; + else process.env["SUPABASE_NO_UPDATE_NOTIFIER"] = realOptOut; + rmSync(workdir, { recursive: true, force: true }); + } const span = (name: string) => spans.find((candidate) => candidate.name === name); return { stderr: stripVTControlCharacters(written.join("")), span }; - }); + } - it.effect("prints the notice for a natively handled command", () => - Effect.gen(function* () { - expect((yield* runHook({})).stderr).toContain( - "A new version of Supabase CLI is available: v99.99.99", - ); - }), - ); + it("prints the notice for a natively handled command", async () => { + expect((await runHook({})).stderr).toContain( + "A new version of Supabase CLI is available: v99.99.99", + ); + }); - it.effect("stays silent when the run delegated to Go, which printed its own notice", () => - Effect.gen(function* () { - expect((yield* runHook({ delegatedToGo: true })).stderr).toBe(""); - }), - ); + it("stays silent when the run delegated to Go, which printed its own notice", async () => { + expect((await runHook({ delegatedToGo: true })).stderr).toBe(""); + }); - it.effect("traces a cache hit on the check span without a fetch span", () => - Effect.gen(function* () { - const { span } = yield* runHook({}); - expect(Object.fromEntries(span("UpgradeNotice.check")!.attributes)).toEqual({ - "cache.location": "project", - "cache.hit": true, - }); - expect(span("UpgradeNotice.fetch")).toBeUndefined(); - }), - ); + it("traces a cache hit on the check span without a fetch span", async () => { + const { span } = await runHook({}); + expect(Object.fromEntries(span("UpgradeNotice.check")!.attributes)).toEqual({ + "cache.location": "project", + "cache.hit": true, + }); + expect(span("UpgradeNotice.fetch")).toBeUndefined(); + }); - it.effect("traces a cache miss with the release fetch as a child of the check span", () => - Effect.gen(function* () { - const { span } = yield* runHook({ cached: false }); - const check = span("UpgradeNotice.check")!; - expect(Object.fromEntries(check.attributes)).toEqual({ - "cache.location": "project", - "cache.hit": false, - }); - const parent = span("UpgradeNotice.fetch")!.parent; - expect(Option.isSome(parent) ? parent.value.spanId : undefined).toBe(check.spanId); - }), - ); + it("traces a cache miss with the release fetch as a child of the check span", async () => { + const { span } = await runHook({ cached: false }); + const check = span("UpgradeNotice.check")!; + expect(Object.fromEntries(check.attributes)).toEqual({ + "cache.location": "project", + "cache.hit": false, + }); + const parent = span("UpgradeNotice.fetch")!.parent; + expect(Option.isSome(parent) ? parent.value.spanId : undefined).toBe(check.spanId); + }); });