Skip to content

Commit 85dfedd

Browse files
authored
feat(cli): port link and unlink commands to native TypeScript (#5426)
## What changed Promotes `supabase link` and `supabase unlink` from Phase-0 Go-binary proxy wrappers to native Effect TypeScript in the legacy (stable-channel) shell. Behaviour follows the Go source (`apps/cli-go/internal/{link,unlink}`) verbatim — same flags, stdout/stderr text, `supabase/.temp/*` file writes, API routes, and exit codes. ### `supabase link` - Resolves the project ref `--project-ref` → `SUPABASE_PROJECT_ID` → TTY prompt, **skipping** the on-disk ref file (matching Go's empty-MemMapFs), and emits the cobra `required flag(s) "project-ref" not set` error on a non-TTY. - Checks remote project status: 404 tolerated (branch projects), `INACTIVE` → paused error + dashboard unpause suggestion, non-`ACTIVE_HEALTHY` → stderr warning; writes `postgres-version`. - Fetches api-keys (`reveal=true`), then best-effort links: `storage-migration`, `pooler-url` (placeholder-stripped, session-mode rewrite; removed on `--skip-pooler`), and `rest`/`gotrue`/`storage` tenant versions. - Writes `project-ref` + `linked-project.json` and fires `cli_project_linked` (org/project `groupIdentify`, groups keyed by org **id**). ### `supabase unlink` - Reads the project ref, removes `supabase/.temp/`, deletes the keyring database-password entry, and surfaces all failures together (Go `errors.Join` parity). - Uses a minimal runtime layer — `unlink` makes no API calls and requires no access token (the management-API layer eagerly requires one). ### Shared / supporting - New `legacy-temp-paths` and `legacy-tenant-versions` helpers in `legacy/shared/` (existing `legacy-project-ref` / `legacy-linked-project-cache` call sites refactored onto the former). - `resolveForLink` on `LegacyProjectRefResolver`; `deleteProjectCredential` on `LegacyCredentials`. - The legacy credentials layer now honours `SUPABASE_NO_KEYRING=1` (matching `next/` and the cli-e2e harness), preventing non-interactive Keychain hangs for keyring-touching commands. ### Reviewer notes — intentional divergences from Go - The cosmetic `WARNING: Local database version differs…` message is omitted (it needs `config.toml [db].major_version` with CLI defaults, not surfaced by the legacy shell); the `postgres-version` file is still written. - The four discarded Go config probes (`/config/database/postgres`, `/postgrest`, `/config/auth`, `/network-restrictions`) are omitted — they only populated in-process config that standalone `link` discards. - The `Finished …` lines render plain (Go's `utils.Aqua` cyan), matching the established legacy-port convention. Both `SIDE_EFFECTS.md` files and `docs/go-cli-porting-status.md` (rows flipped to `ported`) are updated.
1 parent 2035377 commit 85dfedd

30 files changed

Lines changed: 1976 additions & 119 deletions

apps/cli-e2e/src/server/placeholder.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,33 @@ export function applyPlaceholders(input: string): { output: string } {
6161
return { output };
6262
}
6363

64+
// The project-ref placeholder (`__PROJECT_REF__`) is 15 characters, but the
65+
// Management API schema constrains project refs to `^[a-z]{20}$` (minLength 20).
66+
// The Go CLI doesn't validate response bodies, so it tolerates the short
67+
// placeholder; the TS port decodes responses against the generated schema and
68+
// rejects it (e.g. `link` calling `getProject`). When serving a recorded
69+
// response we therefore substitute any field whose value is *exactly* the
70+
// placeholder back to a valid 20-char ref. Substring occurrences such as
71+
// `db.__PROJECT_REF__.supabase.red` are left untouched so tests that assert on
72+
// the literal placeholder host keep matching.
73+
const PROJECT_REF_VALUE = /"__PROJECT_REF__"/g;
74+
const PLACEHOLDER_PROJECT_REF = "abcdefghijklmnopqrst";
75+
76+
/** Extract the 20-char project ref from a `/v1/projects/<ref>` request path,
77+
* falling back to a stable placeholder ref for refless endpoints (e.g. the
78+
* project list). */
79+
export function projectRefFromPath(urlPath: string): string {
80+
const match = urlPath.match(/\/projects\/([a-z]{20})(?:\/|$)/);
81+
return match?.[1] ?? PLACEHOLDER_PROJECT_REF;
82+
}
83+
84+
/** Replace exact-match `__PROJECT_REF__` string values in a serialized JSON
85+
* body with a schema-valid 20-char ref. Operates on the JSON string so only
86+
* full quoted values are rewritten, never substrings. */
87+
export function restoreProjectRef(json: string, ref: string): string {
88+
return json.replace(PROJECT_REF_VALUE, `"${ref}"`);
89+
}
90+
6491
/** Normalize dynamic segments in a URL path to stable unnumbered placeholders.
6592
* Apply this to both the stored fixture path and the incoming request path so
6693
* both sides of a scenario comparison transform identically. */

apps/cli-e2e/src/server/replay-server.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ import type {
99
FixtureStore,
1010
} from "./fixture-loader.ts";
1111
import { loadFixtures, loadScenario } from "./fixture-loader.ts";
12-
import { applyPlaceholders, fixtureKey, normalizeUrlPath } from "./placeholder.ts";
12+
import {
13+
applyPlaceholders,
14+
fixtureKey,
15+
normalizeUrlPath,
16+
projectRefFromPath,
17+
restoreProjectRef,
18+
} from "./placeholder.ts";
1319
import { matchFixture, resetCounters, sortBody, type SequenceCounters } from "./request-matcher.ts";
1420
import type { PgFixture, PgMockHandle } from "./pg-mock.ts";
1521

@@ -470,10 +476,15 @@ async function proxyAndRecord(
470476
scenario,
471477
});
472478

473-
return buildApiResponse(responseBody, upstreamStatus, {
474-
...responseHeaders,
475-
"content-type": responseContentType,
476-
});
479+
return buildApiResponse(
480+
responseBody,
481+
upstreamStatus,
482+
{
483+
...responseHeaders,
484+
"content-type": responseContentType,
485+
},
486+
projectRefFromPath(pathname),
487+
);
477488
}
478489

479490
/** Record a Docker interaction once its streamed body has fully drained. Errors
@@ -750,11 +761,14 @@ function nextFixtureIndex(keyDir: string): number {
750761
return max + 1;
751762
}
752763

753-
/** Build an API response, respecting HTTP no-body status codes (204, 304, 205). */
764+
/** Build an API response, respecting HTTP no-body status codes (204, 304, 205).
765+
* `projectRef` is the ref from the request path, used to restore short
766+
* `__PROJECT_REF__` placeholders to schema-valid 20-char refs in JSON bodies. */
754767
function buildApiResponse(
755768
body: unknown,
756769
status: number,
757770
headers: Record<string, string>,
771+
projectRef: string,
758772
): Response {
759773
if (status === 204 || status === 304 || status === 205) {
760774
return new Response(null, { status, headers });
@@ -770,7 +784,10 @@ function buildApiResponse(
770784
if (body === null) {
771785
return new Response(null, { status, headers });
772786
}
773-
return Response.json(body, { status, headers });
787+
return new Response(restoreProjectRef(JSON.stringify(body), projectRef), {
788+
status,
789+
headers: { "content-type": "application/json", ...headers },
790+
});
774791
}
775792

776793
function serveFromFixtures(
@@ -791,6 +808,7 @@ function serveFromFixtures(
791808
result.entry.response.body,
792809
result.entry.response.status,
793810
result.entry.response.headers,
811+
projectRefFromPath(pathname),
794812
);
795813
}
796814

@@ -882,6 +900,7 @@ function serveFromScenario(
882900
expected.response.body,
883901
expected.response.status,
884902
expected.response.headers,
903+
projectRefFromPath(pathname),
885904
);
886905
}
887906

apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,10 @@ describe("unlink", () => {
158158
});
159159

160160
// The success path (pre-populate project-ref → unlink succeeds) is omitted: the
161-
// ts-legacy unlink handler is a Phase 0 proxy to the Go binary, which attempts
162-
// a system keyring delete on exit. On Linux CI (no D-Bus session bus) the
163-
// keyring call returns an unhandled error and the binary exits 1. The error path
164-
// above already gives meaningful coverage for a proxy command.
161+
// unlink handler deletes the database-password keyring entry on success. On
162+
// Linux CI (no D-Bus session bus) the keyring call returns an unhandled error
163+
// and the command exits 1. The not-linked error path above gives meaningful
164+
// coverage; deeper success-path behaviour is covered by unlink.integration.test.ts.
165165

166166
testParity(["unlink"]);
167167
});

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,8 @@ Legend:
263263
| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) |
264264
| `login` | `wrapped` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) |
265265
| `logout` | `wrapped` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) |
266-
| `link` | `wrapped` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) |
267-
| `unlink` | `wrapped` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) |
266+
| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) |
267+
| `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) |
269269
| `init` | `wrapped` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) |
270270
| `services` | `wrapped` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) |

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

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ 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 { LegacyInvalidAccessTokenError } from "./legacy-errors.ts";
7+
import { LegacyCredentialDeleteError, LegacyInvalidAccessTokenError } from "./legacy-errors.ts";
88

99
const KEYRING_SERVICE = "Supabase CLI";
1010
const LEGACY_KEYRING_ACCOUNT = "access-token";
@@ -160,6 +160,64 @@ function deleteGoWindowsTarget(module: KeyringModule, account: string): boolean
160160
return false;
161161
}
162162
}
163+
// Delete the project database-password entry (keyed by project ref), surfacing a
164+
// real failure while ignoring the "nothing to delete" cases — mirroring Go's
165+
// unlink, which ignores both `keyring.ErrNotFound` AND `credentials.ErrNotSupported`
166+
// (backend unavailable) and only surfaces other errors (`unlink.go:36-40`).
167+
//
168+
// The plain `Entry(service, projectRef)` is the macOS/Linux form and the Windows
169+
// default. On Windows, Go also writes a separate target-shaped credential; it is
170+
// detected via `findCredentials` (a plain `getPassword` does not read the Go
171+
// target reliably) and deleted through the `withTarget` entry. The `withTarget`
172+
// entry is only constructed on Windows — on macOS its first argument is an
173+
// invalid keychain domain and throws.
174+
//
175+
// Each entry is probed before `deleteCredential()`: on macOS deleting an absent
176+
// entry blocks on a Keychain authorization prompt, and an absent read means
177+
// there is nothing to delete (ignorable, per Go). Only a real delete failure is
178+
// surfaced as `LegacyCredentialDeleteError`.
179+
const deleteKeyringEntryStrict = (
180+
module: KeyringModule,
181+
account: string,
182+
platform: RuntimePlatform,
183+
): Effect.Effect<boolean, LegacyCredentialDeleteError> =>
184+
Effect.gen(function* () {
185+
let deleted = false;
186+
187+
const plain = new module.Entry(KEYRING_SERVICE, account);
188+
if (readEntryPassword(plain)) {
189+
yield* Effect.try({
190+
try: () => {
191+
plain.deleteCredential();
192+
},
193+
catch: (cause) =>
194+
new LegacyCredentialDeleteError({
195+
message: `failed to delete project credential: ${String(cause)}`,
196+
}),
197+
});
198+
deleted = true;
199+
}
200+
201+
if (platform === "win32" && readGoWindowsTarget(module, account)) {
202+
const target = module.Entry.withTarget(
203+
goWindowsCredentialTarget(account),
204+
KEYRING_SERVICE,
205+
account,
206+
);
207+
yield* Effect.try({
208+
try: () => {
209+
target.deleteCredential();
210+
},
211+
catch: (cause) =>
212+
new LegacyCredentialDeleteError({
213+
message: `failed to delete project credential: ${String(cause)}`,
214+
}),
215+
});
216+
deleted = true;
217+
}
218+
219+
return deleted;
220+
});
163221

164222
const makeLegacyCredentials = Effect.gen(function* () {
165223
const fs = yield* FileSystem.FileSystem;
@@ -172,10 +230,16 @@ const makeLegacyCredentials = Effect.gen(function* () {
172230
const fallbackDir = path.join(runtimeInfo.homeDir, ".supabase");
173231
const fallbackPath = path.join(fallbackDir, "access-token");
174232

233+
// `SUPABASE_NO_KEYRING=1` disables the OS keyring entirely (matches `next/`'s
234+
// credentials layer and the cli-e2e harness, which sets it). Without this, any
235+
// unconditional keyring access — e.g. `unlink`'s credential delete — blocks on a
236+
// Keychain authorization prompt in non-interactive / CI contexts.
237+
const noKeyring = process.env["SUPABASE_NO_KEYRING"] === "1";
175238
const wsl = yield* detectWsl(fs);
176-
const keyringModule = wsl
177-
? Option.none<KeyringModule>()
178-
: yield* Effect.tryPromise(() => import("@napi-rs/keyring")).pipe(Effect.option);
239+
const keyringModule =
240+
wsl || noKeyring
241+
? Option.none<KeyringModule>()
242+
: yield* Effect.tryPromise(() => import("@napi-rs/keyring")).pipe(Effect.option);
179243

180244
const validate = (token: string): Effect.Effect<string, LegacyInvalidAccessTokenError> =>
181245
ACCESS_TOKEN_PATTERN.test(token)
@@ -261,6 +325,17 @@ const makeLegacyCredentials = Effect.gen(function* () {
261325
}
262326
return anyDeleted;
263327
}),
328+
329+
deleteProjectCredential: (projectRef: string) =>
330+
Effect.gen(function* () {
331+
// WSL / no keyring module: treated as `ErrNotSupported` — a no-op success.
332+
if (Option.isNone(keyringModule)) return false;
333+
return yield* deleteKeyringEntryStrict(
334+
keyringModule.value,
335+
projectRef,
336+
runtimeInfo.platform,
337+
);
338+
}),
264339
});
265340
});
266341

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import type { Effect, Option, Redacted } from "effect";
22
import { Context } from "effect";
33

4-
import type { LegacyInvalidAccessTokenError } from "./legacy-errors.ts";
4+
import type {
5+
LegacyCredentialDeleteError,
6+
LegacyInvalidAccessTokenError,
7+
} from "./legacy-errors.ts";
58

69
interface LegacyCredentialsShape {
710
readonly getAccessToken: Effect.Effect<
@@ -10,6 +13,19 @@ interface LegacyCredentialsShape {
1013
>;
1114
readonly saveAccessToken: (token: string) => Effect.Effect<void, LegacyInvalidAccessTokenError>;
1215
readonly deleteAccessToken: Effect.Effect<boolean>;
16+
/**
17+
* Deletes the stored database-password credential for a project from the OS
18+
* keyring (keyring service `"Supabase CLI"`, account = the **project ref** —
19+
* distinct from the access-token entry). Used by `supabase unlink`.
20+
*
21+
* Returns `true` when an entry was removed, `false` when none existed or the
22+
* keyring is unavailable (WSL). Fails with `LegacyCredentialDeleteError` only
23+
* for real keyring errors (e.g. permission denied), mirroring Go's unlink
24+
* which ignores `ErrNotFound` / `ErrNotSupported` but surfaces everything else.
25+
*/
26+
readonly deleteProjectCredential: (
27+
projectRef: string,
28+
) => Effect.Effect<boolean, LegacyCredentialDeleteError>;
1329
}
1430

1531
export class LegacyCredentials extends Context.Service<LegacyCredentials, LegacyCredentialsShape>()(

apps/cli/src/legacy/auth/legacy-errors.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,14 @@ export class LegacyPlatformAuthRequiredError extends Data.TaggedError(
1111
)<{
1212
readonly message: string;
1313
}> {}
14+
15+
/**
16+
* Raised by `deleteProjectCredential` when removing a stored database-password
17+
* credential from the OS keyring fails for a reason other than "entry not
18+
* found" (which is ignored). Mirrors `supabase unlink`'s behaviour of collecting
19+
* non-`ErrNotFound` / non-`ErrNotSupported` keyring errors
20+
* (`apps/cli-go/internal/unlink/unlink.go:36-40`).
21+
*/
22+
export class LegacyCredentialDeleteError extends Data.TaggedError("LegacyCredentialDeleteError")<{
23+
readonly message: string;
24+
}> {}

apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ function mockCredentials(token: Option.Option<string>) {
3636
getAccessToken: Effect.succeed(Option.map(token, Redacted.make)),
3737
saveAccessToken: () => Effect.void,
3838
deleteAccessToken: Effect.succeed(false),
39+
deleteProjectCredential: () => Effect.succeed(false),
3940
});
4041
}
4142

0 commit comments

Comments
 (0)