diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 541aedff00..0ae99db8c8 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -286,8 +286,8 @@ Legend: | `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | | `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | | `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | -| `functions delete` | `wrapped` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | -| `functions download` | `wrapped` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | +| `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | +| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | | `functions deploy` | `wrapped` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | | `functions new` | `wrapped` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | | `functions serve` | `wrapped` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | diff --git a/apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md index 176ca917b7..f3914b1b22 100644 --- a/apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md @@ -42,15 +42,16 @@ Prints a success message after the function is deleted. ### `--output-format json` -Not applicable (proxied to Go binary). +Prints a structured success result with the function slug and project ref. ### `--output-format stream-json` -Not applicable (proxied to Go binary). +Prints a structured success result with the function slug and project ref. ## Notes - Requires exactly one argument: the function slug/name. - Does NOT remove the function from the local filesystem. - Requires a linked project (`--project-ref` or linked project config). -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary. +- Runs natively in TypeScript through the Management API. +- Refreshes the linked-project telemetry cache and flushes telemetry state after resolving a project ref. diff --git a/apps/cli/src/legacy/commands/functions/delete/delete.command.ts b/apps/cli/src/legacy/commands/functions/delete/delete.command.ts index 4f3d653cb6..c7fa7bec59 100644 --- a/apps/cli/src/legacy/commands/functions/delete/delete.command.ts +++ b/apps/cli/src/legacy/commands/functions/delete/delete.command.ts @@ -1,5 +1,8 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyFunctionsDelete } from "./delete.handler.ts"; const config = { @@ -19,5 +22,21 @@ export const legacyFunctionsDeleteCommand = Command.make("delete", config).pipe( "Delete a Function from the linked Supabase project. This does NOT remove the Function locally.", ), Command.withShortDescription("Delete a Function from Supabase"), - Command.withHandler((flags) => legacyFunctionsDelete(flags)), + Command.withExamples([ + { + command: "supabase functions delete hello-world", + description: "Delete a deployed function from the linked project", + }, + { + command: "supabase functions delete hello-world --project-ref abcdefghijklmnopqrst", + description: "Delete a deployed function from a specific project", + }, + ]), + Command.withHandler((flags) => + legacyFunctionsDelete(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["functions", "delete"])), ); diff --git a/apps/cli/src/legacy/commands/functions/delete/delete.handler.ts b/apps/cli/src/legacy/commands/functions/delete/delete.handler.ts index a9689084ff..2895f1c7d3 100644 --- a/apps/cli/src/legacy/commands/functions/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/functions/delete/delete.handler.ts @@ -1,12 +1,42 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { deleteFunction } from "../../../../shared/functions/delete.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import type { LegacyFunctionsDeleteFlags } from "./delete.command.ts"; export const legacyFunctionsDelete = Effect.fn("legacy.functions.delete")(function* ( flags: LegacyFunctionsDeleteFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["functions", "delete", flags.functionName]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + let resolvedProjectRef = Option.none(); + + yield* deleteFunction( + { slug: flags.functionName, projectRef: flags.projectRef }, + { + api, + resolveProjectRef: (projectRef) => + resolver.resolve(projectRef).pipe( + Effect.tap((ref) => + Effect.sync(() => { + resolvedProjectRef = Option.some(ref); + }), + ), + ), + }, + ).pipe( + Effect.ensuring( + Effect.suspend(() => + Option.match(resolvedProjectRef, { + onNone: () => Effect.void, + onSome: (ref) => linkedProjectCache.cache(ref), + }), + ), + ), + Effect.ensuring(telemetryState.flush), + ); }); diff --git a/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts new file mode 100644 index 0000000000..c3d78584d3 --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; + +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { legacyFunctionsDelete } from "./delete.handler.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-functions-delete-legacy-"); + +describe("legacy functions delete", () => { + it.live("deletes a function natively through the Management API", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: null } }); + const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const telemetry = mockLegacyTelemetryStateTracked(); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + linkedProjectCache: linkedProjectCache.layer, + telemetry: telemetry.layer, + }); + + return Effect.gen(function* () { + yield* legacyFunctionsDelete({ + functionName: "hello-world", + projectRef: Option.none(), + }); + + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.method).toBe("DELETE"); + expect(api.requests[0]?.url).toBe( + "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst/functions/hello-world", + ); + expect(out.stdoutText).toBe( + "Deleted Function hello-world from project abcdefghijklmnopqrst.\n", + ); + expect(linkedProjectCache.cached).toBe(true); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("uses an explicit project ref", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ response: { status: 200, body: null } }); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ + workdir: tempRoot.current, + projectId: Option.none(), + }), + }); + + return Effect.gen(function* () { + yield* legacyFunctionsDelete({ + functionName: "hello-world", + projectRef: Option.some("qrstuvwxyzabcdefghij"), + }); + + expect(api.requests[0]?.url).toContain("/projects/qrstuvwxyzabcdefghij/functions/"); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md index bcdc672652..7a70bcb517 100644 --- a/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md @@ -8,15 +8,23 @@ ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ---------- | ---------------------------------- | -| `/supabase/functions//index.ts` | TypeScript | always (downloads function source) | +| Path | Format | When | +| --------------------------------------------------- | ------ | ---------------------------------------- | +| `/supabase/functions//` | bytes | for each source file returned by the API | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------ | ------------ | ------------ | ---------------------- | -| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | function source code | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------ | ------------ | ------------ | ------------------------------------------ | +| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | function slugs, when downloading all | +| `GET` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | entrypoint path, when absent from metadata | +| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | multipart function source | + +## Subprocesses + +| Command | When | Purpose | +| ------------------------------------ | ----------------------------------- | ----------------------------------- | +| `supabase-go functions download ...` | `--use-docker` or `--legacy-bundle` | preserve hidden compatibility modes | ## Environment Variables @@ -42,15 +50,16 @@ Prints progress and success messages as functions are downloaded. ### `--output-format json` -Not applicable (proxied to Go binary). +Prints a structured success result with the downloaded function slugs and project ref. ### `--output-format stream-json` -Not applicable (proxied to Go binary). +Prints a structured success result with the downloaded function slugs and project ref. ## Notes - If no function name is provided, downloads all functions. - Requires a linked project (`--project-ref` or linked project config). +- Native downloads reject path traversal and symlink escapes before writing source files. - `--use-docker` and `--legacy-bundle` are hidden flags forwarded to the Go binary for backward compatibility; they are mutually exclusive with `--use-api`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary. +- Refreshes the linked-project telemetry cache and flushes telemetry state after resolving a project ref. diff --git a/apps/cli/src/legacy/commands/functions/download/download.command.ts b/apps/cli/src/legacy/commands/functions/download/download.command.ts index 71c9d42639..0d3f559dd0 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.command.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.command.ts @@ -1,5 +1,8 @@ import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyFunctionsDownload } from "./download.handler.ts"; const config = { @@ -31,5 +34,21 @@ export const legacyFunctionsDownloadCommand = Command.make("download", config).p "Download the source code for a Function from the linked Supabase project. If no function name is provided, downloads all functions.", ), Command.withShortDescription("Download a Function from Supabase"), - Command.withHandler((flags) => legacyFunctionsDownload(flags)), + Command.withExamples([ + { + command: "supabase functions download hello-world", + description: "Download a single function from the linked project", + }, + { + command: "supabase functions download --project-ref abcdefghijklmnopqrst", + description: "Download all functions from a specific project", + }, + ]), + Command.withHandler((flags) => + legacyFunctionsDownload(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["functions", "download"])), ); diff --git a/apps/cli/src/legacy/commands/functions/download/download.handler.ts b/apps/cli/src/legacy/commands/functions/download/download.handler.ts index 61bd8b4d42..70cb9da73c 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.handler.ts @@ -1,16 +1,49 @@ import { Effect, Option } from "effect"; +import { + downloadFunctions, + makeGoProxyDownloadArgs, +} from "../../../../shared/functions/download.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import type { LegacyFunctionsDownloadFlags } from "./download.command.ts"; export const legacyFunctionsDownload = Effect.fn("legacy.functions.download")(function* ( flags: LegacyFunctionsDownloadFlags, ) { + const api = yield* LegacyPlatformApi; + const cliConfig = yield* LegacyCliConfig; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; const proxy = yield* LegacyGoProxy; - const args: string[] = ["functions", "download"]; - if (Option.isSome(flags.functionName)) args.push(flags.functionName.value); - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - if (flags.useApi) args.push("--use-api"); - if (flags.useDocker) args.push("--use-docker"); - if (flags.legacyBundle) args.push("--legacy-bundle"); - yield* proxy.exec(args); + let resolvedProjectRef = Option.none(); + + yield* downloadFunctions(flags, { + api, + projectRoot: cliConfig.workdir, + resolveProjectRef: (projectRef) => + resolver.resolve(projectRef).pipe( + Effect.tap((ref) => + Effect.sync(() => { + resolvedProjectRef = Option.some(ref); + }), + ), + ), + proxyDownload: (proxyFlags, projectRef) => + proxy.exec(makeGoProxyDownloadArgs(proxyFlags, projectRef)), + }).pipe( + Effect.ensuring( + Effect.suspend(() => + Option.match(resolvedProjectRef, { + onNone: () => Effect.void, + onSome: (ref) => linkedProjectCache.cache(ref), + }), + ), + ), + Effect.ensuring(telemetryState.flush), + ); }); diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts new file mode 100644 index 0000000000..dcc6077f5e --- /dev/null +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "@effect/vitest"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Effect, Layer, Option } from "effect"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import { + buildLegacyTestRuntime, + legacyJsonResponse, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import type { LegacyFunctionsDownloadFlags } from "./download.command.ts"; +import { legacyFunctionsDownload } from "./download.handler.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-functions-download-legacy-"); +const baseFlags: LegacyFunctionsDownloadFlags = { + functionName: Option.some("hello-world"), + projectRef: Option.none(), + useApi: false, + useDocker: false, + legacyBundle: false, +}; + +function multipartResponse(request: Parameters[0]) { + const boundary = "legacy-download-test"; + const body = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="metadata"', + "Content-Type: application/json", + "", + JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + `--${boundary}`, + 'Content-Disposition: form-data; name="file"; filename="source/index.ts"', + "", + "console.log('legacy native')", + `--${boundary}--`, + "", + ].join("\r\n"); + return HttpClientResponse.fromWeb( + request, + new Response(body, { + status: 200, + headers: { "content-type": `multipart/form-data; boundary=${boundary}` }, + }), + ); +} + +function mockProxy() { + const calls: Array> = []; + return { + calls, + layer: Layer.succeed(LegacyGoProxy, { + exec: (args) => + Effect.sync(() => { + calls.push([...args]); + }), + }), + }; +} + +describe("legacy functions download", () => { + it.live("downloads a function natively into the legacy workdir", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + handler: (request) => + request.url.endsWith("/body") + ? Effect.succeed(multipartResponse(request)) + : Effect.succeed(legacyJsonResponse(request, 200, {})), + }); + const proxy = mockProxy(); + const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const telemetry = mockLegacyTelemetryStateTracked(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + linkedProjectCache: linkedProjectCache.layer, + telemetry: telemetry.layer, + }), + proxy.layer, + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload(baseFlags); + + expect(proxy.calls).toEqual([]); + expect( + yield* Effect.tryPromise(() => + readFile( + join(tempRoot.current, "supabase", "functions", "hello-world", "index.ts"), + "utf8", + ), + ), + ).toBe("console.log('legacy native')"); + expect(out.stderrText).toContain( + "Downloaded Function hello-world from project abcdefghijklmnopqrst.", + ); + expect(linkedProjectCache.cached).toBe(true); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("keeps hidden Docker compatibility mode behind the Go proxy", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + ); + + return Effect.gen(function* () { + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + + expect(api.requests).toEqual([]); + expect(proxy.calls).toEqual([ + [ + "functions", + "download", + "hello-world", + "--project-ref", + "abcdefghijklmnopqrst", + "--use-docker", + ], + ]); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/next/commands/functions/delete/delete.command.ts b/apps/cli/src/next/commands/functions/delete/delete.command.ts new file mode 100644 index 0000000000..db28aac975 --- /dev/null +++ b/apps/cli/src/next/commands/functions/delete/delete.command.ts @@ -0,0 +1,47 @@ +import { Layer } from "effect"; +import { Argument, Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { credentialsLayer } from "../../../auth/credentials.layer.ts"; +import { platformApiLayer } from "../../../auth/platform-api.layer.ts"; +import { projectLinkStateLayer } from "../../../config/project-link-state.layer.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { functionsDelete } from "./delete.handler.ts"; + +const config = { + slug: Argument.string("slug").pipe(Argument.withDescription("Edge Function slug to delete.")), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type FunctionsDeleteFlags = CliCommand.Command.Config.Infer; + +const functionsDeleteRuntimeLayer = Layer.mergeAll( + platformApiLayer.pipe(Layer.provide(credentialsLayer)), + projectLinkStateLayer, + commandRuntimeLayer(["functions", "delete"]), +); + +export const functionsDeleteCommand = Command.make("delete", config).pipe( + Command.withDescription( + "Delete a Function from the linked Supabase project. This does NOT remove the Function locally.", + ), + Command.withShortDescription("Delete a Function from Supabase"), + Command.withExamples([ + { + command: "supabase functions delete hello-world", + description: "Delete a deployed function from the linked project", + }, + { + command: "supabase functions delete hello-world --project-ref abcdefghijklmnopqrst", + description: "Delete a deployed function from a specific project", + }, + ]), + Command.withHandler((flags) => + functionsDelete(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(functionsDeleteRuntimeLayer), +); diff --git a/apps/cli/src/next/commands/functions/delete/delete.handler.ts b/apps/cli/src/next/commands/functions/delete/delete.handler.ts new file mode 100644 index 0000000000..ae9ab13060 --- /dev/null +++ b/apps/cli/src/next/commands/functions/delete/delete.handler.ts @@ -0,0 +1,12 @@ +import { Effect } from "effect"; +import { PlatformApi } from "../../../auth/platform-api.service.ts"; +import { deleteFunction } from "../../../../shared/functions/delete.ts"; +import { resolveProjectRef } from "../functions.shared.ts"; +import type { FunctionsDeleteFlags } from "./delete.command.ts"; + +export const functionsDelete = Effect.fn("functions.delete")(function* ( + flags: FunctionsDeleteFlags, +) { + const api = yield* PlatformApi; + yield* deleteFunction(flags, { api, resolveProjectRef }); +}); diff --git a/apps/cli/src/next/commands/functions/delete/delete.integration.test.ts b/apps/cli/src/next/commands/functions/delete/delete.integration.test.ts new file mode 100644 index 0000000000..6914188219 --- /dev/null +++ b/apps/cli/src/next/commands/functions/delete/delete.integration.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from "@effect/vitest"; +import { makeApiClient } from "@supabase/api/effect"; +import { Effect, Layer, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { PlatformApi } from "../../../auth/platform-api.service.ts"; +import { + ProjectNotLinkedError, + type ProjectLinkStateValue, +} from "../../../config/project-link-state.service.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { emptyEnv, mockOutput, mockProjectLinkState } from "../../../../../tests/helpers/mocks.ts"; +import type { FunctionsDeleteFlags } from "./delete.command.ts"; +import { + DeleteFunctionNetworkError, + DeleteFunctionUnexpectedStatusError, + FunctionNotFoundError, + InvalidFunctionSlugError, +} from "../../../../shared/functions/delete.errors.ts"; +import { functionsDelete } from "./delete.handler.ts"; + +const PROJECT_REF = "abcdefghijklmnopqrst"; +const BRANCH_REF = "branchrefabcdefghij"; + +const LINK_STATE: ProjectLinkStateValue = { + project: { + ref: PROJECT_REF, + name: "Linked Project", + organization_id: "org-id", + organization_slug: "org-slug", + }, + active_branch: { + ref: BRANCH_REF, + name: "main", + is_default: true, + }, + fetchedAt: "2026-01-01T00:00:00.000Z", + versions: {}, +}; + +const BASE_FLAGS: FunctionsDeleteFlags = { + slug: "hello-world", + projectRef: Option.none(), +}; + +function httpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +function textResponse( + request: HttpClientRequest.HttpClientRequest, + status: number, + body = "", +): HttpClientResponse.HttpClientResponse { + return HttpClientResponse.fromWeb( + request, + new Response(body, { + status, + headers: { + "content-type": "text/plain", + }, + }), + ); +} + +function mockDeleteApi(opts: { status?: number; body?: string } = {}) { + const requests: Array<{ + url: string; + headers: Readonly>; + }> = []; + + const layer = Layer.effect( + PlatformApi, + makeApiClient({ + baseUrl: "https://api.supabase.com", + accessToken: "test-token", + userAgent: "supabase", + headers: { + "X-Supabase-Command": "functions delete", + "X-Supabase-Command-Run-ID": "run-123", + }, + }), + ).pipe( + Layer.provide( + httpClientLayer((request) => { + requests.push({ + url: request.url, + headers: request.headers, + }); + return Effect.succeed(textResponse(request, opts.status ?? 200, opts.body ?? "")); + }), + ), + ); + + return { + layer, + get requests() { + return requests; + }, + }; +} + +function setup( + opts: { + linked?: boolean; + format?: "text" | "json" | "stream-json"; + apiStatus?: number; + apiBody?: string; + } = {}, +) { + const out = mockOutput({ format: opts.format ?? "text", interactive: false }); + const api = mockDeleteApi({ status: opts.apiStatus, body: opts.apiBody }); + const layer = Layer.mergeAll( + emptyEnv(), + out.layer, + mockProjectLinkState(opts.linked === false ? undefined : LINK_STATE), + api.layer, + ); + + return { out, layer, api }; +} + +describe("functions delete", () => { + it.live("deletes a function from the linked project in text mode", () => + Effect.gen(function* () { + const { out, layer, api } = setup(); + + yield* functionsDelete(BASE_FLAGS).pipe(Effect.provide(layer)); + + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.url).toBe( + "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst/functions/hello-world", + ); + expect(api.requests[0]?.headers["x-supabase-command"]).toBe("functions delete"); + expect(out.stdoutText).toBe( + "Deleted Function hello-world from project abcdefghijklmnopqrst.\n", + ); + }), + ); + + it.live("uses an explicit --project-ref without requiring a linked project", () => + Effect.gen(function* () { + const { out, layer, api } = setup({ linked: false }); + + yield* functionsDelete({ + slug: "hello-world", + projectRef: Option.some("qrstuvwxyzabcdefghij"), + }).pipe(Effect.provide(layer)); + + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.url).toBe( + "https://api.supabase.com/v1/projects/qrstuvwxyzabcdefghij/functions/hello-world", + ); + expect(out.stdoutText).toBe( + "Deleted Function hello-world from project qrstuvwxyzabcdefghij.\n", + ); + }), + ); + + it.live("fails when neither a linked project nor --project-ref is available", () => + Effect.gen(function* () { + const { layer } = setup({ linked: false }); + + const error = yield* functionsDelete(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(ProjectNotLinkedError); + }), + ); + + it.live("fails for invalid function slugs before calling the API", () => + Effect.gen(function* () { + const { layer, api } = setup(); + + const error = yield* functionsDelete({ + slug: "hello.world", + projectRef: Option.none(), + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(InvalidFunctionSlugError); + expect(api.requests).toHaveLength(0); + }), + ); + + it.live("maps API 404 responses to FunctionNotFoundError", () => + Effect.gen(function* () { + const { layer } = setup({ apiStatus: 404, apiBody: "not found" }); + + const error = yield* functionsDelete(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(FunctionNotFoundError); + expect(error.message).toBe( + "Function hello-world does not exist on the Supabase project: nothing to delete", + ); + }), + ); + + it.live("maps network failures to Go-style delete errors", () => + Effect.gen(function* () { + const layer = Layer.mergeAll( + emptyEnv(), + mockOutput({ format: "text", interactive: false }).layer, + mockProjectLinkState(LINK_STATE), + Layer.effect( + PlatformApi, + makeApiClient({ + baseUrl: "https://api.supabase.com", + accessToken: "test-token", + userAgent: "supabase", + headers: { + "X-Supabase-Command": "functions delete", + "X-Supabase-Command-Run-ID": "run-123", + }, + }), + ).pipe( + Layer.provide( + httpClientLayer((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause: new Error("network error"), + description: "network error", + }), + }), + ), + ), + ), + ), + ); + + const error = yield* functionsDelete(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(DeleteFunctionNetworkError); + expect(error.message).toBe("failed to delete function: network error"); + }), + ); + + it.live("maps unexpected statuses to Go-style delete errors", () => + Effect.gen(function* () { + const { layer } = setup({ apiStatus: 503, apiBody: "unavailable" }); + + const error = yield* functionsDelete(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(DeleteFunctionUnexpectedStatusError); + expect(error.message).toBe("unexpected delete function status 503: unavailable"); + }), + ); + + it.live("emits a JSON failure payload instead of throwing in JSON mode", () => + Effect.gen(function* () { + const { out, layer } = setup({ format: "json", apiStatus: 404, apiBody: "not found" }); + + yield* functionsDelete(BASE_FLAGS).pipe(withJsonErrorHandling, Effect.provide(layer)); + + expect(out.messages).toContainEqual(expect.objectContaining({ type: "fail" })); + }), + ); + + it.live("emits structured success data in JSON mode", () => + Effect.gen(function* () { + const { out, layer } = setup({ format: "json" }); + + yield* functionsDelete(BASE_FLAGS).pipe(Effect.provide(layer)); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Deleted Edge Function.", + data: { + function_slug: "hello-world", + project_ref: PROJECT_REF, + }, + }), + ); + }), + ); +}); diff --git a/apps/cli/src/next/commands/functions/download/download.command.ts b/apps/cli/src/next/commands/functions/download/download.command.ts new file mode 100644 index 0000000000..4432db7c0b --- /dev/null +++ b/apps/cli/src/next/commands/functions/download/download.command.ts @@ -0,0 +1,65 @@ +import { BunServices } from "@effect/platform-bun"; +import { Layer } from "effect"; +import { Argument, Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { credentialsLayer } from "../../../auth/credentials.layer.ts"; +import { platformApiLayer } from "../../../auth/platform-api.layer.ts"; +import { projectLinkStateLayer } from "../../../config/project-link-state.layer.ts"; +import { makeGoProxyLayer } from "../../../../shared/legacy/go-proxy.layer.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; +import { functionsDownload } from "./download.handler.ts"; + +const config = { + functionName: Argument.string("Function name").pipe( + Argument.withDescription("Name of the Function to download. Downloads all if omitted."), + Argument.optional, + ), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), + useApi: Flag.boolean("use-api").pipe( + Flag.withDescription("Unbundle functions server-side without using Docker."), + ), + useDocker: Flag.boolean("use-docker").pipe( + Flag.withDescription("Use Docker to unbundle functions client-side."), + Flag.withHidden, + ), + legacyBundle: Flag.boolean("legacy-bundle").pipe( + Flag.withDescription("Use legacy bundling mechanism."), + Flag.withHidden, + ), +} as const; + +export type FunctionsDownloadFlags = CliCommand.Command.Config.Infer; + +const functionsDownloadRuntimeLayer = Layer.mergeAll( + BunServices.layer, + platformApiLayer.pipe(Layer.provide(credentialsLayer)), + projectLinkStateLayer, + commandRuntimeLayer(["functions", "download"]), + makeGoProxyLayer(), +); + +export const functionsDownloadCommand = Command.make("download", config).pipe( + Command.withDescription( + "Download the source code for a Function from the linked Supabase project. If no function name is provided, downloads all functions.", + ), + Command.withShortDescription("Download a Function from Supabase"), + Command.withExamples([ + { + command: "supabase functions download hello-world", + description: "Download a single function from the linked project", + }, + { + command: "supabase functions download --project-ref abcdefghijklmnopqrst", + description: "Download all functions from a specific project", + }, + ]), + Command.withHandler((flags) => + functionsDownload(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), + ), + Command.provide(functionsDownloadRuntimeLayer), +); diff --git a/apps/cli/src/next/commands/functions/download/download.handler.ts b/apps/cli/src/next/commands/functions/download/download.handler.ts new file mode 100644 index 0000000000..02b9aed1b2 --- /dev/null +++ b/apps/cli/src/next/commands/functions/download/download.handler.ts @@ -0,0 +1,24 @@ +import { Effect } from "effect"; +import { PlatformApi } from "../../../auth/platform-api.service.ts"; +import { ProjectHome } from "../../../config/project-home.service.ts"; +import { + downloadFunctions, + makeGoProxyDownloadArgs, +} from "../../../../shared/functions/download.ts"; +import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { resolveProjectRef } from "../functions.shared.ts"; +import type { FunctionsDownloadFlags } from "./download.command.ts"; + +export const functionsDownload = Effect.fnUntraced(function* (flags: FunctionsDownloadFlags) { + const api = yield* PlatformApi; + const projectHome = yield* ProjectHome; + const proxy = yield* LegacyGoProxy; + + yield* downloadFunctions(flags, { + api, + projectRoot: projectHome.projectRoot, + resolveProjectRef, + proxyDownload: (proxyFlags, projectRef) => + proxy.exec(makeGoProxyDownloadArgs(proxyFlags, projectRef), { cwd: projectHome.projectRoot }), + }); +}); diff --git a/apps/cli/src/next/commands/functions/download/download.integration.test.ts b/apps/cli/src/next/commands/functions/download/download.integration.test.ts new file mode 100644 index 0000000000..40b69d0e81 --- /dev/null +++ b/apps/cli/src/next/commands/functions/download/download.integration.test.ts @@ -0,0 +1,1424 @@ +import { describe, expect, it } from "@effect/vitest"; +import { FunctionResponse, makeApiClient } from "@supabase/api/effect"; +import { existsSync, mkdtempSync } from "node:fs"; +import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect, Layer, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { PlatformApi } from "../../../auth/platform-api.service.ts"; +import { + ProjectNotLinkedError, + type ProjectLinkStateValue, +} from "../../../config/project-link-state.service.ts"; +import { ProjectHome } from "../../../config/project-home.service.ts"; +import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { + emptyEnv, + mockOutput, + mockProjectLinkState, + mockRuntimeInfo, +} from "../../../../../tests/helpers/mocks.ts"; +import type { FunctionsDownloadFlags } from "./download.command.ts"; +import { + ConflictingFunctionDownloadFlagsError, + InvalidFunctionDownloadResponseError, + InvalidFunctionSlugError, + UnsafeFunctionDownloadPathError, +} from "../../../../shared/functions/download.errors.ts"; +import { functionsDownload } from "./download.handler.ts"; + +const PROJECT_REF = "abcdefghijklmnopqrst"; +const BRANCH_REF = "branchrefabcdefghij"; +type ResponseBody = string | Blob; + +const LINK_STATE: ProjectLinkStateValue = { + project: { + ref: PROJECT_REF, + name: "Linked Project", + organization_id: "org-id", + organization_slug: "org-slug", + }, + active_branch: { + ref: BRANCH_REF, + name: "main", + is_default: true, + }, + fetchedAt: "2026-01-01T00:00:00.000Z", + versions: {}, +}; + +const BASE_FLAGS: FunctionsDownloadFlags = { + functionName: Option.some("hello-world"), + projectRef: Option.none(), + useApi: false, + useDocker: false, + legacyBundle: false, +}; + +function makeTempDir(): string { + return mkdtempSync(join(tmpdir(), "supabase-functions-download-")); +} + +async function writeProjectConfig(cwd: string) { + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(join(cwd, "supabase", "config.toml"), ""); +} + +function textResponse( + request: HttpClientRequest.HttpClientRequest, + status: number, + body: ResponseBody = "", + contentType = "text/plain", +): HttpClientResponse.HttpClientResponse { + return HttpClientResponse.fromWeb( + request, + new Response(body, { + status, + headers: { + "content-type": contentType, + }, + }), + ); +} + +function jsonResponse( + request: HttpClientRequest.HttpClientRequest, + status: number, + body: unknown, +): HttpClientResponse.HttpClientResponse { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json", + }, + }), + ); +} + +function transportFailure( + request: HttpClientRequest.HttpClientRequest, + error: Error, +): HttpClientError.HttpClientError { + return new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause: error, + description: error.message, + }), + }); +} + +function multipartBody(parts: Array<{ headers: Record; body: string }>) { + const boundary = "supabase-test-boundary"; + const body = [ + ...parts.map((part) => { + const headers = Object.entries(part.headers) + .map(([key, value]) => `${key}: ${value}`) + .join("\r\n"); + return `--${boundary}\r\n${headers}\r\n\r\n${part.body}\r\n`; + }), + `--${boundary}--\r\n`, + ].join(""); + + return { + body, + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} + +function binaryMultipartBody(parts: Array<{ headers: Record; body: Uint8Array }>) { + const boundary = "supabase-binary-boundary"; + const encoder = new TextEncoder(); + const chunks = parts.flatMap((part) => { + const headers = Object.entries(part.headers) + .map(([key, value]) => `${key}: ${value}`) + .join("\r\n"); + return [ + encoder.encode(`--${boundary}\r\n${headers}\r\n\r\n`), + part.body, + encoder.encode("\r\n"), + ]; + }); + chunks.push(encoder.encode(`--${boundary}--\r\n`)); + + return { + body: new Blob(chunks), + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} + +function makeFunction( + overrides: Partial = {}, +): typeof FunctionResponse.Type { + return { + id: "function-id", + slug: "hello-world", + name: "Hello World", + status: "ACTIVE", + version: 2, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: true, + entrypoint_path: "functions/hello-world/index.ts", + import_map_path: "functions/hello-world/deno.json", + ...overrides, + }; +} + +function mockDownloadApi(opts: { + list?: ReadonlyArray; + listStatus?: number; + listBody?: unknown; + listError?: Error; + functionBySlug?: Readonly>; + functionStatusBySlug?: Readonly>; + functionBodyBySlug?: Readonly>; + bodyBySlug?: Readonly< + Record + >; + bodyErrorBySlug?: Readonly>; +}) { + const requests: string[] = []; + const acceptHeaders: Array = []; + + const layer = Layer.effect( + PlatformApi, + makeApiClient({ + baseUrl: "https://api.supabase.com", + accessToken: "test-token", + userAgent: "supabase", + headers: { + "X-Supabase-Command": "functions download", + "X-Supabase-Command-Run-ID": "run-123", + }, + }), + ).pipe( + Layer.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push(request.url); + acceptHeaders.push(request.headers.accept); + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}/functions`) { + if (opts.listError !== undefined) { + return Effect.fail(transportFailure(request, opts.listError)); + } + return Effect.succeed( + jsonResponse(request, opts.listStatus ?? 200, opts.listBody ?? opts.list ?? []), + ); + } + + const bodyMatch = url.pathname.match( + new RegExp(`^/v1/projects/${PROJECT_REF}/functions/([^/]+)/body$`), + ); + if (bodyMatch?.[1] !== undefined) { + const slug = decodeURIComponent(bodyMatch[1]); + const responseError = opts.bodyErrorBySlug?.[slug]; + if (responseError !== undefined) { + return Effect.fail(transportFailure(request, responseError)); + } + const response = opts.bodyBySlug?.[slug]; + return Effect.succeed( + textResponse( + request, + response?.status ?? 200, + response?.body ?? "", + response?.contentType ?? "multipart/form-data; boundary=missing", + ), + ); + } + + const functionMatch = url.pathname.match( + new RegExp(`^/v1/projects/${PROJECT_REF}/functions/([^/]+)$`), + ); + if (functionMatch?.[1] !== undefined) { + const slug = decodeURIComponent(functionMatch[1]); + return Effect.succeed( + jsonResponse( + request, + opts.functionStatusBySlug?.[slug] ?? 200, + opts.functionBodyBySlug?.[slug] ?? + opts.functionBySlug?.[slug] ?? + makeFunction({ slug }), + ), + ); + } + + return Effect.succeed(textResponse(request, 404, "not found")); + }), + ), + ), + ); + + return { + layer, + get requests() { + return requests; + }, + get acceptHeaders() { + return acceptHeaders; + }, + }; +} + +function setup( + cwd: string, + opts: Parameters[0] & { + format?: "text" | "json" | "stream-json"; + linked?: boolean; + projectRoot?: string; + } = {}, +) { + const out = mockOutput({ format: opts.format ?? "text", interactive: false }); + const api = mockDownloadApi(opts); + const proxy = mockLegacyGoProxy(); + const layer = Layer.mergeAll( + emptyEnv(), + out.layer, + api.layer, + proxy.layer, + mockRuntimeInfo({ cwd }), + mockProjectLinkState(opts.linked === false ? undefined : LINK_STATE), + mockProjectHome(opts.projectRoot ?? cwd), + ); + + return { out, api, layer, proxy }; +} + +function mockLegacyGoProxy() { + const calls: string[][] = []; + return { + layer: Layer.succeed(LegacyGoProxy, { + exec: (args: ReadonlyArray) => + Effect.sync(() => { + calls.push([...args]); + }), + }), + get calls() { + return calls; + }, + }; +} + +function mockProjectHome(projectRoot: string) { + const projectHomeDir = join(projectRoot, ".supabase"); + return Layer.succeed( + ProjectHome, + ProjectHome.of({ + projectRoot, + supabaseDir: join(projectRoot, "supabase"), + projectHomeDir, + projectLinkPath: join(projectHomeDir, "project.json"), + projectLocalVersionsPath: join(projectHomeDir, "local-versions.json"), + ensureProjectHomeDir: Effect.void, + stackDir: (name) => join(projectHomeDir, "stacks", name), + stackStatePath: (name) => join(projectHomeDir, "stacks", name, "state.json"), + stackMetadataPath: (name) => join(projectHomeDir, "stacks", name, "stack.json"), + stackDataDir: (name) => join(projectHomeDir, "stacks", name, "data"), + stackLogsDir: (name) => join(projectHomeDir, "stacks", name, "logs"), + }), + ); +} + +describe("functions download", () => { + it.live("downloads a function from the linked project using multipart metadata", () => { + const tempDir = makeTempDir(); + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/index.ts"', + }, + body: "console.log('hello')", + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/utils.ts"', + }, + body: "export const value = 1;", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { out, api, layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": multipart, + }, + }); + + yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer)); + + expect(api.requests).toContain( + "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst/functions/hello-world/body", + ); + expect(api.acceptHeaders).toContain("multipart/form-data"); + expect( + yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), + ), + ).toBe("console.log('hello')"); + expect( + yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "functions", "hello-world", "utils.ts"), "utf8"), + ), + ).toBe("export const value = 1;"); + expect(out.stderrText).toContain("Downloading Function: hello-world\n"); + expect(out.stderrText).toContain( + `Extracting file: ${join(tempDir, "supabase", "functions", "hello-world", "index.ts")}\n`, + ); + expect(out.stderrText).toContain( + `Downloaded Function hello-world from project abcdefghijklmnopqrst.\n`, + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("downloads multipart file parts under any field name", () => { + const tempDir = makeTempDir(); + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + }, + { + headers: { + "Content-Disposition": 'form-data; name="source"; filename="source/index.ts"', + }, + body: "console.log('source')", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": multipart, + }, + }); + + yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer)); + + expect( + yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), + ), + ).toBe("console.log('source')"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live( + "falls back to function metadata when multipart metadata has an empty entrypoint path", + () => { + const tempDir = makeTempDir(); + const absoluteEntrypoint = "/tmp/functions-download-empty/source/index.ts"; + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "" }), + }, + { + headers: { + "Content-Disposition": `form-data; name="file"; filename="${absoluteEntrypoint}"`, + }, + body: "console.log('empty metadata')", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + functionBySlug: { + "hello-world": makeFunction({ + slug: "hello-world", + entrypoint_path: `file://${absoluteEntrypoint}`, + }), + }, + bodyBySlug: { + "hello-world": multipart, + }, + }); + + yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer)); + + expect( + yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), + ), + ).toBe("console.log('empty metadata')"); + expect( + existsSync(join(tempDir, "supabase", "functions", "hello-world", "source", "index.ts")), + ).toBe(false); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + + it.live("downloads into the linked project root when run from a subdirectory", () => { + const tempDir = makeTempDir(); + const subdirectory = join(tempDir, "nested", "directory"); + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/index.ts"', + }, + body: "console.log('hello')", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(subdirectory, { recursive: true })); + const { layer } = setup(subdirectory, { + projectRoot: tempDir, + bodyBySlug: { + "hello-world": multipart, + }, + }); + + yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer)); + + expect( + yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), + ), + ).toBe("console.log('hello')"); + expect(existsSync(join(subdirectory, "supabase", "functions"))).toBe(false); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("preserves binary file bytes from multipart responses", () => { + const tempDir = makeTempDir(); + const binary = new Uint8Array([0, 255, 128, 13, 10, 45, 45, 1]); + const multipart = binaryMultipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: new TextEncoder().encode( + JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + ), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/asset.bin"', + }, + body: binary, + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": multipart, + }, + }); + + yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer)); + + expect( + new Uint8Array( + yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "functions", "hello-world", "asset.bin")), + ), + ), + ).toEqual(binary); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live( + "falls back to function metadata when multipart metadata omits the entrypoint path", + () => { + const tempDir = makeTempDir(); + const absoluteEntrypoint = "/tmp/functions-download-abs/My Project/source/index.ts"; + const absoluteUtil = "/tmp/functions-download-abs/My Project/source/lib/utils.ts"; + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, + { + headers: { + "Content-Disposition": `form-data; name="file"; filename="${absoluteEntrypoint}"`, + }, + body: "console.log('abs')", + }, + { + headers: { + "Content-Disposition": `form-data; name="file"; filename="${absoluteUtil}"`, + }, + body: "export const util = 2;", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + functionBySlug: { + "hello-world": makeFunction({ + slug: "hello-world", + entrypoint_path: `file://${absoluteEntrypoint.replaceAll(" ", "%20")}`, + }), + }, + bodyBySlug: { + "hello-world": multipart, + }, + }); + + yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer)); + + expect( + yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), + ), + ).toBe("console.log('abs')"); + expect( + yield* Effect.tryPromise(() => + readFile( + join(tempDir, "supabase", "functions", "hello-world", "lib", "utils.ts"), + "utf8", + ), + ), + ).toBe("export const util = 2;"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }, + ); + + it.live("downloads every function when no name is provided", () => { + const tempDir = makeTempDir(); + const helloBody = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/index.ts"', + }, + body: "console.log('hello')", + }, + ]); + const byeBody = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/index.ts"', + }, + body: "console.log('bye')", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { out, layer } = setup(tempDir, { + list: [ + makeFunction({ slug: "hello-world", name: "Hello World" }), + makeFunction({ slug: "goodbye-world", name: "Goodbye World" }), + ], + bodyBySlug: { + "hello-world": helloBody, + "goodbye-world": byeBody, + }, + }); + + yield* functionsDownload({ + ...BASE_FLAGS, + functionName: Option.none(), + }).pipe(Effect.provide(layer)); + + expect( + yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), + ), + ).toBe("console.log('hello')"); + expect( + yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "functions", "goodbye-world", "index.ts"), "utf8"), + ), + ).toBe("console.log('bye')"); + expect(out.stderrText).toContain("Found 2 function(s) to download\n"); + expect(out.stderrText).toContain( + "Successfully downloaded all functions from project abcdefghijklmnopqrst\n", + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("downloads remote slugs from download-all without local slug validation", () => { + const tempDir = makeTempDir(); + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/index.ts"', + }, + body: "console.log('remote')", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + list: [makeFunction({ slug: "1remote" })], + bodyBySlug: { + "1remote": multipart, + }, + }); + + yield* functionsDownload({ + ...BASE_FLAGS, + functionName: Option.none(), + }).pipe(Effect.provide(layer)); + + expect( + yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "functions", "1remote", "index.ts"), "utf8"), + ), + ).toBe("console.log('remote')"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("prints the download-all success line when the project has one function", () => { + const tempDir = makeTempDir(); + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/index.ts"', + }, + body: "console.log('hello')", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { out, layer } = setup(tempDir, { + list: [makeFunction({ slug: "hello-world" })], + bodyBySlug: { + "hello-world": multipart, + }, + }); + + yield* functionsDownload({ + ...BASE_FLAGS, + functionName: Option.none(), + }).pipe(Effect.provide(layer)); + + expect(out.stderrText).toContain( + "Successfully downloaded all functions from project abcdefghijklmnopqrst\n", + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("uses --use-api without delegating to the Go proxy", () => { + const tempDir = makeTempDir(); + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/index.ts"', + }, + body: "console.log('hello')", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer, proxy } = setup(tempDir, { + bodyBySlug: { + "hello-world": multipart, + }, + }); + + yield* functionsDownload({ + ...BASE_FLAGS, + useApi: true, + }).pipe(Effect.provide(layer)); + + expect(proxy.calls).toEqual([]); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("delegates --legacy-bundle with the linked project ref to the Go proxy", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer, proxy } = setup(tempDir); + + yield* functionsDownload({ + ...BASE_FLAGS, + legacyBundle: true, + }).pipe(Effect.provide(layer)); + + expect(proxy.calls).toEqual([ + ["functions", "download", "hello-world", "--project-ref", PROJECT_REF, "--legacy-bundle"], + ]); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("delegates --use-docker with the linked project ref to the Go proxy", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer, proxy } = setup(tempDir); + + yield* functionsDownload({ + ...BASE_FLAGS, + useDocker: true, + }).pipe(Effect.provide(layer)); + + expect(proxy.calls).toEqual([ + ["functions", "download", "hello-world", "--project-ref", PROJECT_REF, "--use-docker"], + ]); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("rejects mutually exclusive compatibility flags", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + const { api, layer, proxy } = setup(tempDir); + + const error = yield* functionsDownload({ + ...BASE_FLAGS, + useApi: true, + legacyBundle: true, + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(ConflictingFunctionDownloadFlagsError); + expect(api.requests).toHaveLength(0); + expect(proxy.calls).toHaveLength(0); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("rejects invalid slugs before calling the API", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { api, layer } = setup(tempDir); + + const error = yield* functionsDownload({ + ...BASE_FLAGS, + functionName: Option.some("hello.world"), + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(InvalidFunctionSlugError); + expect(api.requests).toHaveLength(0); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("fails when neither a linked project nor --project-ref is available", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { linked: false }); + + const error = yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(ProjectNotLinkedError); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("prints the Go-style empty-state line when no functions exist", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { out, layer } = setup(tempDir, { + list: [], + }); + + yield* functionsDownload({ + ...BASE_FLAGS, + functionName: Option.none(), + }).pipe(Effect.provide(layer)); + + expect(out.stderrText).toBe("No functions found in project abcdefghijklmnopqrst\n"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("fails when the response is not multipart", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": { + body: `{"error":"no multipart"}`, + contentType: "application/json", + }, + }, + }); + + const error = yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(InvalidFunctionDownloadResponseError); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("fails when the multipart boundary is absent from the response body", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": { + body: "not a multipart body", + contentType: "multipart/form-data; boundary=missing", + }, + }, + }); + + const error = yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(InvalidFunctionDownloadResponseError); + expect(error.message).toBe( + "failed to read form: multipart response is missing its opening boundary", + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("fails when a multipart file has malformed content disposition", () => { + const tempDir = makeTempDir(); + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="', + }, + body: "console.log('hello')", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": multipart, + }, + }); + + const error = yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(InvalidFunctionDownloadResponseError); + expect(error.message).toBe("failed to parse content disposition: malformed filename"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("writes structured success data in JSON mode for native downloads", () => { + const tempDir = makeTempDir(); + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/index.ts"', + }, + body: "console.log('hello')", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { out, layer } = setup(tempDir, { + format: "json", + bodyBySlug: { + "hello-world": multipart, + }, + }); + + yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer)); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Downloaded Edge Function source.", + data: { + function_slugs: ["hello-world"], + project_ref: PROJECT_REF, + }, + }), + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("maps list transport errors with Go-style wording", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + listError: new Error("network error"), + }); + + const error = yield* functionsDownload({ + ...BASE_FLAGS, + functionName: Option.none(), + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("failed to list functions: network error"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("maps unexpected list statuses with Go-style wording", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + listStatus: 503, + listBody: { message: "unavailable" }, + }); + + const error = yield* functionsDownload({ + ...BASE_FLAGS, + functionName: Option.none(), + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('unexpected list functions status 503: {"message":"unavailable"}'); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("maps body transport errors with Go-style wording", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + bodyErrorBySlug: { + "hello-world": new Error("network error"), + }, + }); + + const error = yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("failed to download function: network error"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("maps unexpected body statuses with Go-style wording", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": { + status: 503, + body: "unavailable", + contentType: "text/plain", + }, + }, + }); + + const error = yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("Error status 503: unavailable"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("maps metadata fallback transport errors with Go-style wording", () => { + const tempDir = makeTempDir(); + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="/tmp/source/index.ts"', + }, + body: "console.log('hello')", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": multipart, + }, + functionStatusBySlug: { + "hello-world": 503, + }, + functionBodyBySlug: { + "hello-world": { message: "downstream unavailable" }, + }, + }); + + const error = yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe( + 'Failed to download Function hello-world on the Supabase project: {"message":"downstream unavailable"}', + ); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("honors Supabase-Path headers for files shared across functions", () => { + const tempDir = makeTempDir(); + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/index.ts"', + }, + body: "console.log('hello')", + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/secret.env"', + "Supabase-Path": "../secret.env", + }, + body: "SECRET=1", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": multipart, + }, + functionBySlug: { + "hello-world": makeFunction({ + slug: "hello-world", + entrypoint_path: "file:///source/index.ts", + }), + }, + }); + + yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer)); + + expect( + yield* Effect.tryPromise(() => + readFile(join(tempDir, "supabase", "functions", "secret.env"), "utf8"), + ), + ).toBe("SECRET=1"); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("rejects Supabase-Path headers that escape the functions directory", () => { + const tempDir = makeTempDir(); + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/secret.env"', + "Supabase-Path": "../../../../../../outside.env", + }, + body: "SECRET=1", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": multipart, + }, + }); + + const error = yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(UnsafeFunctionDownloadPathError); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); + + it.live("rejects a functions directory symlinked outside the project", () => { + const tempDir = makeTempDir(); + const outsideDir = makeTempDir(); + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/index.ts"', + }, + body: "console.log('hello')", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* Effect.tryPromise(() => + symlink(outsideDir, join(tempDir, "supabase", "functions"), "junction"), + ); + const { layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": multipart, + }, + }); + + const error = yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(UnsafeFunctionDownloadPathError); + }).pipe( + Effect.ensuring( + Effect.all([ + Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true })), + Effect.tryPromise(() => rm(outsideDir, { recursive: true, force: true })), + ]).pipe(Effect.orDie), + ), + ); + }); + + it.live("rejects a symlinked supabase directory before creating the functions directory", () => { + const tempDir = makeTempDir(); + const outsideDir = makeTempDir(); + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + }, + { + headers: { + "Content-Disposition": 'form-data; name="file"; filename="source/index.ts"', + }, + body: "console.log('hello')", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => symlink(outsideDir, join(tempDir, "supabase"), "junction")); + const { layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": multipart, + }, + }); + + const error = yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(UnsafeFunctionDownloadPathError); + expect(existsSync(join(outsideDir, "functions"))).toBe(false); + }).pipe( + Effect.ensuring( + Effect.all([ + Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true })), + Effect.tryPromise(() => rm(outsideDir, { recursive: true, force: true })), + ]).pipe(Effect.orDie), + ), + ); + }); + + it.live("rejects symlinked parent directories before creating descendants", () => { + const tempDir = makeTempDir(); + const outsideDir = makeTempDir(); + const functionDir = join(tempDir, "supabase", "functions", "hello-world"); + const multipart = multipartBody([ + { + headers: { + "Content-Disposition": 'form-data; name="metadata"', + "Content-Type": "application/json", + }, + body: JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + }, + { + headers: { + "Content-Disposition": + 'form-data; name="file"; filename="source/lib/new-directory/file.ts"', + }, + body: "console.log('outside')", + }, + ]); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(functionDir, { recursive: true })); + yield* Effect.tryPromise(() => symlink(outsideDir, join(functionDir, "lib"), "junction")); + const { layer } = setup(tempDir, { + bodyBySlug: { + "hello-world": multipart, + }, + }); + + const error = yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(UnsafeFunctionDownloadPathError); + expect(existsSync(join(outsideDir, "new-directory"))).toBe(false); + }).pipe( + Effect.ensuring( + Effect.all([ + Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true })), + Effect.tryPromise(() => rm(outsideDir, { recursive: true, force: true })), + ]).pipe(Effect.orDie), + ), + ); + }); + + it.live("emits a JSON failure payload instead of throwing in JSON mode", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + const { out, layer } = setup(tempDir, { + format: "json", + bodyBySlug: { + "hello-world": { + body: `{"error":"no multipart"}`, + contentType: "application/json", + }, + }, + }); + + yield* functionsDownload(BASE_FLAGS).pipe(withJsonErrorHandling, Effect.provide(layer)); + + expect(out.messages).toContainEqual(expect.objectContaining({ type: "fail" })); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + ); + }); +}); diff --git a/apps/cli/src/next/commands/functions/functions.command.ts b/apps/cli/src/next/commands/functions/functions.command.ts index 0b693b3a11..d7e01d383f 100644 --- a/apps/cli/src/next/commands/functions/functions.command.ts +++ b/apps/cli/src/next/commands/functions/functions.command.ts @@ -1,10 +1,18 @@ import { Command } from "effect/unstable/cli"; import { functionsDevCommand } from "./dev/dev.command.ts"; +import { functionsDeleteCommand } from "./delete/delete.command.ts"; +import { functionsDownloadCommand } from "./download/download.command.ts"; import { functionsListCommand } from "./list/list.command.ts"; import { functionsNewCommand } from "./new/new.command.ts"; export const functionsCommand = Command.make("functions").pipe( Command.withDescription("Manage Supabase Edge Functions."), Command.withShortDescription("Manage Supabase Edge Functions"), - Command.withSubcommands([functionsNewCommand, functionsListCommand, functionsDevCommand]), + Command.withSubcommands([ + functionsListCommand, + functionsDeleteCommand, + functionsDownloadCommand, + functionsNewCommand, + functionsDevCommand, + ]), ); diff --git a/apps/cli/src/next/commands/functions/functions.shared.ts b/apps/cli/src/next/commands/functions/functions.shared.ts new file mode 100644 index 0000000000..3407b7f136 --- /dev/null +++ b/apps/cli/src/next/commands/functions/functions.shared.ts @@ -0,0 +1,24 @@ +import { Effect, Option } from "effect"; +import { + ProjectLinkState, + ProjectNotLinkedError, +} from "../../config/project-link-state.service.ts"; + +export const resolveProjectRef = Effect.fnUntraced(function* (projectRef: Option.Option) { + if (Option.isSome(projectRef)) { + return projectRef.value; + } + + const projectLinkState = yield* ProjectLinkState; + const maybeLinkState = yield* projectLinkState.load; + if (Option.isNone(maybeLinkState)) { + return yield* Effect.fail( + new ProjectNotLinkedError({ + detail: "No project is linked in this directory.", + suggestion: "Run `supabase link` first or pass `--project-ref`.", + }), + ); + } + + return maybeLinkState.value.project.ref; +}); diff --git a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts index 9df5738d74..666a139383 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -12,6 +12,9 @@ import { legacyProjectsCommand } from "../../legacy/commands/projects/projects.c import { legacyProjectsCreateCommand } from "../../legacy/commands/projects/create/create.command.ts"; import { legacyStartCommand } from "../../legacy/commands/start/start.command.ts"; import { legacyStopCommand } from "../../legacy/commands/stop/stop.command.ts"; +import { LEGACY_VALID_TOKEN } from "../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput, withEnv } from "../../../tests/helpers/mocks.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../legacy/global-flags.ts"; import { LegacyGoProxy } from "../legacy/go-proxy.service.ts"; import { textCliOutputFormatter } from "../output/text-formatter.ts"; @@ -36,6 +39,7 @@ function mockLegacyGoProxy() { } const legacyTestRoot = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), Command.withSubcommands([ legacyStartCommand, legacyStopCommand, @@ -55,6 +59,11 @@ const silentCliOutputFormatter: CliOutput.Formatter = { formatVersion: () => "", }; +const authenticatedEnv = { + SUPABASE_ACCESS_TOKEN: LEGACY_VALID_TOKEN, + ...(process.env["SystemRoot"] === undefined ? {} : { SystemRoot: process.env["SystemRoot"] }), +}; + describe("native hidden flags", () => { it("omits hidden flags from help docs for every legacy command that still carries one", () => { expect(buildHelpDoc(legacyStartCommand).flags.map((flag) => flag.name)).toEqual([ @@ -119,8 +128,9 @@ describe("native hidden flags", () => { "functions", "download", "hello", + "--project-ref", + "abcdefghijklmnopqrst", "--use-docker", - "--legacy-bundle", ]); yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ "functions", @@ -135,14 +145,21 @@ describe("native hidden flags", () => { "--all=false", ]); }).pipe( - Effect.provide(Layer.mergeAll(proxy.layer, CliOutput.layer(textCliOutputFormatter()))), + Effect.provide( + Layer.mergeAll( + withEnv(authenticatedEnv), + proxy.layer, + mockOutput({ format: "text" }).layer, + CliOutput.layer(textCliOutputFormatter()), + ), + ), ) as Effect.Effect, ); expect(proxy.calls).toEqual([ ["start", "--preview"], ["stop", "--backup=false"], - ["functions", "download", "hello", "--use-docker", "--legacy-bundle"], + ["functions", "download", "hello", "--project-ref", "abcdefghijklmnopqrst", "--use-docker"], ["functions", "deploy", "hello", "--use-docker", "--legacy-bundle"], ["functions", "serve", "--all=false"], ]); diff --git a/apps/cli/src/shared/functions/delete.errors.ts b/apps/cli/src/shared/functions/delete.errors.ts new file mode 100644 index 0000000000..ba0ee0a761 --- /dev/null +++ b/apps/cli/src/shared/functions/delete.errors.ts @@ -0,0 +1,19 @@ +import { Data } from "effect"; + +export class InvalidFunctionSlugError extends Data.TaggedError("InvalidFunctionSlugError")<{ + readonly message: string; +}> {} + +export class FunctionNotFoundError extends Data.TaggedError("FunctionNotFoundError")<{ + readonly message: string; +}> {} + +export class DeleteFunctionNetworkError extends Data.TaggedError("DeleteFunctionNetworkError")<{ + readonly message: string; +}> {} + +export class DeleteFunctionUnexpectedStatusError extends Data.TaggedError( + "DeleteFunctionUnexpectedStatusError", +)<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/shared/functions/delete.ts b/apps/cli/src/shared/functions/delete.ts new file mode 100644 index 0000000000..4e766c2952 --- /dev/null +++ b/apps/cli/src/shared/functions/delete.ts @@ -0,0 +1,91 @@ +import { operationDefinitions, type ApiClient } from "@supabase/api/effect"; +import { Effect, type Option } from "effect"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import { Output } from "../output/output.service.ts"; +import { + DeleteFunctionNetworkError, + DeleteFunctionUnexpectedStatusError, + FunctionNotFoundError, + InvalidFunctionSlugError, +} from "./delete.errors.ts"; +import { invalidFunctionSlugDetail, validateFunctionSlugMessage } from "./functions.shared.ts"; + +export interface DeleteFunctionOptions { + readonly slug: string; + readonly projectRef: Option.Option; +} + +export interface DeleteFunctionDependencies { + readonly api: ApiClient; + readonly resolveProjectRef: ( + projectRef: Option.Option, + ) => Effect.Effect; +} + +function validateSlug(slug: string): Effect.Effect { + if (validateFunctionSlugMessage(slug) === undefined) { + return Effect.void; + } + + return Effect.fail(new InvalidFunctionSlugError({ message: invalidFunctionSlugDetail })); +} + +export function deleteFunction( + flags: DeleteFunctionOptions, + dependencies: DeleteFunctionDependencies, +) { + return Effect.gen(function* () { + const output = yield* Output; + + yield* validateSlug(flags.slug); + const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); + + const response = yield* dependencies.api + .executeRaw(operationDefinitions.v1DeleteAFunction, { + ref: projectRef, + function_slug: flags.slug, + }) + .pipe( + Effect.mapError((error) => { + if (HttpClientError.isHttpClientError(error)) { + const description = error.reason.description ?? error.reason._tag; + return new DeleteFunctionNetworkError({ + message: `failed to delete function: ${description}`, + }); + } + return new DeleteFunctionNetworkError({ + message: `failed to delete function: ${String(error)}`, + }); + }), + ); + + switch (response.status) { + case 200: + break; + case 404: + return yield* Effect.fail( + new FunctionNotFoundError({ + message: `Function ${flags.slug} does not exist on the Supabase project: nothing to delete`, + }), + ); + default: { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* Effect.fail( + new DeleteFunctionUnexpectedStatusError({ + message: `unexpected delete function status ${response.status}: ${body}`, + }), + ); + } + } + + if (output.format !== "text") { + yield* output.success("Deleted Edge Function.", { + function_slug: flags.slug, + project_ref: projectRef, + }); + return; + } + + yield* output.raw(`Deleted Function ${flags.slug} from project ${projectRef}.\n`); + }).pipe(Effect.withSpan("functions.delete")); +} diff --git a/apps/cli/src/shared/functions/download.errors.ts b/apps/cli/src/shared/functions/download.errors.ts new file mode 100644 index 0000000000..b12aeca7af --- /dev/null +++ b/apps/cli/src/shared/functions/download.errors.ts @@ -0,0 +1,29 @@ +import { Data } from "effect"; + +export class InvalidFunctionSlugError extends Data.TaggedError("InvalidFunctionSlugError")<{ + readonly message: string; +}> {} + +export class ConflictingFunctionDownloadFlagsError extends Data.TaggedError( + "ConflictingFunctionDownloadFlagsError", +)<{ + readonly message: string; +}> {} + +export class FunctionDownloadNotFoundError extends Data.TaggedError( + "FunctionDownloadNotFoundError", +)<{ + readonly message: string; +}> {} + +export class InvalidFunctionDownloadResponseError extends Data.TaggedError( + "InvalidFunctionDownloadResponseError", +)<{ + readonly message: string; +}> {} + +export class UnsafeFunctionDownloadPathError extends Data.TaggedError( + "UnsafeFunctionDownloadPathError", +)<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts new file mode 100644 index 0000000000..562066ee4b --- /dev/null +++ b/apps/cli/src/shared/functions/download.ts @@ -0,0 +1,786 @@ +import { operationDefinitions, type ApiClient } from "@supabase/api/effect"; +import { randomUUID } from "node:crypto"; +import { open, rename, rm } from "node:fs/promises"; +import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Effect, FileSystem, Option } from "effect"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { Output } from "../output/output.service.ts"; +import { invalidFunctionSlugDetail, validateFunctionSlugMessage } from "./functions.shared.ts"; +import { + ConflictingFunctionDownloadFlagsError, + FunctionDownloadNotFoundError, + InvalidFunctionDownloadResponseError, + InvalidFunctionSlugError, + UnsafeFunctionDownloadPathError, +} from "./download.errors.ts"; + +const legacyEntrypointPath = "file:///src/index.ts"; + +export interface DownloadFunctionsOptions { + readonly functionName: Option.Option; + readonly projectRef: Option.Option; + readonly useApi: boolean; + readonly useDocker: boolean; + readonly legacyBundle: boolean; +} + +export interface DownloadFunctionsDependencies< + ResolveError, + ResolveRequirements, + ProxyError, + ProxyRequirements, +> { + readonly api: ApiClient; + readonly projectRoot: string; + readonly resolveProjectRef: ( + projectRef: Option.Option, + ) => Effect.Effect; + readonly proxyDownload: ( + flags: DownloadFunctionsOptions, + projectRef: string, + ) => Effect.Effect; +} + +interface DownloadRuntimeDependencies { + readonly api: ApiClient; + readonly projectRoot: string; +} + +export function makeGoProxyDownloadArgs( + flags: DownloadFunctionsOptions, + projectRef: string, +): ReadonlyArray { + const args: string[] = ["functions", "download"]; + if (Option.isSome(flags.functionName)) { + args.push(flags.functionName.value); + } + args.push("--project-ref", projectRef); + if (flags.useDocker) { + args.push("--use-docker"); + } + if (flags.legacyBundle) { + args.push("--legacy-bundle"); + } + return args; +} + +interface DownloadMetadata { + readonly entrypoint_path?: string; +} + +interface DownloadFilePart { + readonly path: string; + readonly body: Uint8Array; +} + +interface DecodedMultipartForm { + readonly metadata: DownloadMetadata | undefined; + readonly files: ReadonlyArray; +} + +interface MultipartPart { + readonly headers: Readonly>; + readonly body: Uint8Array; +} + +function getObjectProperty(value: unknown, key: string): unknown { + return typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined; +} + +function isContainedPath(root: string, candidate: string): boolean { + const relativeCandidate = relative(root, candidate); + return ( + relativeCandidate === "" || + (!isAbsolute(relativeCandidate) && + relativeCandidate !== ".." && + !relativeCandidate.startsWith(`..${sep}`)) + ); +} + +function validateSlug(slug: string): Effect.Effect { + if (validateFunctionSlugMessage(slug) === undefined) { + return Effect.void; + } + + return Effect.fail(new InvalidFunctionSlugError({ message: invalidFunctionSlugDetail })); +} + +function validateDownloadFlags( + flags: DownloadFunctionsOptions, +): Effect.Effect { + const selected = [ + flags.useApi ? "--use-api" : undefined, + flags.useDocker ? "--use-docker" : undefined, + flags.legacyBundle ? "--legacy-bundle" : undefined, + ].filter((flag) => flag !== undefined); + + return selected.length <= 1 + ? Effect.void + : Effect.fail( + new ConflictingFunctionDownloadFlagsError({ + message: `flags ${selected.join(", ")} are mutually exclusive`, + }), + ); +} + +function mapTransportError(prefix: string, error: unknown): Error { + if (HttpClientError.isHttpClientError(error)) { + const description = error.reason.description ?? error.reason._tag; + return new Error(`${prefix}: ${description}`); + } + + if (error instanceof Error) { + return new Error(`${prefix}: ${error.message}`); + } + + return new Error(`${prefix}: ${String(error)}`); +} + +function hasEntrypointPath(metadata: DownloadMetadata | undefined): metadata is { + readonly entrypoint_path: string; +} { + return metadata?.entrypoint_path !== undefined && metadata.entrypoint_path.length > 0; +} + +function fileUrlToEntrypointPath(rawEntrypoint: string): string { + const fileUrl = new URL(rawEntrypoint); + try { + return fileURLToPath(fileUrl); + } catch { + return decodeURIComponent(fileUrl.pathname); + } +} + +function parseDownloadMetadata(raw: string): DownloadMetadata { + const text = raw.trim(); + if (text.length === 0) { + return {}; + } + + const parsed = JSON.parse(text); + const deno2EntrypointPath = getObjectProperty(parsed, "deno2_entrypoint_path"); + if (typeof deno2EntrypointPath === "string" && deno2EntrypointPath.length > 0) { + return { entrypoint_path: deno2EntrypointPath }; + } + + const entrypointPath = getObjectProperty(parsed, "entrypoint_path"); + return typeof entrypointPath === "string" && entrypointPath.length > 0 + ? { entrypoint_path: entrypointPath } + : {}; +} + +function readMultipartBoundary( + contentType: string, +): Effect.Effect { + if (contentType.length === 0) { + return Effect.fail( + new InvalidFunctionDownloadResponseError({ + message: "failed to parse content type: missing content type", + }), + ); + } + + const mediaTypeMatch = contentType.match(/^\s*([^;]+)/); + const mediaType = mediaTypeMatch?.[1]?.trim() ?? contentType.trim(); + if (!mediaType.toLowerCase().startsWith("multipart/")) { + return Effect.fail( + new InvalidFunctionDownloadResponseError({ + message: `expected multipart response, got ${mediaType}`, + }), + ); + } + + const boundaryMatch = contentType.match(/boundary="?([^";]+)"?/i); + if (boundaryMatch?.[1] === undefined) { + return Effect.fail( + new InvalidFunctionDownloadResponseError({ + message: "failed to parse content type: missing multipart boundary", + }), + ); + } + + return Effect.succeed(boundaryMatch[1]); +} + +function findBytes(payload: Uint8Array, needle: Uint8Array, fromIndex = 0): number { + outer: for (let index = fromIndex; index <= payload.length - needle.length; index += 1) { + for (let offset = 0; offset < needle.length; offset += 1) { + if (payload[index + offset] !== needle[offset]) { + continue outer; + } + } + return index; + } + return -1; +} + +function parseMultipartHeaders(rawHeaders: string): Readonly> { + const headers: Record = {}; + for (const line of rawHeaders.split("\r\n")) { + const separatorIndex = line.indexOf(":"); + if (separatorIndex < 0) { + continue; + } + const name = line.slice(0, separatorIndex).trim().toLowerCase(); + const value = line.slice(separatorIndex + 1).trim(); + headers[name] = value; + } + return headers; +} + +function findNextMultipartBoundary( + payload: Uint8Array, + boundaryPrefix: Uint8Array, + from = 0, +): number { + let offset = from; + while (offset < payload.length) { + const index = findBytes(payload, boundaryPrefix, offset); + if (index < 0) { + return -1; + } + + const suffixIndex = index + boundaryPrefix.length; + const isClosingBoundary = payload[suffixIndex] === 45 && payload[suffixIndex + 1] === 45; + const isPartBoundary = payload[suffixIndex] === 13 && payload[suffixIndex + 1] === 10; + if (isClosingBoundary || isPartBoundary) { + return index; + } + + offset = index + 1; + } + + return -1; +} + +function decodeMultipartParts( + payload: Uint8Array, + boundary: string, +): Effect.Effect, InvalidFunctionDownloadResponseError> { + return Effect.try({ + try: () => { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const delimiter = encoder.encode(`--${boundary}`); + const headerSeparator = encoder.encode("\r\n\r\n"); + const nextPartPrefix = encoder.encode(`\r\n--${boundary}`); + const parts: MultipartPart[] = []; + let delimiterIndex = findBytes(payload, delimiter); + if (delimiterIndex < 0) { + throw new Error("multipart response is missing its opening boundary"); + } + + while (delimiterIndex >= 0) { + let partStart = delimiterIndex + delimiter.length; + if (payload[partStart] === 45 && payload[partStart + 1] === 45) { + break; + } + if (payload[partStart] === 13 && payload[partStart + 1] === 10) { + partStart += 2; + } + + const separatorIndex = findBytes(payload, headerSeparator, partStart); + if (separatorIndex < 0) { + throw new Error("multipart part is missing its header separator"); + } + const bodyStart = separatorIndex + headerSeparator.length; + const nextPartIndex = findNextMultipartBoundary(payload, nextPartPrefix, bodyStart); + if (nextPartIndex < 0) { + throw new Error("multipart response is missing its closing boundary"); + } + + parts.push({ + headers: parseMultipartHeaders(decoder.decode(payload.slice(partStart, separatorIndex))), + body: payload.slice(bodyStart, nextPartIndex), + }); + delimiterIndex = nextPartIndex + 2; + } + + return parts; + }, + catch: (cause) => + new InvalidFunctionDownloadResponseError({ + message: `failed to read form: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }); +} + +function readContentDispositionParam( + contentDisposition: string, + param: "name" | "filename" | "filename*", +): Effect.Effect { + const paramPattern = param.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const quotedMatch = contentDisposition.match( + new RegExp(`(?:^|;)\\s*${paramPattern}="((?:[^"\\\\]|\\\\.)*)"`, "i"), + ); + if (quotedMatch !== null) { + return Effect.succeed(quotedMatch[1]?.replaceAll('\\"', '"')); + } + + const assignmentMatch = contentDisposition.match( + new RegExp(`(?:^|;)\\s*${paramPattern}=([^;]*)`, "i"), + ); + if (assignmentMatch === null) { + return Effect.succeed(undefined); + } + const token = assignmentMatch[1]?.trim() ?? ""; + if (token.length > 0 && !token.startsWith('"') && !/\s/.test(token)) { + return Effect.succeed(token); + } + + return Effect.fail( + new InvalidFunctionDownloadResponseError({ + message: `failed to parse content disposition: malformed ${param}`, + }), + ); +} + +function decodeRfc5987Param( + value: string, +): Effect.Effect { + const firstQuote = value.indexOf("'"); + const secondQuote = firstQuote < 0 ? -1 : value.indexOf("'", firstQuote + 1); + if (firstQuote < 0 || secondQuote < 0) { + return Effect.fail( + new InvalidFunctionDownloadResponseError({ + message: "failed to parse content disposition: malformed filename*", + }), + ); + } + + const charset = value.slice(0, firstQuote).toLowerCase(); + if (charset !== "utf-8" && charset !== "us-ascii") { + return Effect.fail( + new InvalidFunctionDownloadResponseError({ + message: `failed to parse content disposition: unsupported filename* charset ${charset}`, + }), + ); + } + + return Effect.try({ + try: () => decodeURIComponent(value.slice(secondQuote + 1)), + catch: (cause) => + new InvalidFunctionDownloadResponseError({ + message: `failed to parse content disposition: malformed filename*: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }); +} + +function readFormFieldName( + headers: Readonly>, +): Effect.Effect { + const contentDisposition = headers["content-disposition"]; + if (contentDisposition === undefined) { + return Effect.succeed(undefined); + } + return readContentDispositionParam(contentDisposition, "name"); +} + +function readContentDispositionFilename( + contentDisposition: string, +): Effect.Effect { + return Effect.gen(function* () { + const encodedFilename = yield* readContentDispositionParam(contentDisposition, "filename*"); + if (encodedFilename !== undefined) { + return yield* decodeRfc5987Param(encodedFilename); + } + + return yield* readContentDispositionParam(contentDisposition, "filename"); + }); +} + +function getPartPath( + headers: Readonly>, +): Effect.Effect { + const supabasePath = headers["supabase-path"]; + if (supabasePath !== undefined && supabasePath.length > 0) { + return Effect.succeed(supabasePath); + } + + const contentDisposition = headers["content-disposition"]; + if (contentDisposition === undefined || contentDisposition.length === 0) { + return Effect.succeed(""); + } + + return readContentDispositionFilename(contentDisposition).pipe( + Effect.map((filename) => filename ?? ""), + ); +} + +function decodeMultipartForm( + response: HttpClientResponse.HttpClientResponse, +): Effect.Effect { + return Effect.gen(function* () { + const contentType = response.headers["content-type"] ?? ""; + const boundary = yield* readMultipartBoundary(contentType); + const payload = new Uint8Array( + yield* response.arrayBuffer.pipe( + Effect.mapError( + (cause) => + new InvalidFunctionDownloadResponseError({ + message: `failed to read form: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + ), + ), + ); + const parts = yield* decodeMultipartParts(payload, boundary); + + let metadata: DownloadMetadata | undefined; + const files: DownloadFilePart[] = []; + + for (const part of parts) { + const filePath = yield* getPartPath(part.headers); + if (filePath.length > 0) { + files.push({ path: filePath, body: part.body }); + continue; + } + + const fieldName = yield* readFormFieldName(part.headers); + if (fieldName === "metadata") { + const rawMetadata = new TextDecoder().decode(part.body); + metadata = yield* Effect.try({ + try: () => parseDownloadMetadata(rawMetadata), + catch: (cause) => + new InvalidFunctionDownloadResponseError({ + message: `failed to unmarshal metadata: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }); + } + } + + return { metadata, files }; + }); +} + +function resolveEntrypointPath( + metadata: DownloadMetadata | undefined, + remoteFunction: DownloadMetadata | undefined, +) { + const rawEntrypoint = hasEntrypointPath(metadata) + ? metadata.entrypoint_path + : hasEntrypointPath(remoteFunction) + ? remoteFunction.entrypoint_path + : legacyEntrypointPath; + + try { + if (rawEntrypoint.startsWith("file://")) { + return fileUrlToEntrypointPath(rawEntrypoint); + } + } catch { + return rawEntrypoint; + } + + return rawEntrypoint; +} + +function resolveDownloadDestination( + functionsRoot: string, + functionDir: string, + entrypointPath: string, + partPath: string, +): Effect.Effect { + const normalizedEntrypoint = entrypointPath.replaceAll("\\", "/"); + const normalizedPartPath = partPath.replaceAll("\\", "/"); + const relativePath = + posix.isAbsolute(normalizedEntrypoint) === posix.isAbsolute(normalizedPartPath) + ? posix.relative(normalizedEntrypoint, normalizedPartPath) + : posix.join("..", normalizedPartPath); + const entrypointName = posix.basename(normalizedEntrypoint); + const destination = + relativePath.length === 0 + ? resolve(functionDir, entrypointName) + : resolve(functionDir, entrypointName, ...relativePath.split("/")); + if (isContainedPath(resolve(functionsRoot), destination)) { + return Effect.succeed(destination); + } + + return Effect.fail( + new UnsafeFunctionDownloadPathError({ + message: `refusing to extract Function file outside ${functionsRoot}: ${partPath}`, + }), + ); +} + +function ensureContainedPath(root: string, candidate: string, sourcePath: string) { + if (isContainedPath(root, candidate)) { + return Effect.void; + } + + return Effect.fail( + new UnsafeFunctionDownloadPathError({ + message: `refusing to extract Function file outside ${root}: ${sourcePath}`, + }), + ); +} + +function writeFileWithoutFollowingSymlinks( + destination: string, + body: Uint8Array, + sourcePath: string, +) { + return Effect.gen(function* () { + const tempDestination = join(dirname(destination), `.supabase-download-${randomUUID()}.tmp`); + const file = yield* Effect.tryPromise({ + try: () => open(tempDestination, "wx"), + catch: (cause) => + new UnsafeFunctionDownloadPathError({ + message: `failed to create temporary Function file while extracting ${sourcePath}: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }); + + yield* Effect.tryPromise({ + try: () => file.writeFile(body), + catch: (cause) => + new UnsafeFunctionDownloadPathError({ + message: `failed to write Function file: ${sourcePath}: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }).pipe(Effect.ensuring(Effect.promise(() => file.close()).pipe(Effect.ignore))); + + yield* Effect.tryPromise({ + try: () => rename(tempDestination, destination), + catch: (cause) => + new UnsafeFunctionDownloadPathError({ + message: `failed to move Function file into place: ${sourcePath}: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }).pipe( + Effect.catch((error) => + Effect.promise(() => rm(tempDestination, { force: true })).pipe( + Effect.ignore, + Effect.andThen(() => Effect.fail(error)), + ), + ), + ); + }); +} + +const listRemoteFunctionSlugs = Effect.fnUntraced(function* (api: ApiClient, projectRef: string) { + const response = yield* api + .executeRaw(operationDefinitions.v1ListAllFunctions, { + ref: projectRef, + }) + .pipe(Effect.mapError((error) => mapTransportError("failed to list functions", error))); + + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + if (response.status !== 200) { + return yield* Effect.fail( + new Error(`unexpected list functions status ${response.status}: ${body}`), + ); + } + + return yield* Effect.try({ + try: () => { + const parsed = JSON.parse(body); + if (!Array.isArray(parsed)) { + throw new Error("expected functions list response to be an array"); + } + return parsed.flatMap((value) => { + const slug = getObjectProperty(value, "slug"); + return typeof slug === "string" ? [slug] : []; + }); + }, + catch: (cause) => + new InvalidFunctionDownloadResponseError({ + message: `failed to read functions list: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }); +}); + +const getRemoteFunction = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + slug: string, +) { + const response = yield* api + .executeRaw(operationDefinitions.v1GetAFunction, { + ref: projectRef, + function_slug: slug, + }) + .pipe(Effect.mapError((error) => mapTransportError("failed to get function metadata", error))); + + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + switch (response.status) { + case 200: + break; + case 404: + return yield* Effect.fail( + new FunctionDownloadNotFoundError({ + message: `Function ${slug} does not exist on the Supabase project.`, + }), + ); + default: + return yield* Effect.fail( + new Error(`Failed to download Function ${slug} on the Supabase project: ${body}`), + ); + } + + return yield* Effect.try({ + try: () => { + const parsed = JSON.parse(body); + const entrypointPath = getObjectProperty(parsed, "entrypoint_path"); + return typeof entrypointPath === "string" && entrypointPath.length > 0 + ? { entrypoint_path: entrypointPath } + : { entrypoint_path: legacyEntrypointPath }; + }, + catch: (cause) => + new InvalidFunctionDownloadResponseError({ + message: `failed to get function metadata: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }); +}); + +const downloadBody = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + slug: string, +) { + const response = yield* api + .executeRaw( + operationDefinitions.v1GetAFunctionBody, + { + ref: projectRef, + function_slug: slug, + }, + { Accept: "multipart/form-data" }, + ) + .pipe(Effect.mapError((error) => mapTransportError("failed to download function", error))); + + if (response.status === 200) { + return response; + } + + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* Effect.fail(new Error(`Error status ${response.status}: ${body}`)); +}); + +const downloadSingle = Effect.fnUntraced(function* ( + dependencies: DownloadRuntimeDependencies, + projectRef: string, + slug: string, +) { + const fs = yield* FileSystem.FileSystem; + const output = yield* Output; + + if (output.format === "text") { + yield* output.raw(`Downloading Function: ${slug}\n`, "stderr"); + } + + const response = yield* downloadBody(dependencies.api, projectRef, slug); + const { metadata, files } = yield* decodeMultipartForm(response); + const remoteFunction = hasEntrypointPath(metadata) + ? undefined + : yield* getRemoteFunction(dependencies.api, projectRef, slug); + const entrypointPath = resolveEntrypointPath(metadata, remoteFunction); + const projectRoot = dependencies.projectRoot; + const functionsRoot = join(projectRoot, "supabase", "functions"); + const functionDir = join(functionsRoot, slug); + const realProjectRoot = yield* fs.realPath(projectRoot); + const makeContainedDirectory = Effect.fnUntraced(function* ( + root: string, + directory: string, + sourcePath: string, + ) { + let existingParent = directory; + while (!(yield* fs.exists(existingParent))) { + existingParent = dirname(existingParent); + } + const realExistingParent = yield* fs.realPath(existingParent); + yield* ensureContainedPath(root, realExistingParent, sourcePath); + yield* fs.makeDirectory(directory, { recursive: true }); + const realDirectory = yield* fs.realPath(directory); + yield* ensureContainedPath(root, realDirectory, sourcePath); + }); + + yield* makeContainedDirectory(realProjectRoot, functionsRoot, functionsRoot); + const realFunctionsRoot = yield* fs.realPath(functionsRoot); + + for (const file of files) { + if (file.path.length === 0) { + continue; + } + + const destination = yield* resolveDownloadDestination( + functionsRoot, + functionDir, + entrypointPath, + file.path, + ); + const parent = dirname(destination); + yield* makeContainedDirectory(realFunctionsRoot, parent, file.path); + yield* writeFileWithoutFollowingSymlinks(destination, file.body, file.path); + yield* ensureContainedPath(realFunctionsRoot, yield* fs.realPath(destination), file.path); + if (output.format === "text") { + yield* output.raw(`Extracting file: ${destination}\n`, "stderr"); + } + } + + if (output.format === "text") { + yield* output.raw(`Downloaded Function ${slug} from project ${projectRef}.\n`, "stderr"); + } + + return slug; +}); + +export function downloadFunctions( + flags: DownloadFunctionsOptions, + dependencies: DownloadFunctionsDependencies< + ResolveError, + ResolveRequirements, + ProxyError, + ProxyRequirements + >, +) { + return Effect.gen(function* () { + const output = yield* Output; + + yield* validateDownloadFlags(flags); + + if (flags.useDocker || flags.legacyBundle) { + const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); + return yield* dependencies.proxyDownload(flags, projectRef); + } + + if (Option.isSome(flags.functionName)) { + yield* validateSlug(flags.functionName.value); + } + + const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); + const slugs = Option.isSome(flags.functionName) + ? [flags.functionName.value] + : yield* listRemoteFunctionSlugs(dependencies.api, projectRef); + + if (slugs.length === 0) { + if (output.format === "text") { + yield* output.raw(`No functions found in project ${projectRef}\n`, "stderr"); + return; + } + yield* output.success("No functions found.", { function_slugs: [], project_ref: projectRef }); + return; + } + + if (output.format === "text" && Option.isNone(flags.functionName)) { + yield* output.raw(`Found ${slugs.length} function(s) to download\n`, "stderr"); + } + + const downloaded: string[] = []; + for (const slug of slugs) { + downloaded.push(yield* downloadSingle(dependencies, projectRef, slug)); + } + + if (output.format !== "text") { + yield* output.success("Downloaded Edge Function source.", { + function_slugs: downloaded, + project_ref: projectRef, + }); + return; + } + + if (Option.isNone(flags.functionName)) { + yield* output.raw( + `Successfully downloaded all functions from project ${projectRef}\n`, + "stderr", + ); + } + }); +} diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts new file mode 100644 index 0000000000..3732e573c1 --- /dev/null +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -0,0 +1,8 @@ +const functionSlugPattern = /^[A-Za-z][A-Za-z0-9_-]*$/; + +export const invalidFunctionSlugDetail = + "Invalid Function name. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (^[A-Za-z][A-Za-z0-9_-]*$)"; + +export function validateFunctionSlugMessage(slug: string): string | undefined { + return functionSlugPattern.test(slug) ? undefined : invalidFunctionSlugDetail; +} diff --git a/apps/cli/src/shared/legacy/go-proxy.layer.ts b/apps/cli/src/shared/legacy/go-proxy.layer.ts index f7f86f08e1..da3e100901 100644 --- a/apps/cli/src/shared/legacy/go-proxy.layer.ts +++ b/apps/cli/src/shared/legacy/go-proxy.layer.ts @@ -159,7 +159,7 @@ export function makeGoProxyLayer(opts?: { const globalArgs = opts?.globalArgs ?? []; return LegacyGoProxy.of({ - exec: (args) => + exec: (args, execOpts) => Effect.scoped( Effect.gen(function* () { if (!("found" in resolved)) { @@ -198,7 +198,7 @@ export function makeGoProxyLayer(opts?: { // normal completion, failure, or fiber interruption. yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); const command = ChildProcess.make(binary, [...globalArgs, ...args], { - cwd: opts?.cwd, + cwd: execOpts?.cwd ?? opts?.cwd, env: opts?.env, extendEnv: true, stdin: "inherit", diff --git a/apps/cli/src/shared/legacy/go-proxy.service.ts b/apps/cli/src/shared/legacy/go-proxy.service.ts index b92de5b547..dd455c596b 100644 --- a/apps/cli/src/shared/legacy/go-proxy.service.ts +++ b/apps/cli/src/shared/legacy/go-proxy.service.ts @@ -7,7 +7,10 @@ interface LegacyGoProxyShape { * and propagating the exit code. On a non-zero exit the process exits with * the same code — callers do not need to handle the failure case. */ - readonly exec: (args: ReadonlyArray) => Effect.Effect; + readonly exec: ( + args: ReadonlyArray, + opts?: { readonly cwd?: string }, + ) => Effect.Effect; } export class LegacyGoProxy extends Context.Service()( diff --git a/packages/api/src/effect.unit.test.ts b/packages/api/src/effect.unit.test.ts index 84e9b72523..456cf5b4cc 100644 --- a/packages/api/src/effect.unit.test.ts +++ b/packages/api/src/effect.unit.test.ts @@ -5,7 +5,7 @@ import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; -import { makeApiClient } from "./effect.ts"; +import { makeApiClient, operationDefinitions } from "./effect.ts"; const textDecoder = new TextDecoder(); @@ -50,6 +50,34 @@ const config = { } as const; describe("makeApiClient", () => { + test("allows raw operations to override generated request headers", async () => { + let accept: string | undefined; + + const client = await Effect.runPromise( + makeApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + accept = request.headers.accept; + return Effect.succeed(jsonResponse(request, 200, {})); + }), + ), + ), + ); + + await Effect.runPromise( + client.executeRaw( + operationDefinitions.v1GetAFunctionBody, + { + ref: "abcdefghijklmnopqrst", + function_slug: "hello-world", + }, + { Accept: "multipart/form-data" }, + ), + ); + + expect(accept).toBe("multipart/form-data"); + }); + test("uses the default API URL when baseUrl is omitted", async () => { const seenRequests: Array<{ method: string; url: string }> = []; diff --git a/packages/api/src/internal/client.ts b/packages/api/src/internal/client.ts index 9f4c0e954a..24dc02ed8b 100644 --- a/packages/api/src/internal/client.ts +++ b/packages/api/src/internal/client.ts @@ -65,6 +65,7 @@ export interface SupabaseApiClientShape { readonly executeRaw: ( definition: OperationDefinition, input: OperationInput, + headers?: Readonly>, ) => Effect.Effect; } @@ -528,10 +529,14 @@ export function makeSupabaseApiClient( } return yield* Effect.die(`Unsupported response kind: ${definition.response.kind}`); }), - executeRaw: (definition, input) => + executeRaw: (definition, input, headers) => Effect.gen(function* () { const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input); - const request = yield* buildRequest(definition, validated); + const request = yield* buildRequest(definition, validated).pipe( + Effect.map((request) => + headers === undefined ? request : HttpClientRequest.setHeaders(request, headers), + ), + ); return yield* prepared.execute(request); }), };