diff --git a/cmd/functions.go b/cmd/functions.go index 5a58651ab..02060b99a 100644 --- a/cmd/functions.go +++ b/cmd/functions.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/supabase/cli/internal/functions/delete" "github.com/supabase/cli/internal/functions/deploy" + "github.com/supabase/cli/internal/functions/download" new_ "github.com/supabase/cli/internal/functions/new" "github.com/supabase/cli/internal/functions/serve" "github.com/supabase/cli/internal/login" @@ -42,6 +43,21 @@ var ( }, } + functionsDownloadCmd = &cobra.Command{ + Use: "download ", + Short: "Download a Function from Supabase", + Long: "Download source code of a Function from the linked Supabase project.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + fsys := afero.NewOsFs() + if err := PromptLogin(fsys); err != nil { + return err + } + ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt) + return download.Run(ctx, args[0], projectRef, fsys) + }, + } + noVerifyJWT = new(bool) useLegacyBundle bool importMapPath string @@ -104,10 +120,12 @@ func init() { functionsServeCmd.Flags().BoolVar(noVerifyJWT, "no-verify-jwt", false, "Disable JWT verification for the Function.") functionsServeCmd.Flags().StringVar(&envFilePath, "env-file", "", "Path to an env file to be populated to the Function environment.") functionsServeCmd.Flags().StringVar(&importMapPath, "import-map", "", "Path to import map file.") + functionsDownloadCmd.Flags().StringVar(&projectRef, "project-ref", "", "Project ref of the Supabase project.") functionsCmd.AddCommand(functionsDeleteCmd) functionsCmd.AddCommand(functionsDeployCmd) functionsCmd.AddCommand(functionsNewCmd) functionsCmd.AddCommand(functionsServeCmd) + functionsCmd.AddCommand(functionsDownloadCmd) rootCmd.AddCommand(functionsCmd) } diff --git a/internal/functions/deploy/deploy.go b/internal/functions/deploy/deploy.go index c609e6933..6448875db 100644 --- a/internal/functions/deploy/deploy.go +++ b/internal/functions/deploy/deploy.go @@ -23,7 +23,7 @@ const eszipContentType = "application/vnd.denoland.eszip" func Run(ctx context.Context, slug string, projectRefArg string, noVerifyJWT *bool, useLegacyBundle bool, importMapPath string, fsys afero.Fs) error { // 1. Sanity checks. projectRef := projectRefArg - var buildScriptPath string + var scriptDir *utils.DenoScriptDir { if len(projectRefArg) == 0 { ref, err := utils.LoadProjectRef(fsys) @@ -68,7 +68,7 @@ func Run(ctx context.Context, slug string, projectRefArg string, noVerifyJWT *bo } var err error - buildScriptPath, err = utils.CopyEszipScripts(ctx, fsys) + scriptDir, err = utils.CopyDenoScripts(ctx, fsys) if err != nil { return err } @@ -94,6 +94,7 @@ func Run(ctx context.Context, slug string, projectRefArg string, noVerifyJWT *bo } } + buildScriptPath := scriptDir.BuildPath args := []string{"run", "-A", buildScriptPath, filepath.Join(functionPath, "index.ts"), importMapPath} if useLegacyBundle { args = []string{"bundle", "--no-check=remote", "--quiet", filepath.Join(functionPath, "index.ts")} diff --git a/internal/functions/download/download.go b/internal/functions/download/download.go new file mode 100644 index 000000000..131e5463f --- /dev/null +++ b/internal/functions/download/download.go @@ -0,0 +1,82 @@ +package download + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/afero" + "github.com/supabase/cli/internal/utils" +) + +func Run(ctx context.Context, slug string, projectRefArg string, fsys afero.Fs) error { + // 1. Sanity checks. + projectRef := projectRefArg + var scriptDir *utils.DenoScriptDir + { + if len(projectRefArg) == 0 { + ref, err := utils.LoadProjectRef(fsys) + if err != nil { + return err + } + projectRef = ref + } else if !utils.ProjectRefPattern.MatchString(projectRefArg) { + return errors.New("Invalid project ref format. Must be like `abcdefghijklmnopqrst`.") + } + if err := utils.ValidateFunctionSlug(slug); err != nil { + return err + } + } + if err := utils.InstallOrUpgradeDeno(ctx, fsys); err != nil { + return err + } + + var err error + scriptDir, err = utils.CopyDenoScripts(ctx, fsys) + if err != nil { + return err + } + + // 2. Download Function. + { + fmt.Println("Downloading " + utils.Bold(slug)) + denoPath, err := utils.GetDenoPath() + if err != nil { + return err + } + + resp, err := utils.GetSupabase().GetFunctionBodyWithResponse(ctx, projectRef, slug) + if err != nil { + return err + } + + switch resp.StatusCode() { + case http.StatusNotFound: // Function doesn't exist + return errors.New("Function " + utils.Aqua(slug) + " does not exist on the Supabase project.") + case http.StatusOK: // Function exists + resBuf := bytes.NewReader(resp.Body) + + extractScriptPath := scriptDir.ExtractPath + funcDir := filepath.Join(utils.FunctionsDir, slug) + var errBuf bytes.Buffer + args := []string{"run", "-A", extractScriptPath, funcDir} + cmd := exec.CommandContext(ctx, denoPath, args...) + cmd.Stdin = resBuf + cmd.Stdout = os.Stdout + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + return fmt.Errorf("Error downloading function: %w\n%v", err, errBuf.String()) + } + default: + return errors.New("Unexpected error downloading Function: " + string(resp.Body)) + } + } + + fmt.Println("Downloaded Function " + utils.Aqua(slug) + " from project " + utils.Aqua(projectRef) + ".") + return nil +} diff --git a/internal/utils/eszip/build.ts b/internal/utils/denos/build.ts similarity index 100% rename from internal/utils/eszip/build.ts rename to internal/utils/denos/build.ts diff --git a/internal/utils/denos/extract.ts b/internal/utils/denos/extract.ts new file mode 100644 index 000000000..fe0309729 --- /dev/null +++ b/internal/utils/denos/extract.ts @@ -0,0 +1,45 @@ +import * as path from "https://deno.land/std@0.127.0/path/mod.ts"; +import { readAll } from "https://deno.land/std@0.162.0/streams/conversion.ts"; +import { decompress } from "https://deno.land/x/brotli@0.1.7/mod.ts"; + +import { Parser } from "https://deno.land/x/eszip@v0.30.0/mod.ts"; + +function url2path(url: string) { + const srcPath = new URL(url).pathname.replace("/src", ""); + return path.join(...srcPath.split("/").filter(Boolean)); +} + +async function write(p: string, content: string) { + await Deno.mkdir(path.dirname(p), { recursive: true }); + await Deno.writeTextFile(p, content); +} + +async function loadEszip(bytes: Uint8Array) { + const parser = await Parser.createInstance(); + const specifiers = await parser.parseBytes(bytes); + await parser.load(); + return { parser, specifiers }; +} + +async function extractEszip(dest: string, parser, specifiers) { + const imports = {}; + + for (const specifier of specifiers) { + // skip remote dependencies + if (!specifier.startsWith("file:")) { + continue; + } + const module = await parser.getModuleSource(specifier); + await write(path.join(dest, url2path(specifier)), module); + } +} + +async function extractSource(dest: string) { + const buf = await readAll(Deno.stdin); + // response is compressed with Brotli + const decompressed = decompress(buf); + const { parser, specifiers } = await loadEszip(decompressed); + await extractEszip(dest, parser, specifiers); +} + +extractSource(Deno.args[0]); diff --git a/internal/utils/misc.go b/internal/utils/misc.go index 3222fa319..d3f0f267f 100644 --- a/internal/utils/misc.go +++ b/internal/utils/misc.go @@ -92,8 +92,8 @@ var ( //go:embed templates/globals.sql GlobalsSql string - //go:embed eszip/* - eszipEmbedDir embed.FS + //go:embed denos/* + denoEmbedDir embed.FS AccessTokenPattern = regexp.MustCompile(`^sbp_[a-f0-9]{40}$`) ProjectRefPattern = regexp.MustCompile(`^[a-z]{20}$`) @@ -408,8 +408,8 @@ func InstallOrUpgradeDeno(ctx context.Context, fsys afero.Fs) error { return nil } -func isBuildScriptModified(fsys afero.Fs, buildScriptPath string) (bool, error) { - bs, err := afero.ReadFile(fsys, buildScriptPath) +func isScriptModified(fsys afero.Fs, destPath string, src []byte) (bool, error) { + dest, err := afero.ReadFile(fsys, destPath) if err != nil { if errors.Is(err, fs.ErrNotExist) { return true, nil @@ -417,43 +417,33 @@ func isBuildScriptModified(fsys afero.Fs, buildScriptPath string) (bool, error) return false, err } - es, err := fs.ReadFile(eszipEmbedDir, "eszip/build.ts") - if err != nil { - return false, err - } + // compare the md5 checksum of src bytes with user's copy. + // if the checksums doesn't match, script is modified. + return md5.Sum(dest) != md5.Sum(src), nil +} - // compare the md5 checksum of current build script with user's copy. - // if the checksums doesn't match, build script is modified. - return md5.Sum(bs) != md5.Sum(es), nil +type DenoScriptDir struct { + ExtractPath string + BuildPath string } -// Copy ESZIP scripts needed for function deploy, returning the build script path or an error. -func CopyEszipScripts(ctx context.Context, fsys afero.Fs) (string, error) { +// Copy Deno scripts needed for function deploy and downloads, returning a DenoScriptDir struct or an error. +func CopyDenoScripts(ctx context.Context, fsys afero.Fs) (*DenoScriptDir, error) { denoPath, err := GetDenoPath() if err != nil { - return "", err + return nil, err } denoDirPath := filepath.Dir(denoPath) - scriptDirPath := filepath.Join(denoDirPath, "eszip") - buildScriptPath := filepath.Join(scriptDirPath, "build.ts") + scriptDirPath := filepath.Join(denoDirPath, "denos") // make the script directory if not exist if err := MkdirIfNotExistFS(fsys, scriptDirPath); err != nil { - return "", err - } - - // check if the build script should be copied - modified, err := isBuildScriptModified(fsys, buildScriptPath) - if err != nil { - return "", err - } - if !modified { - return buildScriptPath, nil + return nil, err } // copy embed files to script directory - err = fs.WalkDir(eszipEmbedDir, "eszip", func(path string, d fs.DirEntry, err error) error { + err = fs.WalkDir(denoEmbedDir, "denos", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } @@ -463,10 +453,21 @@ func CopyEszipScripts(ctx context.Context, fsys afero.Fs) (string, error) { return nil } - contents, err := fs.ReadFile(eszipEmbedDir, path) + destPath := filepath.Join(denoDirPath, path) + + contents, err := fs.ReadFile(denoEmbedDir, path) + if err != nil { + return err + } + + // check if the script should be copied + modified, err := isScriptModified(fsys, destPath, contents) if err != nil { return err } + if !modified { + return nil + } if err := afero.WriteFile(fsys, filepath.Join(denoDirPath, path), contents, 0666); err != nil { return err @@ -476,10 +477,15 @@ func CopyEszipScripts(ctx context.Context, fsys afero.Fs) (string, error) { }) if err != nil { - return "", err + return nil, err + } + + sd := DenoScriptDir{ + ExtractPath: filepath.Join(scriptDirPath, "extract.ts"), + BuildPath: filepath.Join(scriptDirPath, "build.ts"), } - return filepath.Join(buildScriptPath), nil + return &sd, nil } func LoadAccessToken() (string, error) {