Skip to content

Commit beee75c

Browse files
authored
feat(cli): port login and logout commands to native TypeScript (#5446)
Promotes `supabase login` and `supabase logout` from Phase 0 Go-binary proxy wrappers to native TypeScript Effect handlers in the legacy shell (CLI-1302). Parity with the Go CLI is the explicit priority — matching stdout/stderr strings, streams, exit codes, the OAuth crypto flow, the credential-delete ordering, and the telemetry stitch. ## login - **Token path** — resolves the token from `--token` → `SUPABASE_ACCESS_TOKEN` → piped stdin (non-TTY), saves it, then stitches telemetry identity and prints `You are now logged in. Happy coding!`. - **Browser OAuth flow** — ECDH P-256 keypair + AES-256-GCM decrypt (`LegacyLoginCrypto`), session polling at `/platform/cli/login/{sessionId}` + best-effort `/v1/profile` gotrue-id fetch (`LegacyLoginApi`). Verify-with-retries mirrors Go's backoff: 3 attempts total, `Retry (n/2)` printed on the first two failures only. - **Telemetry** — fetches the gotrue id, stitches or clears the `distinct_id`, and always captures `cli_login_completed`. The stitch *aliases* only (no `identify`) to match Go's `StitchLogin`. - **Profile persistence** — on success, an explicitly-set profile is written to `~/.supabase/profile` (Go's `PostRunE` / `SaveProfileName`); `LegacyCliConfig` now reads that file back as the lowest-precedence profile source. - Claude Code plugin hint on a TTY stdout. ## logout - Confirm prompt honoring `--yes`, the not-logged-in stderr path (exits 0, skips the credential sweep), real-removal-failure propagation, and the project-credential sweep. ## Shared infra - `LegacyCredentials.deleteAccessToken` reshaped to the Go-faithful tri-state (`void` | `LegacyNotLoggedInError` | `LegacyDeleteTokenError`) reproducing Go's file-first / legacy-keyring / profile-keyring ordering — including the deliberate "file removed yet *not logged in*" quirk on no-keyring hosts. Adds `deleteAllProjectCredentials`. - `LegacyTelemetryState` gains `stitchLogin` / `clearDistinctId` (alias + persist, sharing one JSON read/merge internal). ## Reviewer-relevant notes - The profile-file **read** fallback lives in the shared `LegacyCliConfig` layer, so it now applies to every legacy command (correct Go parity, wider than just login). - `~/.supabase/profile` write failure is fatal (exit 1), matching Go's "block subsequent CI commands on save failure". SIDE_EFFECTS.md added for both commands; the porting-status tracker is flipped to `ported`.
1 parent f5b186a commit beee75c

34 files changed

Lines changed: 2256 additions & 133 deletions

apps/cli/docs/go-cli-porting-status.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,8 @@ Legend:
261261
| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) |
262262
| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) |
263263
| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) |
264-
| `login` | `wrapped` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) |
265-
| `logout` | `wrapped` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) |
264+
| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) |
265+
| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) |
266266
| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) |
267267
| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) |
268268
| `bootstrap` | `wrapped` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) |

apps/cli/src/legacy/auth/legacy-credentials.layer.ts

Lines changed: 136 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts";
44
import { normalizeKeyringToken } from "../../shared/auth/keyring-token.ts";
55
import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts";
66
import { LegacyCredentials } from "./legacy-credentials.service.ts";
7-
import { LegacyCredentialDeleteError, LegacyInvalidAccessTokenError } from "./legacy-errors.ts";
7+
import {
8+
LegacyCredentialDeleteError,
9+
LegacyDeleteTokenError,
10+
LegacyInvalidAccessTokenError,
11+
LegacyNotLoggedInError,
12+
} from "./legacy-errors.ts";
813

914
const KEYRING_SERVICE = "Supabase CLI";
1015
const LEGACY_KEYRING_ACCOUNT = "access-token";
@@ -14,6 +19,9 @@ const ACCESS_TOKEN_PATTERN = /^sbp_(oauth_)?[a-f0-9]{40}$/;
1419

1520
const INVALID_TOKEN_MESSAGE = "Invalid access token format. Must be like `sbp_0102...1920`.";
1621

22+
// Go's `utils.ErrNotLoggedIn` (`access_token.go:19`).
23+
const NOT_LOGGED_IN_MESSAGE = "You were not logged in, nothing to do.";
24+
1725
type KeyringModule = typeof import("@napi-rs/keyring");
1826
type KeyringEntry = InstanceType<KeyringModule["Entry"]>;
1927
type RuntimePlatform = NodeJS.Platform;
@@ -219,6 +227,92 @@ const deleteKeyringEntryStrict = (
219227
return deleted;
220228
});
221229

230+
// Delete the access-token profile entry, distinguishing the three outcomes Go's
231+
// `credentials.StoreProvider.Delete(profile)` collapses into via the
232+
// `access_token.go:110-117` error mapping:
233+
// - `"deleted"` — an entry existed and was removed (→ logged out, exit 0);
234+
// - `"notFound"` — no entry existed (→ Go's `ErrNotLoggedIn`, exit 0);
235+
// - `LegacyDeleteTokenError` — a real `deleteCredential()` failure (exit 1).
236+
// Like `deleteKeyringEntryStrict`, the entry is probed first so deleting an
237+
// absent macOS entry never blocks on a Keychain prompt, and the Windows
238+
// target-shaped credential is handled separately.
239+
const deleteProfileKeyringEntry = (
240+
module: KeyringModule,
241+
account: string,
242+
platform: RuntimePlatform,
243+
): Effect.Effect<"deleted" | "notFound", LegacyDeleteTokenError> =>
244+
Effect.gen(function* () {
245+
let found = false;
246+
247+
const plain = new module.Entry(KEYRING_SERVICE, account);
248+
if (readEntryPassword(plain)) {
249+
yield* Effect.try({
250+
try: () => {
251+
plain.deleteCredential();
252+
},
253+
catch: (cause) =>
254+
new LegacyDeleteTokenError({
255+
message: `failed to delete access token from keyring: ${String(cause)}`,
256+
}),
257+
});
258+
found = true;
259+
}
260+
261+
if (platform === "win32" && readGoWindowsTarget(module, account)) {
262+
const target = module.Entry.withTarget(
263+
goWindowsCredentialTarget(account),
264+
KEYRING_SERVICE,
265+
account,
266+
);
267+
yield* Effect.try({
268+
try: () => {
269+
target.deleteCredential();
270+
},
271+
catch: (cause) =>
272+
new LegacyDeleteTokenError({
273+
message: `failed to delete access token from keyring: ${String(cause)}`,
274+
}),
275+
});
276+
found = true;
277+
}
278+
279+
return found ? "deleted" : "notFound";
280+
});
281+
282+
// Best-effort wipe of every entry in the `"Supabase CLI"` keyring namespace —
283+
// the project database-password credentials `link` writes. Mirrors Go's
284+
// `keyring.DeleteAll(namespace)` (`store.go:71`). Never fails: per-entry delete
285+
// errors are swallowed so a single stuck credential can't abort logout.
286+
//
287+
// On Windows, Go stores credentials under the target-shaped name
288+
// `Supabase CLI:<account>` rather than the plain `Entry(service, account)` form
289+
// (see `writeGoWindowsTarget`). So each discovered account is deleted in BOTH
290+
// forms — the plain entry and, on win32, the Go target entry — mirroring the
291+
// individual deletes in `deleteProfileKeyringEntry`. Without this, a Go-written
292+
// project credential would survive `logout` on Windows.
293+
const deleteAllKeyringEntries = (
294+
module: KeyringModule,
295+
platform: RuntimePlatform,
296+
): Effect.Effect<void> =>
297+
Effect.sync(() => {
298+
let entries: ReadonlyArray<{ account: string }>;
299+
try {
300+
entries = module.findCredentials(KEYRING_SERVICE);
301+
} catch {
302+
return;
303+
}
304+
for (const { account } of entries) {
305+
try {
306+
new module.Entry(KEYRING_SERVICE, account).deleteCredential();
307+
} catch {
308+
// best-effort per entry
309+
}
310+
if (platform === "win32" && readGoWindowsTarget(module, account)) {
311+
deleteGoWindowsTarget(module, account);
312+
}
313+
}
314+
});
315+
222316
const makeLegacyCredentials = Effect.gen(function* () {
223317
const fs = yield* FileSystem.FileSystem;
224318
const path = yield* Path.Path;
@@ -307,23 +401,50 @@ const makeLegacyCredentials = Effect.gen(function* () {
307401
}),
308402

309403
deleteAccessToken: Effect.gen(function* () {
310-
let anyDeleted = false;
311-
if (Option.isSome(keyringModule)) {
312-
if (yield* tryKeyringDelete(keyringModule.value, profileAccount, runtimeInfo.platform)) {
313-
anyDeleted = true;
314-
}
315-
if (
316-
yield* tryKeyringDelete(keyringModule.value, LEGACY_KEYRING_ACCOUNT, runtimeInfo.platform)
317-
) {
318-
anyDeleted = true;
319-
}
320-
}
404+
// Reproduce Go's `utils.DeleteAccessToken` (`access_token.go:100-119`) in
405+
// its exact order.
406+
407+
// 1. Always remove the fallback token file first. A missing file is
408+
// ignored (Go's `errors.Is(err, os.ErrNotExist)`); any other removal
409+
// failure aborts before the keyring is touched.
321410
const exists = yield* fs.exists(fallbackPath).pipe(Effect.orElseSucceed(() => false));
322411
if (exists) {
323-
yield* fs.remove(fallbackPath).pipe(Effect.orDie);
324-
anyDeleted = true;
412+
yield* fs.remove(fallbackPath).pipe(
413+
Effect.catch((error) =>
414+
Effect.fail(
415+
new LegacyDeleteTokenError({
416+
message: `failed to remove access token file: ${error.message}`,
417+
}),
418+
),
419+
),
420+
);
421+
}
422+
423+
// 2. Best-effort delete of the legacy `access-token` keyring account.
424+
// Go debug-logs and ignores any error here — never affects the result.
425+
if (Option.isSome(keyringModule)) {
426+
yield* tryKeyringDelete(keyringModule.value, LEGACY_KEYRING_ACCOUNT, runtimeInfo.platform);
427+
}
428+
429+
// 3. Delete the profile keyring account — this alone decides the outcome.
430+
// No keyring backend (WSL / `SUPABASE_NO_KEYRING` / unsupported) maps to
431+
// Go's `ErrNotSupported`/`ErrUnsupportedPlatform` → `ErrNotLoggedIn`.
432+
if (Option.isNone(keyringModule)) {
433+
return yield* Effect.fail(new LegacyNotLoggedInError({ message: NOT_LOGGED_IN_MESSAGE }));
325434
}
326-
return anyDeleted;
435+
const outcome = yield* deleteProfileKeyringEntry(
436+
keyringModule.value,
437+
profileAccount,
438+
runtimeInfo.platform,
439+
);
440+
if (outcome === "notFound") {
441+
return yield* Effect.fail(new LegacyNotLoggedInError({ message: NOT_LOGGED_IN_MESSAGE }));
442+
}
443+
}),
444+
445+
deleteAllProjectCredentials: Effect.gen(function* () {
446+
if (Option.isNone(keyringModule)) return;
447+
yield* deleteAllKeyringEntries(keyringModule.value, runtimeInfo.platform);
327448
}),
328449

329450
deleteProjectCredential: (projectRef: string) =>

0 commit comments

Comments
 (0)