Skip to content

Commit 2064429

Browse files
authored
feat(cli): port functions delete & download (#5527)
## TL;DR ports `supabase functions delete` and `supabase functions download` to native ts. ## What’s introduced adds native delete and download flows using the management api while preserving compatibility and existing output behavior & integration coverage around it! ## ref: - closes CLI-1319 will introduce `supabase functions deploy` as a followup :)
1 parent 09a5a92 commit 2064429

27 files changed

Lines changed: 3235 additions & 38 deletions

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,8 @@ Legend:
286286
| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) |
287287
| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) |
288288
| `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) |
289-
| `functions delete` | `wrapped` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) |
290-
| `functions download` | `wrapped` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) |
289+
| `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) |
290+
| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) |
291291
| `functions deploy` | `wrapped` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) |
292292
| `functions new` | `wrapped` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) |
293293
| `functions serve` | `wrapped` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) |

apps/cli/src/legacy/commands/functions/delete/SIDE_EFFECTS.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,16 @@ Prints a success message after the function is deleted.
4242

4343
### `--output-format json`
4444

45-
Not applicable (proxied to Go binary).
45+
Prints a structured success result with the function slug and project ref.
4646

4747
### `--output-format stream-json`
4848

49-
Not applicable (proxied to Go binary).
49+
Prints a structured success result with the function slug and project ref.
5050

5151
## Notes
5252

5353
- Requires exactly one argument: the function slug/name.
5454
- Does NOT remove the function from the local filesystem.
5555
- Requires a linked project (`--project-ref` or linked project config).
56-
- Phase 0 proxy: all invocations are forwarded to the bundled Go binary.
56+
- Runs natively in TypeScript through the Management API.
57+
- Refreshes the linked-project telemetry cache and flushes telemetry state after resolving a project ref.

apps/cli/src/legacy/commands/functions/delete/delete.command.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { Argument, Command, Flag } from "effect/unstable/cli";
22
import type * as CliCommand from "effect/unstable/cli/Command";
3+
import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts";
4+
import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts";
5+
import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts";
36
import { legacyFunctionsDelete } from "./delete.handler.ts";
47

58
const config = {
@@ -19,5 +22,21 @@ export const legacyFunctionsDeleteCommand = Command.make("delete", config).pipe(
1922
"Delete a Function from the linked Supabase project. This does NOT remove the Function locally.",
2023
),
2124
Command.withShortDescription("Delete a Function from Supabase"),
22-
Command.withHandler((flags) => legacyFunctionsDelete(flags)),
25+
Command.withExamples([
26+
{
27+
command: "supabase functions delete hello-world",
28+
description: "Delete a deployed function from the linked project",
29+
},
30+
{
31+
command: "supabase functions delete hello-world --project-ref abcdefghijklmnopqrst",
32+
description: "Delete a deployed function from a specific project",
33+
},
34+
]),
35+
Command.withHandler((flags) =>
36+
legacyFunctionsDelete(flags).pipe(
37+
withLegacyCommandInstrumentation({ flags }),
38+
withJsonErrorHandling,
39+
),
40+
),
41+
Command.provide(legacyManagementApiRuntimeLayer(["functions", "delete"])),
2342
);
Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,42 @@
11
import { Effect, Option } from "effect";
2-
import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts";
2+
import { deleteFunction } from "../../../../shared/functions/delete.ts";
3+
import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts";
4+
import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts";
5+
import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts";
6+
import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts";
37
import type { LegacyFunctionsDeleteFlags } from "./delete.command.ts";
48

59
export const legacyFunctionsDelete = Effect.fn("legacy.functions.delete")(function* (
610
flags: LegacyFunctionsDeleteFlags,
711
) {
8-
const proxy = yield* LegacyGoProxy;
9-
const args: string[] = ["functions", "delete", flags.functionName];
10-
if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value);
11-
yield* proxy.exec(args);
12+
const api = yield* LegacyPlatformApi;
13+
const resolver = yield* LegacyProjectRefResolver;
14+
const linkedProjectCache = yield* LegacyLinkedProjectCache;
15+
const telemetryState = yield* LegacyTelemetryState;
16+
let resolvedProjectRef = Option.none<string>();
17+
18+
yield* deleteFunction(
19+
{ slug: flags.functionName, projectRef: flags.projectRef },
20+
{
21+
api,
22+
resolveProjectRef: (projectRef) =>
23+
resolver.resolve(projectRef).pipe(
24+
Effect.tap((ref) =>
25+
Effect.sync(() => {
26+
resolvedProjectRef = Option.some(ref);
27+
}),
28+
),
29+
),
30+
},
31+
).pipe(
32+
Effect.ensuring(
33+
Effect.suspend(() =>
34+
Option.match(resolvedProjectRef, {
35+
onNone: () => Effect.void,
36+
onSome: (ref) => linkedProjectCache.cache(ref),
37+
}),
38+
),
39+
),
40+
Effect.ensuring(telemetryState.flush),
41+
);
1242
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Effect, Option } from "effect";
3+
4+
import {
5+
buildLegacyTestRuntime,
6+
mockLegacyCliConfig,
7+
mockLegacyLinkedProjectCacheTracked,
8+
mockLegacyPlatformApi,
9+
mockLegacyTelemetryStateTracked,
10+
useLegacyTempWorkdir,
11+
} from "../../../../../tests/helpers/legacy-mocks.ts";
12+
import { mockOutput } from "../../../../../tests/helpers/mocks.ts";
13+
import { legacyFunctionsDelete } from "./delete.handler.ts";
14+
15+
const tempRoot = useLegacyTempWorkdir("supabase-functions-delete-legacy-");
16+
17+
describe("legacy functions delete", () => {
18+
it.live("deletes a function natively through the Management API", () => {
19+
const out = mockOutput({ format: "text" });
20+
const api = mockLegacyPlatformApi({ response: { status: 200, body: null } });
21+
const linkedProjectCache = mockLegacyLinkedProjectCacheTracked();
22+
const telemetry = mockLegacyTelemetryStateTracked();
23+
const layer = buildLegacyTestRuntime({
24+
out,
25+
api,
26+
cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }),
27+
linkedProjectCache: linkedProjectCache.layer,
28+
telemetry: telemetry.layer,
29+
});
30+
31+
return Effect.gen(function* () {
32+
yield* legacyFunctionsDelete({
33+
functionName: "hello-world",
34+
projectRef: Option.none(),
35+
});
36+
37+
expect(api.requests).toHaveLength(1);
38+
expect(api.requests[0]?.method).toBe("DELETE");
39+
expect(api.requests[0]?.url).toBe(
40+
"https://api.supabase.com/v1/projects/abcdefghijklmnopqrst/functions/hello-world",
41+
);
42+
expect(out.stdoutText).toBe(
43+
"Deleted Function hello-world from project abcdefghijklmnopqrst.\n",
44+
);
45+
expect(linkedProjectCache.cached).toBe(true);
46+
expect(telemetry.flushed).toBe(true);
47+
}).pipe(Effect.provide(layer));
48+
});
49+
50+
it.live("uses an explicit project ref", () => {
51+
const out = mockOutput({ format: "text" });
52+
const api = mockLegacyPlatformApi({ response: { status: 200, body: null } });
53+
const layer = buildLegacyTestRuntime({
54+
out,
55+
api,
56+
cliConfig: mockLegacyCliConfig({
57+
workdir: tempRoot.current,
58+
projectId: Option.none(),
59+
}),
60+
});
61+
62+
return Effect.gen(function* () {
63+
yield* legacyFunctionsDelete({
64+
functionName: "hello-world",
65+
projectRef: Option.some("qrstuvwxyzabcdefghij"),
66+
});
67+
68+
expect(api.requests[0]?.url).toContain("/projects/qrstuvwxyzabcdefghij/functions/");
69+
}).pipe(Effect.provide(layer));
70+
});
71+
});

apps/cli/src/legacy/commands/functions/download/SIDE_EFFECTS.md

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,23 @@
88

99
## Files Written
1010

11-
| Path | Format | When |
12-
| ---------------------------------------------- | ---------- | ---------------------------------- |
13-
| `<workdir>/supabase/functions/<slug>/index.ts` | TypeScript | always (downloads function source) |
11+
| Path | Format | When |
12+
| --------------------------------------------------- | ------ | ---------------------------------------- |
13+
| `<workdir>/supabase/functions/<slug>/<remote path>` | bytes | for each source file returned by the API |
1414

1515
## API Routes
1616

17-
| Method | Path | Auth | Request body | Response (used fields) |
18-
| ------ | ------------------------------------------ | ------------ | ------------ | ---------------------- |
19-
| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | function source code |
17+
| Method | Path | Auth | Request body | Response (used fields) |
18+
| ------ | ------------------------------------------ | ------------ | ------------ | ------------------------------------------ |
19+
| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | function slugs, when downloading all |
20+
| `GET` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | entrypoint path, when absent from metadata |
21+
| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | multipart function source |
22+
23+
## Subprocesses
24+
25+
| Command | When | Purpose |
26+
| ------------------------------------ | ----------------------------------- | ----------------------------------- |
27+
| `supabase-go functions download ...` | `--use-docker` or `--legacy-bundle` | preserve hidden compatibility modes |
2028

2129
## Environment Variables
2230

@@ -42,15 +50,16 @@ Prints progress and success messages as functions are downloaded.
4250

4351
### `--output-format json`
4452

45-
Not applicable (proxied to Go binary).
53+
Prints a structured success result with the downloaded function slugs and project ref.
4654

4755
### `--output-format stream-json`
4856

49-
Not applicable (proxied to Go binary).
57+
Prints a structured success result with the downloaded function slugs and project ref.
5058

5159
## Notes
5260

5361
- If no function name is provided, downloads all functions.
5462
- Requires a linked project (`--project-ref` or linked project config).
63+
- Native downloads reject path traversal and symlink escapes before writing source files.
5564
- `--use-docker` and `--legacy-bundle` are hidden flags forwarded to the Go binary for backward compatibility; they are mutually exclusive with `--use-api`.
56-
- Phase 0 proxy: all invocations are forwarded to the bundled Go binary.
65+
- Refreshes the linked-project telemetry cache and flushes telemetry state after resolving a project ref.

apps/cli/src/legacy/commands/functions/download/download.command.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { Argument, Command, Flag } from "effect/unstable/cli";
22
import type * as CliCommand from "effect/unstable/cli/Command";
3+
import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts";
4+
import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts";
5+
import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts";
36
import { legacyFunctionsDownload } from "./download.handler.ts";
47

58
const config = {
@@ -31,5 +34,21 @@ export const legacyFunctionsDownloadCommand = Command.make("download", config).p
3134
"Download the source code for a Function from the linked Supabase project. If no function name is provided, downloads all functions.",
3235
),
3336
Command.withShortDescription("Download a Function from Supabase"),
34-
Command.withHandler((flags) => legacyFunctionsDownload(flags)),
37+
Command.withExamples([
38+
{
39+
command: "supabase functions download hello-world",
40+
description: "Download a single function from the linked project",
41+
},
42+
{
43+
command: "supabase functions download --project-ref abcdefghijklmnopqrst",
44+
description: "Download all functions from a specific project",
45+
},
46+
]),
47+
Command.withHandler((flags) =>
48+
legacyFunctionsDownload(flags).pipe(
49+
withLegacyCommandInstrumentation({ flags }),
50+
withJsonErrorHandling,
51+
),
52+
),
53+
Command.provide(legacyManagementApiRuntimeLayer(["functions", "download"])),
3554
);
Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,49 @@
11
import { Effect, Option } from "effect";
2+
import {
3+
downloadFunctions,
4+
makeGoProxyDownloadArgs,
5+
} from "../../../../shared/functions/download.ts";
26
import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts";
7+
import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts";
8+
import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts";
9+
import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts";
10+
import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts";
11+
import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts";
312
import type { LegacyFunctionsDownloadFlags } from "./download.command.ts";
413

514
export const legacyFunctionsDownload = Effect.fn("legacy.functions.download")(function* (
615
flags: LegacyFunctionsDownloadFlags,
716
) {
17+
const api = yield* LegacyPlatformApi;
18+
const cliConfig = yield* LegacyCliConfig;
19+
const resolver = yield* LegacyProjectRefResolver;
20+
const linkedProjectCache = yield* LegacyLinkedProjectCache;
21+
const telemetryState = yield* LegacyTelemetryState;
822
const proxy = yield* LegacyGoProxy;
9-
const args: string[] = ["functions", "download"];
10-
if (Option.isSome(flags.functionName)) args.push(flags.functionName.value);
11-
if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value);
12-
if (flags.useApi) args.push("--use-api");
13-
if (flags.useDocker) args.push("--use-docker");
14-
if (flags.legacyBundle) args.push("--legacy-bundle");
15-
yield* proxy.exec(args);
23+
let resolvedProjectRef = Option.none<string>();
24+
25+
yield* downloadFunctions(flags, {
26+
api,
27+
projectRoot: cliConfig.workdir,
28+
resolveProjectRef: (projectRef) =>
29+
resolver.resolve(projectRef).pipe(
30+
Effect.tap((ref) =>
31+
Effect.sync(() => {
32+
resolvedProjectRef = Option.some(ref);
33+
}),
34+
),
35+
),
36+
proxyDownload: (proxyFlags, projectRef) =>
37+
proxy.exec(makeGoProxyDownloadArgs(proxyFlags, projectRef)),
38+
}).pipe(
39+
Effect.ensuring(
40+
Effect.suspend(() =>
41+
Option.match(resolvedProjectRef, {
42+
onNone: () => Effect.void,
43+
onSome: (ref) => linkedProjectCache.cache(ref),
44+
}),
45+
),
46+
),
47+
Effect.ensuring(telemetryState.flush),
48+
);
1649
});

0 commit comments

Comments
 (0)