From 9f31e596930c90a1729b3fdc0f845215ec694dbe Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Tue, 14 Jul 2026 16:51:00 +0900 Subject: [PATCH 1/7] Source starter kits from new service --- DEVELOPMENT.md | 4 +- pkg/commands/compute/init.go | 195 +++++++++++++- pkg/commands/compute/init_test.go | 252 +++++------------- pkg/commands/compute/language.go | 105 ++++++-- pkg/commands/compute/language_test.go | 130 +++++++-- pkg/commands/compute/starterkit_redirect.go | 72 +++++ .../compute/starterkit_redirect_test.go | 146 ++++++++++ pkg/config/config.go | 20 -- pkg/file/archive.go | 13 +- pkg/starterkit/doc.go | 4 + pkg/starterkit/starterkit.go | 142 ++++++++++ pkg/starterkit/starterkit_test.go | 139 ++++++++++ scripts/config.sh | 40 --- 13 files changed, 936 insertions(+), 326 deletions(-) create mode 100644 pkg/commands/compute/starterkit_redirect.go create mode 100644 pkg/commands/compute/starterkit_redirect_test.go create mode 100644 pkg/starterkit/doc.go create mode 100644 pkg/starterkit/starterkit.go create mode 100644 pkg/starterkit/starterkit_test.go diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 527f868ef..bb0c99b22 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -62,9 +62,9 @@ The CLI dynamically generates the `./pkg/config/config.toml` within the CI relea The file is added to `.gitignore` to avoid it being added to the git repository. -When compiling the CLI for a new release, it will execute [`./scripts/config.sh`](./scripts/config.sh). The script uses [`./.fastly/config.toml`](./.fastly/config.toml) as a template file to then dynamically inject a list of starter kits (pulling their data from their public repositories). +When compiling the CLI for a new release, it will execute [`./scripts/config.sh`](./scripts/config.sh), which copies [`./.fastly/config.toml`](./.fastly/config.toml) to `./pkg/config/config.toml`, ready to be embedded into the CLI when compiled. -The resulting configuration is then saved to disk at `./pkg/config/config.toml` and embedded into the CLI when compiled. +> **NOTE:** Compute starter kits are no longer listed in this config file. `compute init` sources them live from the starter-kit edge service (see `pkg/starterkit`) instead of from an embedded, build-time-generated list. When a user installs the CLI for the first time, they'll have no existing config and so the embedded config will be used. In the future, when the user updates their CLI, the existing config they have will be used. diff --git a/pkg/commands/compute/init.go b/pkg/commands/compute/init.go index 9c0fdcceb..9c6e356ea 100644 --- a/pkg/commands/compute/init.go +++ b/pkg/commands/compute/init.go @@ -22,7 +22,6 @@ import ( "github.com/fastly/go-fastly/v17/fastly" "github.com/fastly/cli/pkg/argparser" - "github.com/fastly/cli/pkg/config" "github.com/fastly/cli/pkg/debug" fsterr "github.com/fastly/cli/pkg/errors" fstexec "github.com/fastly/cli/pkg/exec" @@ -31,6 +30,7 @@ import ( "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/internal/beacon" "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/starterkit" "github.com/fastly/cli/pkg/text" ) @@ -178,7 +178,7 @@ func (c *InitCommand) Exec(in io.Reader, out io.Writer) (err error) { } } - languages := NewLanguages(c.Globals.Config.StarterKits) + languages := NewLanguages() var language *Language @@ -210,6 +210,15 @@ func (c *InitCommand) Exec(in io.Reader, out io.Writer) (err error) { // select a starter kit project. triggerStarterKitPrompt := c.CloneFrom == "" && !mf.Exists() && language.Name != "other" if triggerStarterKitPrompt { + client := starterkit.New(starterkit.DefaultEndpoint, c.Globals.HTTPClient, c.Globals.Flags.Debug) + if err := language.FetchStarterKits(client); err != nil { + c.Globals.ErrLog.Add(err) + return fsterr.RemediationError{ + Inner: fmt.Errorf("could not reach the starter kit service: %w", err), + Remediation: "Please check your network connection and try again.", + } + } + from, branch, tag, err = c.PromptForStarterKit(language.StarterKits, in, out) if err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ @@ -778,7 +787,7 @@ func validateLanguageOption(languages []*Language) func(string) error { // PromptForStarterKit prompts the user for a package starter kit. // // It returns the path to the starter kit, and the corresponding branch/tag. -func (c *InitCommand) PromptForStarterKit(kits []config.StarterKit, in io.Reader, out io.Writer) (from string, branch string, tag string, err error) { +func (c *InitCommand) PromptForStarterKit(kits []starterkit.Kit, in io.Reader, out io.Writer) (from string, branch string, tag string, err error) { var option string flags := c.Globals.Flags @@ -798,7 +807,7 @@ func (c *InitCommand) PromptForStarterKit(kits []config.StarterKit, in io.Reader text.Output(out, "\n%s", text.Bold("Starter kit:")) for i, kit := range kits { fmt.Fprintf(out, "[%d] %s\n", i+1, text.Bold(kit.Name)) - text.Indent(out, 4, "%s\n%s", kit.Description, kit.Path) + text.Indent(out, 4, "%s\n%s", kit.Description, kit.FromValue()) } text.Info(out, "\nFor a complete list of Starter Kits:") text.Indent(out, 4, "https://www.fastly.com/documentation/solutions/starters") @@ -815,21 +824,23 @@ func (c *InitCommand) PromptForStarterKit(kits []config.StarterKit, in io.Reader option = "1" } + // NOTE: The starter-kit edge service has no concept of git refs, so + // branch/tag are always empty when resolving via a selected kit. var i int if i, err = strconv.Atoi(option); err == nil { if i < 1 || i > len(kits) { return "", "", "", fmt.Errorf("invalid starter kit option: %s", option) } template := kits[i-1] - return template.Path, template.Branch, template.Tag, nil + return template.FromValue(), "", "", nil } return option, "", "", nil } -func validateTemplateOptionOrURL(templates []config.StarterKit) func(string) error { +func validateTemplateOptionOrURL(templates []starterkit.Kit) func(string) error { return func(input string) error { - msg := "must be a valid option or git URL" + msg := "must be a valid option, git URL, or starter-kit// reference" if input == "" { return nil } @@ -839,10 +850,13 @@ func validateTemplateOptionOrURL(templates []config.StarterKit) func(string) err } return nil } - if !gitRepositoryRegEx.MatchString(input) { - return errors.New(msg) + if gitRepositoryRegEx.MatchString(input) { + return nil } - return nil + if _, _, ok := starterkit.ParseFrom(input); ok { + return nil + } + return errors.New(msg) } } @@ -858,6 +872,22 @@ func (c *InitCommand) FetchPackageTemplate(branch, tag string, archives []file.A msg := "Fetching package template" spinner.Message(msg + "...") + // If --from is of the form starter-kit//, resolve it directly + // against the starter-kit edge service, bypassing local-dir/URL/git + // detection entirely. + if lang, name, ok := starterkit.ParseFrom(c.CloneFrom); ok { + client := starterkit.New(starterkit.DefaultEndpoint, c.Globals.HTTPClient, c.Globals.Flags.Debug) + if err := c.fetchAndExtractTarball(client.TarballURL(lang, name), archives); err != nil { + spinner.StopFailMessage(msg) + if spinErr := spinner.StopFail(); spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return err + } + spinner.StopMessage(msg) + return spinner.Stop() + } + // If the user has provided a local file path, we'll recursively copy the // directory to c.dir. if fi, err := os.Stat(c.CloneFrom); err == nil && fi.IsDir() { @@ -1077,9 +1107,154 @@ mimes: return spinner.Stop() } +// fetchAndExtractTarball downloads the archive at url and extracts it into +// c.dir, using content-negotiation (matching the response's Content-Type +// header or the url's file extension against archives) to determine the +// archive format. +// +// NOTE: This does not touch the spinner. Callers are responsible for spinner +// lifecycle around this call, matching how ClonePackageFromEndpoint (which +// this is a sibling to) already leaves spinner handling to its callers. +func (c *InitCommand) fetchAndExtractTarball(url string, archives []file.Archive) error { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + err = fmt.Errorf("failed to construct package request URL: %w", err) + c.Globals.ErrLog.Add(err) + return err + } + for _, archive := range archives { + for _, mime := range archive.MimeTypes() { + req.Header.Add("Accept", mime) + } + } + + if c.Globals.Flags.Debug { + debug.DumpHTTPRequest(req) + } + res, err := c.Globals.HTTPClient.Do(req) + if c.Globals.Flags.Debug { + debug.DumpHTTPResponse(res) + } + if err != nil { + err = fmt.Errorf("failed to get package '%s': %w", url, err) + c.Globals.ErrLog.Add(err) + return err + } + defer res.Body.Close() // #nosec G307 + + if res.StatusCode != http.StatusOK { + err := fmt.Errorf("failed to get package '%s': %s", url, res.Status) + c.Globals.ErrLog.Add(err) + return err + } + + tempdir, err := tempDir("package-init-download") + if err != nil { + err = fmt.Errorf("error creating temporary path for package template download: %w", err) + c.Globals.ErrLog.Add(err) + return err + } + defer os.RemoveAll(tempdir) + + filename := filepath.Join(tempdir, filepath.Base(url)) + ext := filepath.Ext(filename) + + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable + // + // Disabling as we require a user to configure their own environment. + /* #nosec */ + f, err := os.Create(filename) + if err != nil { + err = fmt.Errorf("failed to create local %s archive: %w", filename, err) + c.Globals.ErrLog.Add(err) + return err + } + + if _, err := io.Copy(f, res.Body); err != nil { + err = fmt.Errorf("failed to write %s archive to disk: %w", filename, err) + c.Globals.ErrLog.Add(err) + return err + } + + // NOTE: We used to `defer` the closing of the file after its creation but + // realised that this caused issues on Windows as it was unable to rename the + // file as we still have the descriptor `f` open. + if err := f.Close(); err != nil { + c.Globals.ErrLog.Add(err) + } + + var archive file.Archive + +mimes: + for _, mimetype := range res.Header.Values("Content-Type") { + for _, a := range archives { + for _, mime := range a.MimeTypes() { + if mimetype == mime { + archive = a + break mimes + } + } + } + } + + if archive == nil { + for _, a := range archives { + for _, e := range a.Extensions() { + if ext == e { + archive = a + break + } + } + } + } + + if archive == nil { + err := fmt.Errorf("could not determine archive format for '%s'", url) + c.Globals.ErrLog.Add(err) + return err + } + + // Ensure there is a file extension on our filename, otherwise we won't + // know what type of archive format we're dealing with when we come to call + // the archive.Extract() method. + if ext == "" { + filenameWithExt := filename + archive.Extensions()[0] + if err := os.Rename(filename, filenameWithExt); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + filename = filenameWithExt + } + + archive.SetDestination(c.dir) + archive.SetFilename(filename) + + if err := archive.Extract(); err != nil { + err = fmt.Errorf("failed to extract %s archive content: %w", filename, err) + c.Globals.ErrLog.Add(err) + return err + } + + return nil +} + // ClonePackageFromEndpoint clones the given repo (from) into a temp directory, // then copies specific files to the destination directory (path). +// +// Before cloning a Fastly-owned repo, it checks for a root-level +// .starter-kit-id marker file redirecting to the starter-kit edge service +// (see starterKitRedirect). This lets legacy fastly/compute-starter-kit-* +// repos transparently redirect any tool honoring the convention to the new +// monorepo-backed source of truth, without the repos themselves being cloned. func (c *InitCommand) ClonePackageFromEndpoint(from, branch, tag string) error { + if fastlyOrgRegEx.MatchString(from) { + if lang, name, ok := starterKitRedirect(c.Globals.HTTPClient, from); ok { + client := starterkit.New(starterkit.DefaultEndpoint, c.Globals.HTTPClient, c.Globals.Flags.Debug) + return c.fetchAndExtractTarball(client.TarballURL(lang, name), file.Archives) + } + } + _, err := exec.LookPath("git") if err != nil { return fsterr.RemediationError{ diff --git a/pkg/commands/compute/init_test.go b/pkg/commands/compute/init_test.go index 3cd7cc3fa..19740c7d6 100644 --- a/pkg/commands/compute/init_test.go +++ b/pkg/commands/compute/init_test.go @@ -30,32 +30,10 @@ func TestInit(t *testing.T) { t.Skip("Set TEST_COMPUTE_INIT to run this test") } - skRust := []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default", - Branch: "main", - }, - } - skJS := []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-javascript-default", - Branch: "main", - }, - } - skCPP := []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-cpp-default", - Branch: "main", - }, - { - Name: "Empty", - Path: "https://github.com/fastly/compute-starter-kit-cpp-empty", - Branch: "main", - }, - } + // NOTE: Starter kits are no longer sourced from a local config.File + // fixture -- the interactive prompt now fetches them live from the + // starter-kit edge service (see pkg/starterkit), so scenarios that rely + // on the default (option "1") kit selection exercise the real service. scenarios := []struct { name string @@ -89,13 +67,8 @@ func TestInit(t *testing.T) { }, }, { - name: "name prompt", - args: args("compute init"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - Rust: skRust, - }, - }, + name: "name prompt", + args: args("compute init"), stdin: "foobar", // expect the first prompt to be for the package name. wantOutput: []string{ "Fetching package template", @@ -106,11 +79,6 @@ func TestInit(t *testing.T) { { name: "description prompt empty", args: args("compute init"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - Rust: skRust, - }, - }, wantOutput: []string{ "Fetching package template", "Reading fastly.toml", @@ -120,11 +88,6 @@ func TestInit(t *testing.T) { { name: "with author", args: args("compute init --author test@example.com"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - Rust: skRust, - }, - }, wantOutput: []string{ "Fetching package template", "Reading fastly.toml", @@ -134,11 +97,6 @@ func TestInit(t *testing.T) { { name: "with multiple authors", args: args("compute init --author test1@example.com --author test2@example.com"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - Rust: skRust, - }, - }, wantOutput: []string{ "Fetching package template", "Reading fastly.toml", @@ -148,16 +106,6 @@ func TestInit(t *testing.T) { { name: "with --from set to starter kit repository", args: args("compute init --from https://github.com/fastly/compute-starter-kit-rust-default"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - Rust: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", - }, - }, - }, - }, wantOutput: []string{ "Fetching package template", "Reading fastly.toml", @@ -167,16 +115,6 @@ func TestInit(t *testing.T) { { name: "with --from set to starter kit repository when dir with same name exists in pwd", args: args("compute init --auto-yes --from https://github.com/fastly/compute-starter-kit-rust-default"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - Rust: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", - }, - }, - }, - }, wantOutput: []string{ "Fetching package template", "Reading fastly.toml", @@ -189,16 +127,6 @@ func TestInit(t *testing.T) { { name: "with --from set to starter kit repository with .git extension and branch", args: args("compute init --from https://github.com/fastly/compute-starter-kit-rust-default.git --branch main"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - Rust: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", - }, - }, - }, - }, wantOutput: []string{ "Fetching package template", "Reading fastly.toml", @@ -208,16 +136,6 @@ func TestInit(t *testing.T) { { name: "with --from set to starter kit repository with .git extension and branch when dir with same name exists in pwd", args: args("compute init --auto-yes --from https://github.com/fastly/compute-starter-kit-rust-default.git --branch main"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - Rust: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", - }, - }, - }, - }, wantOutput: []string{ "Fetching package template", "Reading fastly.toml", @@ -230,16 +148,6 @@ func TestInit(t *testing.T) { { name: "with --from set to zip archive", args: args("compute init --from https://github.com/fastly/compute-starter-kit-rust-default/archive/refs/heads/main.zip"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - Rust: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", - }, - }, - }, - }, wantOutput: []string{ "Fetching package template", "Reading fastly.toml", @@ -249,16 +157,6 @@ func TestInit(t *testing.T) { { name: "with --from set to zip archive when file with same name exists in pwd", args: args("compute init --auto-yes --from https://github.com/fastly/compute-starter-kit-rust-default/archive/refs/heads/main.zip"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - Rust: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", - }, - }, - }, - }, wantOutput: []string{ "Fetching package template", "Reading fastly.toml", @@ -275,16 +173,24 @@ func TestInit(t *testing.T) { { name: "with --from set to tar.gz archive", args: args("compute init --from https://github.com/Integralist/devnull/files/7339887/compute-starter-kit-rust-default-main.tar.gz"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - Rust: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", - }, - }, - }, + wantOutput: []string{ + "Fetching package template", + "Reading fastly.toml", + "SUCCESS: Initialized package", }, + }, + { + name: "with --from set to starter-kit// reference", + args: args("compute init --from starter-kit/javascript/typescript-default"), + wantOutput: []string{ + "Fetching package template", + "Reading fastly.toml", + "SUCCESS: Initialized package", + }, + }, + { + name: "with --from set to a legacy repo carrying a .starter-kit-id redirect", + args: args("compute init --from https://github.com/fastly/compute-starter-kit-typescript-default"), wantOutput: []string{ "Fetching package template", "Reading fastly.toml", @@ -294,11 +200,6 @@ func TestInit(t *testing.T) { { name: "with existing fastly.toml", args: args("compute init --auto-yes"), // --force will ignore a directory that isn't empty - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - Rust: skRust, - }, - }, manifest: ` manifest_version = 2 service_id = 1234 @@ -315,19 +216,11 @@ func TestInit(t *testing.T) { { name: "no args and no user profiles means no email set for author field", args: args("compute init"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - Rust: skRust, - }, - }, wantFiles: []string{ "Cargo.toml", "fastly.toml", "src/main.rs", }, - unwantedFiles: []string{ - "SECURITY.md", - }, wantOutput: []string{ "Author (email):", "Language:", @@ -354,9 +247,6 @@ func TestInit(t *testing.T) { }, }, }, - StarterKits: config.StarterKitLanguages{ - Rust: skRust, - }, }, manifestIncludes: `authors = ["test@example.com"]`, wantFiles: []string{ @@ -364,9 +254,6 @@ func TestInit(t *testing.T) { "fastly.toml", "src/main.rs", }, - unwantedFiles: []string{ - "SECURITY.md", - }, wantOutput: []string{ "Fetching package template", "Reading fastly.toml", @@ -375,69 +262,39 @@ func TestInit(t *testing.T) { }, }, { - name: "non empty directory", - args: args("compute init"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - Rust: skRust, - }, - }, + name: "non empty directory", + args: args("compute init"), wantError: "project directory not empty", manifest: ` manifest_version = 2 name = "test"`, }, { - name: "with default name inferred from directory", - args: args("compute init"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - Rust: skRust, - }, - }, + name: "with default name inferred from directory", + args: args("compute init"), manifestIncludes: `name = "fastly-temp`, }, { - name: "with directory name inferred from --directory", - args: args("compute init --directory ./foo"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - Rust: skRust, - }, - }, + name: "with directory name inferred from --directory", + args: args("compute init --directory ./foo"), stdin: "Y", manifest: `manifest_version = 2`, manifestPath: "foo", manifestIncludes: `name = "foo`, }, { - name: "with JavaScript language", - args: args("compute init --language javascript"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - JavaScript: skJS, - }, - }, + name: "with JavaScript language", + args: args("compute init --language javascript"), manifestIncludes: `name = "fastly-temp`, }, { - name: "with C++ language", - args: args("compute init --language cpp"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - CPP: skCPP, - }, - }, + name: "with C++ language", + args: args("compute init --language cpp"), manifestIncludes: `name = "fastly-temp`, }, { name: "with --from set to C++ empty starter kit", args: args("compute init --from https://github.com/fastly/compute-starter-kit-cpp-empty"), - configFile: config.File{ - StarterKits: config.StarterKitLanguages{ - CPP: skCPP, - }, - }, wantOutput: []string{ "Fetching package template", "Reading fastly.toml", @@ -554,6 +411,11 @@ func TestInit_ExistingService(t *testing.T) { expectNoManifest bool expectInError string suppressBeacon bool + // starterKitIDCheck is true when ClonedFrom is a fastly-org GitHub URL, + // which now triggers an extra HTTP call (the .starter-kit-id redirect + // lookup) before falling back to git-clone, in addition to the beacon + // notification call. + starterKitIDCheck bool }{ { name: "when the service exists", @@ -688,7 +550,8 @@ func TestInit_ExistingService(t *testing.T) { }, }, nil }, - expectInOutput: []string{"Initializing file structure from selected starter kit..."}, + expectInOutput: []string{"Initializing file structure from selected starter kit..."}, + starterKitIDCheck: true, }, { name: "service has an unreachable cloned_from value", @@ -714,7 +577,8 @@ func TestInit_ExistingService(t *testing.T) { }, }, nil }, - expectInError: "could not fetch original source code", + expectInError: "could not fetch original source code", + starterKitIDCheck: true, }, { name: "service has active version greater than 1", @@ -774,15 +638,23 @@ func TestInit_ExistingService(t *testing.T) { t.Fatal(err) } + // The body is closed by beacon.Notify. + //nolint: bodyclose + responses := []*http.Response{mock.NewHTTPResponse(http.StatusNoContent, nil, nil)} + errs := []error{nil} + if testcase.starterKitIDCheck { + // ClonePackageFromEndpoint now checks for a .starter-kit-id + // redirect marker before cloning a fastly-org repo; simulate + // "not found" so it falls through to the existing git-clone + // behavior these scenarios expect. + //nolint: bodyclose + responses = append([]*http.Response{mock.NewHTTPResponse(http.StatusNotFound, nil, io.NopCloser(strings.NewReader("")))}, responses...) + errs = append([]error{nil}, errs...) + } + httpClient := &mock.HTTPClient{ - Responses: []*http.Response{ - // The body is closed by beacon.Notify. - //nolint: bodyclose - mock.NewHTTPResponse(http.StatusNoContent, nil, nil), - }, - Errors: []error{ - nil, - }, + Responses: responses, + Errors: errs, Index: -1, SaveRequests: true, } @@ -819,8 +691,12 @@ func TestInit_ExistingService(t *testing.T) { if testcase.suppressBeacon { testutil.AssertLength(t, 0, httpClient.Requests) } else { - testutil.AssertLength(t, 1, httpClient.Requests) - beaconReq := httpClient.Requests[0] + wantRequests := 1 + if testcase.starterKitIDCheck { + wantRequests = 2 + } + testutil.AssertLength(t, wantRequests, httpClient.Requests) + beaconReq := httpClient.Requests[len(httpClient.Requests)-1] testutil.AssertEqual(t, "fastly-notification-relay.edgecompute.app", beaconReq.URL.Hostname()) } diff --git a/pkg/commands/compute/language.go b/pkg/commands/compute/language.go index e921d5537..2635b0508 100644 --- a/pkg/commands/compute/language.go +++ b/pkg/commands/compute/language.go @@ -1,12 +1,14 @@ package compute import ( - "fmt" "runtime" "sort" "strings" - "github.com/fastly/cli/pkg/config" + "github.com/blang/semver" + + "github.com/fastly/cli/pkg/revision" + "github.com/fastly/cli/pkg/starterkit" ) // NewLanguages returns a list of supported programming languages. @@ -14,7 +16,11 @@ import ( // NOTE: The 'timeout' value zero is passed into each New call as it's // only useful during the `compute build` phase and is expected to be // provided by the user via a flag on the build command. -func NewLanguages(kits config.StarterKitLanguages) []*Language { +// +// StarterKits are not populated here -- they depend on a live fetch from the +// starter-kit edge service, and are populated lazily via FetchStarterKits +// only when the interactive starter-kit prompt is actually about to run. +func NewLanguages() []*Language { // WARNING: Do not reorder these options as they affect the rendered output. // They are placed in order of language maturity/importance. // @@ -25,27 +31,22 @@ func NewLanguages(kits config.StarterKitLanguages) []*Language { NewLanguage(&LanguageOptions{ Name: "rust", DisplayName: "Rust", - StarterKits: kits.Rust, }), NewLanguage(&LanguageOptions{ Name: "javascript", DisplayName: "JavaScript", - StarterKits: kits.JavaScript, }), NewLanguage(&LanguageOptions{ Name: "go", DisplayName: "Go", - StarterKits: kits.Go, }), NewLanguage(&LanguageOptions{ Name: "cpp", DisplayName: "C++", - StarterKits: kits.CPP, }), NewLanguage(&LanguageOptions{ Name: "python", DisplayName: "Python", - StarterKits: kits.Python, }), NewLanguage(&LanguageOptions{ Name: "other", @@ -56,28 +57,10 @@ func NewLanguages(kits config.StarterKitLanguages) []*Language { // NewLanguage constructs a new Language from a LangaugeOptions. func NewLanguage(options *LanguageOptions) *Language { - // Ensure the 'default' starter kit is always first. - sort.Slice(options.StarterKits, func(i, j int) bool { - suffix := fmt.Sprintf("%s-default", options.Name) - a := strings.HasSuffix(options.StarterKits[i].Path, suffix) - b := strings.HasSuffix(options.StarterKits[j].Path, suffix) - var ( - bitSetA int8 - bitSetB int8 - ) - if a { - bitSetA = 1 - } - if b { - bitSetB = 1 - } - return bitSetA > bitSetB - }) - return &Language{ options.Name, options.DisplayName, - options.StarterKits, + nil, options.SourceDirectory, options.Toolchain, } @@ -87,7 +70,7 @@ func NewLanguage(options *LanguageOptions) *Language { type Language struct { Name string DisplayName string - StarterKits []config.StarterKit + StarterKits []starterkit.Kit SourceDirectory string Toolchain @@ -97,11 +80,75 @@ type Language struct { type LanguageOptions struct { Name string DisplayName string - StarterKits []config.StarterKit SourceDirectory string Toolchain Toolchain } +// FetchStarterKits populates l.StarterKits from the starter-kit edge +// service, filtered server-side to this language, further filtered to kits +// marked for CLI display and supported by the running CLI version, with the +// "default" kit (if present) sorted first. +func (l *Language) FetchStarterKits(client *starterkit.Client) error { + kits, err := client.Kits(l.Name) + if err != nil { + return err + } + + kits = filterByShowOnCLI(kits) + kits = filterByMinCLIVersion(kits) + + sort.Slice(kits, func(i, j int) bool { + a := kits[i].KitName() == "default" + b := kits[j].KitName() == "default" + return a && !b + }) + + l.StarterKits = kits + return nil +} + +// filterByShowOnCLI drops kits not marked catalog.show_on_cli. The edge +// service is also asked to filter server-side (Client.Kits passes ?cli=true), +// but we don't rely on that alone since Kit already carries this field. +func filterByShowOnCLI(kits []starterkit.Kit) []starterkit.Kit { + filtered := make([]starterkit.Kit, 0, len(kits)) + for _, kit := range kits { + if kit.Catalog.ShowOnCLI { + filtered = append(filtered, kit) + } + } + return filtered +} + +// filterByMinCLIVersion drops kits whose catalog.min_cli_version exceeds the +// running CLI's version. Kits with an unparseable or missing +// min_cli_version, or a running CLI version that itself can't be parsed +// (e.g. local dev builds without version info baked in via LDFLAGS), are +// kept rather than hidden. +func filterByMinCLIVersion(kits []starterkit.Kit) []starterkit.Kit { + // revision.None is the AppVersion for local/dev builds without LDFLAGS + // version info baked in -- treat it the same as "unknown", not as a real + // (very old) version, otherwise every kit with a min_cli_version would be + // hidden for anyone running from source. + if revision.AppVersion == revision.None { + return kits + } + + current, err := semver.Parse(strings.TrimPrefix(revision.AppVersion, "v")) + if err != nil { + return kits + } + + filtered := make([]starterkit.Kit, 0, len(kits)) + for _, kit := range kits { + minVersion, err := semver.Parse(strings.TrimPrefix(kit.Catalog.MinCLIVersion, "v")) + if kit.Catalog.MinCLIVersion == "" || err != nil || !current.LT(minVersion) { + filtered = append(filtered, kit) + } + } + return filtered +} + // Shell represents a subprocess shell used by `compute` environment where // `[scripts.build]` has been defined within fastly.toml manifest. type Shell struct{} diff --git a/pkg/commands/compute/language_test.go b/pkg/commands/compute/language_test.go index 38b7cb795..d011017a8 100644 --- a/pkg/commands/compute/language_test.go +++ b/pkg/commands/compute/language_test.go @@ -1,40 +1,114 @@ -package compute_test +package compute import ( + "io" + "net/http" + "strings" "testing" - toml "github.com/pelletier/go-toml" - - "github.com/fastly/cli/pkg/commands/compute" - "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/revision" + "github.com/fastly/cli/pkg/starterkit" ) -// TestStaticConfigStarterKits validates that every language the CLI offers at -// the `compute init` prompt has at least one starter kit in the static config -// embedded into the binary. -// -// The starter kits are injected into the static config at build time by -// ./scripts/config.sh, which holds its own hardcoded list of starter kit -// repositories. Adding a language without also updating that list leaves users -// of the new language unable to init a project (see CDTOOL-1707), and this test -// is here to catch that drift. -// -// NOTE: The static config is generated, not committed, so run `make config` -// before running this test locally. -func TestStaticConfigStarterKits(t *testing.T) { - var f config.File - if err := toml.Unmarshal(config.Static, &f); err != nil { - t.Fatalf("failed to unmarshal the static config: %s", err) +func TestFilterByShowOnCLI(t *testing.T) { + kits := []starterkit.Kit{ + {ID: "rust-default", Catalog: starterkit.Catalog{ShowOnCLI: true}}, + {ID: "rust-internal-only", Catalog: starterkit.Catalog{ShowOnCLI: false}}, + {ID: "rust-unset"}, + } + + got := filterByShowOnCLI(kits) + if len(got) != 1 { + t.Fatalf("want 1 kit, have %d", len(got)) } + if got[0].ID != "rust-default" { + t.Errorf("want rust-default to survive filtering, have %q", got[0].ID) + } +} + +func TestFilterByMinCLIVersion(t *testing.T) { + kits := []starterkit.Kit{ + {ID: "rust-default", Language: "rust", Catalog: starterkit.Catalog{MinCLIVersion: "16.0.0"}}, + {ID: "rust-old", Language: "rust", Catalog: starterkit.Catalog{MinCLIVersion: "1.0.0"}}, + {ID: "rust-no-min", Language: "rust"}, + {ID: "rust-bad-min", Language: "rust", Catalog: starterkit.Catalog{MinCLIVersion: "not-a-version"}}, + } + + t.Run("dev build (revision.None) keeps everything", func(t *testing.T) { + orig := revision.AppVersion + revision.AppVersion = revision.None + defer func() { revision.AppVersion = orig }() + + got := filterByMinCLIVersion(kits) + if len(got) != len(kits) { + t.Fatalf("want %d kits, have %d", len(kits), len(got)) + } + }) + + t.Run("real version filters out kits requiring a newer CLI", func(t *testing.T) { + orig := revision.AppVersion + revision.AppVersion = "v15.4.0" + defer func() { revision.AppVersion = orig }() - for _, language := range compute.NewLanguages(f.StarterKits) { - // The 'other' language is for users bringing their own Wasm binary and so - // has no starter kits by design. - if language.Name == "other" { - continue + got := filterByMinCLIVersion(kits) + if len(got) != 3 { + t.Fatalf("want 3 kits, have %d", len(got)) } - if len(language.StarterKits) == 0 { - t.Errorf("no starter kits found in the static config for language %q: add its starter kit repositories to the 'kits' list in ./scripts/config.sh", language.Name) + for _, id := range []string{"rust-old", "rust-no-min", "rust-bad-min"} { + found := false + for _, k := range got { + if k.ID == id { + found = true + } + } + if !found { + t.Errorf("expected kit %q to survive filtering", id) + } } + for _, k := range got { + if k.ID == "rust-default" { + t.Errorf("expected rust-default (min_cli_version 16.0.0) to be filtered out") + } + } + }) + + t.Run("unparseable running version keeps everything", func(t *testing.T) { + orig := revision.AppVersion + revision.AppVersion = "not-a-version" + defer func() { revision.AppVersion = orig }() + + got := filterByMinCLIVersion(kits) + if len(got) != len(kits) { + t.Fatalf("want %d kits, have %d", len(kits), len(got)) + } + }) +} + +func TestLanguageFetchStarterKits(t *testing.T) { + orig := revision.AppVersion + revision.AppVersion = revision.None // avoid min_cli_version filtering noise in this test + defer func() { revision.AppVersion = orig }() + + body := `{"generated_at":"2026-07-13T00:00:00Z","kits":[ + {"id":"rust-websockets","name":"WebSockets","language":"rust","description":"desc","catalog":{"show_on_cli":true}}, + {"id":"rust-default","name":"Default","language":"rust","description":"desc","catalog":{"show_on_cli":true}}, + {"id":"rust-internal-only","name":"Internal","language":"rust","description":"desc","catalog":{"show_on_cli":false}} + ]}` + res := mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader(body))) + httpClient := mock.NewHTTPClientWithResponses([]*http.Response{res}) + client := starterkit.New("https://example.com", httpClient, false) + + lang := NewLanguage(&LanguageOptions{Name: "rust", DisplayName: "Rust"}) + if err := lang.FetchStarterKits(client); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(lang.StarterKits) != 2 { + t.Fatalf("want 2 starter kits, have %d", len(lang.StarterKits)) + } + // The "default" kit should be sorted first regardless of manifest order. + if got := lang.StarterKits[0].KitName(); got != "default" { + t.Errorf("want first starter kit to be %q, have %q", "default", got) } } diff --git a/pkg/commands/compute/starterkit_redirect.go b/pkg/commands/compute/starterkit_redirect.go new file mode 100644 index 000000000..1fe2e3deb --- /dev/null +++ b/pkg/commands/compute/starterkit_redirect.go @@ -0,0 +1,72 @@ +package compute + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/starterkit" +) + +// starterKitIDFile is the well-known marker file a legacy +// fastly/compute-starter-kit-* repo may contain at its root, redirecting +// `compute init` to the new starter-kit edge service instead of cloning the +// legacy repo directly. +const starterKitIDFile = ".starter-kit-id" + +// starterKitRedirect checks whether the given (Fastly-owned) GitHub repo URL +// has a root-level .starter-kit-id marker file, and if so, parses its +// content as a "starter-kit//" reference. +// +// Any failure along the way (the repo URL doesn't parse into an org/repo, the +// marker file is missing or unreadable, the request errors, or the content +// doesn't parse) is treated identically: ok is false, and the caller should +// fall back to cloning the legacy repo directly. +func starterKitRedirect(httpClient api.HTTPClient, repoURL string) (lang, name string, ok bool) { + org, repo, ok := githubOrgRepo(repoURL) + if !ok { + return "", "", false + } + + rawURL := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/HEAD/%s", org, repo, starterKitIDFile) + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return "", "", false + } + + res, err := httpClient.Do(req) + if err != nil { + return "", "", false + } + defer res.Body.Close() // #nosec G307 + + if res.StatusCode != http.StatusOK { + return "", "", false + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return "", "", false + } + + return starterkit.ParseFrom(strings.TrimSpace(string(body))) +} + +// githubOrgRepo extracts the "org" and "repo" path segments from a +// github.com repository URL, stripping any ".git" suffix from the repo name. +func githubOrgRepo(repoURL string) (org, repo string, ok bool) { + u, err := url.Parse(repoURL) + if err != nil { + return "", "", false + } + + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + + return parts[0], strings.TrimSuffix(parts[1], ".git"), true +} diff --git a/pkg/commands/compute/starterkit_redirect_test.go b/pkg/commands/compute/starterkit_redirect_test.go new file mode 100644 index 000000000..9770255b9 --- /dev/null +++ b/pkg/commands/compute/starterkit_redirect_test.go @@ -0,0 +1,146 @@ +package compute + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/fastly/cli/pkg/mock" +) + +func TestGithubOrgRepo(t *testing.T) { + for _, testcase := range []struct { + name string + input string + wantOrg string + wantRepo string + wantOK bool + }{ + { + name: "plain", + input: "https://github.com/fastly/compute-starter-kit-rust-default", + wantOrg: "fastly", + wantRepo: "compute-starter-kit-rust-default", + wantOK: true, + }, + { + name: "with .git suffix", + input: "https://github.com/fastly/compute-starter-kit-rust-default.git", + wantOrg: "fastly", + wantRepo: "compute-starter-kit-rust-default", + wantOK: true, + }, + { + name: "with trailing slash", + input: "https://github.com/fastly/compute-starter-kit-rust-default/", + wantOrg: "fastly", + wantRepo: "compute-starter-kit-rust-default", + wantOK: true, + }, + { + name: "too few path segments", + input: "https://github.com/fastly", + wantOK: false, + }, + { + name: "too many path segments", + input: "https://github.com/fastly/foo/archive/refs/heads/main.zip", + wantOK: false, + }, + { + name: "unparseable", + input: "://not a url", + wantOK: false, + }, + } { + t.Run(testcase.name, func(t *testing.T) { + org, repo, ok := githubOrgRepo(testcase.input) + if ok != testcase.wantOK { + t.Fatalf("ok: want %v, have %v", testcase.wantOK, ok) + } + if testcase.wantOK { + if org != testcase.wantOrg { + t.Errorf("org: want %q, have %q", testcase.wantOrg, org) + } + if repo != testcase.wantRepo { + t.Errorf("repo: want %q, have %q", testcase.wantRepo, repo) + } + } + }) + } +} + +func TestStarterKitRedirect(t *testing.T) { + for _, testcase := range []struct { + name string + repoURL string + status int + body string + err error + wantLang string + wantName string + wantOK bool + }{ + { + name: "marker file present and valid", + repoURL: "https://github.com/fastly/compute-starter-kit-typescript-default", + status: http.StatusOK, + body: "starter-kit/javascript/typescript-default\n", + wantLang: "javascript", + wantName: "typescript-default", + wantOK: true, + }, + { + name: "marker file absent (404)", + repoURL: "https://github.com/fastly/compute-starter-kit-rust-empty", + status: http.StatusNotFound, + body: "", + wantOK: false, + }, + { + name: "marker file present but malformed", + repoURL: "https://github.com/fastly/compute-starter-kit-rust-empty", + status: http.StatusOK, + body: "not-a-valid-reference", + wantOK: false, + }, + { + name: "network error", + repoURL: "https://github.com/fastly/compute-starter-kit-rust-empty", + err: io.ErrUnexpectedEOF, + wantOK: false, + }, + { + name: "repo URL doesn't parse into org/repo", + repoURL: "https://github.com/fastly", + wantOK: false, + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var httpClient *mock.HTTPClient + switch { + case testcase.err != nil: + httpClient = mock.NewHTTPClientWithErrors([]error{testcase.err}) + case testcase.status != 0: + res := mock.NewHTTPResponse(testcase.status, nil, io.NopCloser(strings.NewReader(testcase.body))) + httpClient = mock.NewHTTPClientWithResponses([]*http.Response{res}) + default: + httpClient = mock.NewHTTPClientWithResponses(nil) + } + + lang, name, ok := starterKitRedirect(httpClient, testcase.repoURL) + if ok != testcase.wantOK { + t.Fatalf("ok: want %v, have %v", testcase.wantOK, ok) + } + if testcase.wantOK { + if lang != testcase.wantLang { + t.Errorf("lang: want %q, have %q", testcase.wantLang, lang) + } + if name != testcase.wantName { + t.Errorf("name: want %q, have %q", testcase.wantName, name) + } + } + }) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index f6eeabb80..ded90c38d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -187,24 +187,6 @@ type Profile struct { Token string `toml:"token" json:"token"` } -// StarterKitLanguages represents language specific starter kits. -type StarterKitLanguages struct { - CPP []StarterKit `toml:"cpp"` - Go []StarterKit `toml:"go"` - JavaScript []StarterKit `toml:"javascript"` - Rust []StarterKit `toml:"rust"` - Python []StarterKit `toml:"python"` -} - -// StarterKit represents starter kit specific configuration. -type StarterKit struct { - Name string `toml:"name"` - Description string `toml:"description"` - Path string `toml:"path"` - Tag string `toml:"tag"` - Branch string `toml:"branch"` -} - // ensureConfigDirExists creates the application configuration directory if it // doesn't already exist. func ensureConfigDirExists(path string) error { @@ -226,8 +208,6 @@ type File struct { Language Language `toml:"language"` // Profiles represents legacy profile accounts (migrated to [auth]). Profiles Profiles `toml:"profile,omitempty"` - // StarterKitLanguages represents language specific starter kits. - StarterKits StarterKitLanguages `toml:"starter-kits"` // Viceroy represents viceroy specific configuration. Viceroy Versioner `toml:"viceroy"` // WasmMetadata represents what metadata will be collected. diff --git a/pkg/file/archive.go b/pkg/file/archive.go index d14eafd8a..633bd88b4 100644 --- a/pkg/file/archive.go +++ b/pkg/file/archive.go @@ -156,26 +156,21 @@ func (a ArchiveBase) Extract() error { return err } - if _, err := os.Stat("fastly.toml"); err == nil { + if _, err := os.Stat(filepath.Join(a.Dst, "fastly.toml")); err == nil { return nil } // Looks like the package files are contained within a top-level directory // that now need to be extracted. - wd, err := os.Getwd() - if err != nil { - return fmt.Errorf("error determining current directory: %w", err) - } - var dirContentToMove string - err = filepath.WalkDir(wd, func(path string, entry fs.DirEntry, err error) error { + err := filepath.WalkDir(a.Dst, func(path string, entry fs.DirEntry, err error) error { // WalkDir() triggered an error if err != nil { return err } - // We already check if the current directory had a manifest so skip it - if entry.IsDir() && path == wd { + // We already check if the destination directory had a manifest so skip it + if entry.IsDir() && path == a.Dst { return nil } // We expect there to be a directory that contains the manifest diff --git a/pkg/starterkit/doc.go b/pkg/starterkit/doc.go new file mode 100644 index 000000000..1837c8eca --- /dev/null +++ b/pkg/starterkit/doc.go @@ -0,0 +1,4 @@ +// Package starterkit contains a client for the Fastly Compute starter-kit +// edge service, which serves the manifest and tarball archives for Compute +// starter kit templates. +package starterkit diff --git a/pkg/starterkit/starterkit.go b/pkg/starterkit/starterkit.go new file mode 100644 index 000000000..786c68068 --- /dev/null +++ b/pkg/starterkit/starterkit.go @@ -0,0 +1,142 @@ +package starterkit + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/debug" +) + +// DefaultEndpoint is the base URL of the starter-kit edge service. +const DefaultEndpoint = "https://compute-starter-kits.fastly.dev" + +// Catalog represents the catalog-specific metadata of a starter kit, i.e. how +// it should be surfaced to end users (docs site, CLI, etc). +type Catalog struct { + ShowOnDocs bool `json:"show_on_docs"` + ShowOnCLI bool `json:"show_on_cli"` + Tags []string `json:"tags"` + Topics []string `json:"topics"` + MinCLIVersion string `json:"min_cli_version"` + Slug string `json:"slug"` +} + +// Kit represents a single starter kit entry in the manifest. +type Kit struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Language string `json:"language"` + Description string `json:"description"` + Catalog Catalog `json:"catalog"` +} + +// KitName returns the kit-specific portion of the ID (the ID with the +// language prefix removed). This is safe to derive because the language is +// known explicitly from the Language field, unlike guessing a split point +// from the number of hyphens in the ID. +func (k Kit) KitName() string { + return strings.TrimPrefix(k.ID, k.Language+"-") +} + +// FromValue returns the canonical --from value that resolves to this kit, +// e.g. "starter-kit/javascript/typescript-default". +func (k Kit) FromValue() string { + return "starter-kit/" + k.Language + "/" + k.KitName() +} + +// Manifest represents the full /kits response. +type Manifest struct { + GeneratedAt string `json:"generated_at"` + Kits []Kit `json:"kits"` +} + +// Client is a client for the starter-kit edge service. +type Client struct { + endpoint string + httpClient api.HTTPClient + debug bool +} + +// New returns a usable Client. +func New(endpoint string, httpClient api.HTTPClient, debugMode bool) *Client { + return &Client{ + endpoint: strings.TrimSuffix(endpoint, "/"), + httpClient: httpClient, + debug: debugMode, + } +} + +// Kits fetches the starter-kit manifest, filtered server-side to kits with +// catalog.show_on_cli set. If lang is non-empty, results are additionally +// filtered server-side to that language. +func (c *Client) Kits(lang string) ([]Kit, error) { + q := url.Values{} + q.Set("cli", "true") + if lang != "" { + q.Set("lang", lang) + } + + reqURL := c.endpoint + "/kits?" + q.Encode() + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to construct starter kit manifest request: %w", err) + } + + if c.debug { + debug.DumpHTTPRequest(req) + } + res, err := c.httpClient.Do(req) + if c.debug { + debug.DumpHTTPResponse(res) + } + if err != nil { + return nil, fmt.Errorf("failed to fetch starter kit manifest: %w", err) + } + defer res.Body.Close() // #nosec G307 + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch starter kit manifest '%s': %s", reqURL, res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("failed to read starter kit manifest response: %w", err) + } + + var manifest Manifest + if err := json.Unmarshal(body, &manifest); err != nil { + return nil, fmt.Errorf("failed to parse starter kit manifest: %w", err) + } + + return manifest.Kits, nil +} + +// TarballURL builds the tarball download URL for the given language/kit-name +// pair. It performs no HTTP request. +func (c *Client) TarballURL(lang, name string) string { + return fmt.Sprintf("%s/kits/%s/%s/tarball", c.endpoint, lang, name) +} + +// ParseFrom parses a --from value of the form "starter-kit//" +// into its (lang, name) parts. Any other shape (missing prefix, wrong number +// of segments, empty segments) returns ok == false. +func ParseFrom(from string) (lang, name string, ok bool) { + const prefix = "starter-kit/" + if !strings.HasPrefix(from, prefix) { + return "", "", false + } + + rest := strings.TrimPrefix(from, prefix) + parts := strings.Split(rest, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + + return parts[0], parts[1], true +} diff --git a/pkg/starterkit/starterkit_test.go b/pkg/starterkit/starterkit_test.go new file mode 100644 index 000000000..0b1f9eb2a --- /dev/null +++ b/pkg/starterkit/starterkit_test.go @@ -0,0 +1,139 @@ +package starterkit_test + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/starterkit" + "github.com/fastly/cli/pkg/testutil" +) + +func TestParseFrom(t *testing.T) { + for _, testcase := range []struct { + name string + input string + wantLang string + wantName string + wantOK bool + }{ + { + name: "valid", + input: "starter-kit/javascript/typescript-default", + wantLang: "javascript", + wantName: "typescript-default", + wantOK: true, + }, + { + name: "valid with hyphenated kit name", + input: "starter-kit/rust/connect-google-bigquery", + wantLang: "rust", + wantName: "connect-google-bigquery", + wantOK: true, + }, + { + name: "missing prefix", + input: "javascript/typescript-default", + wantOK: false, + }, + { + name: "just the prefix", + input: "starter-kit/", + wantOK: false, + }, + { + name: "missing name segment", + input: "starter-kit/javascript", + wantOK: false, + }, + { + name: "too many segments", + input: "starter-kit/javascript/typescript-default/extra", + wantOK: false, + }, + { + name: "empty lang segment", + input: "starter-kit//typescript-default", + wantOK: false, + }, + { + name: "empty name segment", + input: "starter-kit/javascript/", + wantOK: false, + }, + { + name: "plain github url", + input: "https://github.com/fastly/compute-starter-kit-rust-default", + wantOK: false, + }, + } { + t.Run(testcase.name, func(t *testing.T) { + lang, name, ok := starterkit.ParseFrom(testcase.input) + testutil.AssertBool(t, testcase.wantOK, ok) + if testcase.wantOK { + testutil.AssertString(t, testcase.wantLang, lang) + testutil.AssertString(t, testcase.wantName, name) + } + }) + } +} + +func TestKitFromValue(t *testing.T) { + kit := starterkit.Kit{ + ID: "javascript-typescript-kv-store", + Language: "javascript", + } + testutil.AssertString(t, "typescript-kv-store", kit.KitName()) + testutil.AssertString(t, "starter-kit/javascript/typescript-kv-store", kit.FromValue()) +} + +func TestClientTarballURL(t *testing.T) { + c := starterkit.New("https://example.com/", nil, false) + testutil.AssertString(t, "https://example.com/kits/javascript/typescript-default/tarball", c.TarballURL("javascript", "typescript-default")) +} + +func TestClientKits(t *testing.T) { + for _, testcase := range []struct { + name string + status int + body string + wantErr string + wantCount int + }{ + { + name: "success", + status: 200, + body: `{"generated_at":"2026-07-13T00:00:00Z","kits":[{"id":"javascript-typescript-default","name":"TypeScript","language":"javascript","description":"desc","catalog":{"show_on_cli":true,"min_cli_version":"16.0.0"}}]}`, + wantCount: 1, + }, + { + name: "non-200", + status: 500, + body: "", + wantErr: "failed to fetch starter kit manifest", + }, + { + name: "malformed json", + status: 200, + body: "not json", + wantErr: "failed to parse starter kit manifest", + }, + } { + t.Run(testcase.name, func(t *testing.T) { + res := mock.NewHTTPResponse(testcase.status, nil, io.NopCloser(strings.NewReader(testcase.body))) + httpClient := mock.HTMLClient([]*http.Response{res}, []error{nil}) + + c := starterkit.New("https://example.com", httpClient, false) + kits, err := c.Kits("") + + if testcase.wantErr != "" { + testutil.AssertErrorContains(t, err, testcase.wantErr) + return + } + testutil.AssertNoError(t, err) + testutil.AssertLength(t, testcase.wantCount, kits) + }) + } +} diff --git a/scripts/config.sh b/scripts/config.sh index 667272085..0ef8f6d39 100755 --- a/scripts/config.sh +++ b/scripts/config.sh @@ -3,43 +3,3 @@ set -e cp ".fastly/config.toml" "pkg/config/config.toml" - -if ! command -v tq &> /dev/null -then - cargo install tomlq -fi - -kits=( - compute-starter-kit-go-default - compute-starter-kit-go-tinygo - compute-starter-kit-javascript-default - compute-starter-kit-javascript-empty - compute-starter-kit-rust-default - compute-starter-kit-rust-empty - compute-starter-kit-rust-static-content - compute-starter-kit-rust-websockets - compute-starter-kit-typescript - compute-starter-kit-cpp-default - compute-starter-kit-cpp-empty - compute-starter-kit-python-default -) - -function parse() { - tq -r -f "$k.toml" $1 -} - -function append() { - echo $1 >>pkg/config/config.toml -} - -for k in ${kits[@]}; do - curl -s "https://raw.githubusercontent.com/fastly/$k/main/fastly.toml" -o "$k.toml" - - append '' - append "[[starter-kits.$(parse language)]]" - append "description = \"$(parse description)\"" - append "name = \"$(parse name)\"" - append "path = \"https://github.com/fastly/$k\"" - - rm "$k.toml" -done From c6cf19dd74fb6786f407a77ee4da1cbf35c6f2c3 Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Wed, 15 Jul 2026 03:12:53 +0900 Subject: [PATCH 2/7] Add changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87ffdb1cb..d0a6f70fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### Enhancements: +- feat(compute/init): Source starter kits from service ([#1846](https://github.com/fastly/cli/pull/1846)) + ### Dependencies: ## [v15.6.0](https://github.com/fastly/cli/releases/tag/v15.6.0) (2026-08-07) From 6fc41fa891d755625878c8171718384025161094 Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Wed, 15 Jul 2026 14:46:22 +0900 Subject: [PATCH 3/7] Update docs --- pkg/app/metadata.json | 5 +++++ pkg/commands/compute/init.go | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/app/metadata.json b/pkg/app/metadata.json index 302994d6d..f64d19293 100644 --- a/pkg/app/metadata.json +++ b/pkg/app/metadata.json @@ -303,6 +303,11 @@ "description": "To initialize a new Compute package you must select a supported language. The language can be provided using the optional `--language` flag, which supports tab completion hints, or the flag can be omitted and you'll be prompted interactively. The `--name` flag can also be omitted, which will result in the CLI prompting you interactively.", "title": "Initialize a new Compute package locally" }, + { + "cmd": "fastly compute init --from=starter-kits/javascript/typescript-default", + "description": "Use a [Compute starter kit](https://www.fastly.com/documentation/solutions/starters) as the source for your new package.", + "title": "Initialize a new Compute package locally using a Compute Starter Kit" + }, { "cmd": "fastly compute init --from=https://fiddle.fastly.dev/fiddle/0220c0d2", "description": "Any [Compute examples](https://www.fastly.com/documentation/solutions/examples) can be used as a source template for your new package.", diff --git a/pkg/commands/compute/init.go b/pkg/commands/compute/init.go index 9c6e356ea..658ef94eb 100644 --- a/pkg/commands/compute/init.go +++ b/pkg/commands/compute/init.go @@ -68,7 +68,7 @@ func NewInitCommand(parent argparser.Registerer, g *global.Data) *InitCommand { c.CmdClause.Flag("author", "Author(s) of the package").Short('a').StringsVar(&g.Manifest.File.Authors) c.CmdClause.Flag("branch", "Git branch name to clone from package template repository").Hidden().StringVar(&c.branch) c.CmdClause.Flag("directory", "Destination to write the new package, defaulting to the current directory").Short('p').StringVar(&c.dir) - c.CmdClause.Flag("from", "Local project directory, or Git repository URL, or URL referencing a .zip/.tar.gz file, containing a package template, or an existing service ID created from a starter kit").Short('f').StringVar(&c.CloneFrom) + c.CmdClause.Flag("from", "one of: (a) a starter kit identifier (starter-kit//); (b) a local project directory, a Git repository URL, or a URL referencing a .zip/.tar.gz file containing a package template; or (c) the service ID of a service created from a starter kit").Short('f').StringVar(&c.CloneFrom) c.CmdClause.Flag("language", "Language of the package").Short('l').HintOptions(Languages...).EnumVar(&c.language, Languages...) c.CmdClause.Flag("tag", "Git tag name to clone from package template repository").Hidden().StringVar(&c.tag) From 3079bb49bd0ef3438ff0f4ee9396caa478b7d573 Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Tue, 21 Jul 2026 14:47:04 +0900 Subject: [PATCH 4/7] Record `cloned_from` as a browsable link into the kit's source --- CHANGELOG.md | 2 +- pkg/commands/compute/init.go | 28 ++++++--- pkg/commands/compute/init_test.go | 76 +++++++++++++++++++++- pkg/starterkit/starterkit.go | 44 ++++++++++++- pkg/starterkit/starterkit_test.go | 101 +++++++++++++++++++++++++++++- 5 files changed, 238 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0a6f70fb..40d3b367d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Enhancements: -- feat(compute/init): Source starter kits from service ([#1846](https://github.com/fastly/cli/pull/1846)) +- feat(compute/init): Source starter kits from service, recording `cloned_from` as a browsable link into the kit's source ([#1846](https://github.com/fastly/cli/pull/1846)) ### Dependencies: diff --git a/pkg/commands/compute/init.go b/pkg/commands/compute/init.go index 658ef94eb..2fa45790e 100644 --- a/pkg/commands/compute/init.go +++ b/pkg/commands/compute/init.go @@ -872,10 +872,16 @@ func (c *InitCommand) FetchPackageTemplate(branch, tag string, archives []file.A msg := "Fetching package template" spinner.Message(msg + "...") - // If --from is of the form starter-kit//, resolve it directly - // against the starter-kit edge service, bypassing local-dir/URL/git - // detection entirely. - if lang, name, ok := starterkit.ParseFrom(c.CloneFrom); ok { + // If --from is of the form starter-kit//, or a browsable URL + // into the compute-starter-kits monorepo (see starterkit.ParseSourceURL, + // e.g. as round-tripped through a previous run's cloned_from), resolve it + // directly against the starter-kit edge service, bypassing + // local-dir/URL/git detection entirely. + lang, name, ok := starterkit.ParseFrom(c.CloneFrom) + if !ok { + lang, name, ok = starterkit.ParseSourceURL(c.CloneFrom) + } + if ok { client := starterkit.New(starterkit.DefaultEndpoint, c.Globals.HTTPClient, c.Globals.Flags.Debug) if err := c.fetchAndExtractTarball(client.TarballURL(lang, name), archives); err != nil { spinner.StopFailMessage(msg) @@ -1248,9 +1254,17 @@ mimes: // repos transparently redirect any tool honoring the convention to the new // monorepo-backed source of truth, without the repos themselves being cloned. func (c *InitCommand) ClonePackageFromEndpoint(from, branch, tag string) error { + client := starterkit.New(starterkit.DefaultEndpoint, c.Globals.HTTPClient, c.Globals.Flags.Debug) + + // If from is a browsable link into the compute-starter-kits monorepo + // (the canonical --from/cloned_from value for a kit, see Kit.FromValue), + // fetch the kit tarball directly rather than cloning the whole monorepo. + if lang, name, ok := starterkit.ParseSourceURL(from); ok { + return c.fetchAndExtractTarball(client.TarballURL(lang, name), file.Archives) + } + if fastlyOrgRegEx.MatchString(from) { if lang, name, ok := starterKitRedirect(c.Globals.HTTPClient, from); ok { - client := starterkit.New(starterkit.DefaultEndpoint, c.Globals.HTTPClient, c.Globals.Flags.Debug) return c.fetchAndExtractTarball(client.TarballURL(lang, name), file.Archives) } } @@ -1374,7 +1388,7 @@ func (c *InitCommand) UpdateManifest(m manifest.File, spinner text.Spinner, name m.Description = desc m.Authors = authors m.Language = language.Name - m.ClonedFrom = c.CloneFrom + m.ClonedFrom = starterkit.CanonicalSourceURL(c.CloneFrom) if err := m.Write(mp); err != nil { return fmt.Errorf("error saving fastly.toml: %w", err) } @@ -1435,7 +1449,7 @@ func (c *InitCommand) UpdateManifest(m manifest.File, spinner text.Spinner, name } } - m.ClonedFrom = c.CloneFrom + m.ClonedFrom = starterkit.CanonicalSourceURL(c.CloneFrom) err = spinner.Process("Saving manifest changes", func(_ *text.SpinnerWrapper) error { if err := m.Write(mp); err != nil { diff --git a/pkg/commands/compute/init_test.go b/pkg/commands/compute/init_test.go index 19740c7d6..2b3f775a2 100644 --- a/pkg/commands/compute/init_test.go +++ b/pkg/commands/compute/init_test.go @@ -1,6 +1,9 @@ package compute_test import ( + "archive/tar" + "bytes" + "compress/gzip" "context" "errors" "io" @@ -23,6 +26,36 @@ import ( "github.com/fastly/cli/pkg/threadsafe" ) +// buildTestTarballGz returns the bytes of a minimal tar.gz archive +// containing a single file, for use as a fake starter-kit tarball response. +func buildTestTarballGz(t *testing.T) []byte { + t.Helper() + + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + content := []byte("name = \"empty\"\nlanguage = \"rust\"\nmanifest_version = 3\n") + if err := tw.WriteHeader(&tar.Header{ + Name: "fastly.toml", + Mode: 0o644, + Size: int64(len(content)), + }); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gw.Close(); err != nil { + t.Fatal(err) + } + + return buf.Bytes() +} + func TestInit(t *testing.T) { args := testutil.SplitArgs if os.Getenv("TEST_COMPUTE_INIT") == "" { @@ -416,6 +449,11 @@ func TestInit_ExistingService(t *testing.T) { // lookup) before falling back to git-clone, in addition to the beacon // notification call. starterKitIDCheck bool + // tarballFromMonorepo is true when ClonedFrom is a compute-starter-kits + // monorepo URL (see starterkit.ParseSourceURL), which fetches a kit + // tarball directly instead of falling back to git-clone, in addition + // to the beacon notification call. + tarballFromMonorepo bool }{ { name: "when the service exists", @@ -553,6 +591,33 @@ func TestInit_ExistingService(t *testing.T) { expectInOutput: []string{"Initializing file structure from selected starter kit..."}, starterKitIDCheck: true, }, + { + name: "service has a cloned_from value pointing into the compute-starter-kits monorepo", + args: testutil.SplitArgs("compute init --from LsyQ2UXDGk6d4ENjvgqTN4"), + getServiceDetails: func(_ context.Context, _ *fastly.GetServiceDetailsInput) (*fastly.ServiceDetail, error) { + return &fastly.ServiceDetail{ + ServiceID: serviceID, + Name: fastly.NullString("cloned-service"), + Comment: fastly.NullString(""), + Type: fastly.NullString("wasm"), + ActiveVersion: &fastly.Version{ + Number: fastly.ToPointer(1), + }, + }, nil + }, + getPackage: func(_ context.Context, _ *fastly.GetPackageInput) (*fastly.Package, error) { + return &fastly.Package{ + ServiceID: serviceID, + PackageID: fastly.NullString("hVPTrHgswnF5KFwFKoQz1f"), + Metadata: &fastly.PackageMetadata{ + ClonedFrom: fastly.ToPointer("https://github.com/fastly/compute-starter-kits/tree/main/starter-kits/rust/empty"), + Language: fastly.ToPointer("rust"), + }, + }, nil + }, + expectInOutput: []string{"Initializing file structure from selected starter kit..."}, + tarballFromMonorepo: true, + }, { name: "service has an unreachable cloned_from value", args: testutil.SplitArgs("compute init --from LsyQ2UXDGk6d4ENjvgqTN4"), @@ -651,6 +716,15 @@ func TestInit_ExistingService(t *testing.T) { responses = append([]*http.Response{mock.NewHTTPResponse(http.StatusNotFound, nil, io.NopCloser(strings.NewReader("")))}, responses...) errs = append([]error{nil}, errs...) } + if testcase.tarballFromMonorepo { + // ClonePackageFromEndpoint recognizes the cloned_from URL as a + // compute-starter-kits monorepo link and fetches the kit + // tarball directly instead of git-cloning. + tarball := buildTestTarballGz(t) + //nolint: bodyclose + responses = append([]*http.Response{mock.NewHTTPResponse(http.StatusOK, map[string]string{"Content-Type": "application/gzip"}, io.NopCloser(bytes.NewReader(tarball)))}, responses...) + errs = append([]error{nil}, errs...) + } httpClient := &mock.HTTPClient{ Responses: responses, @@ -692,7 +766,7 @@ func TestInit_ExistingService(t *testing.T) { testutil.AssertLength(t, 0, httpClient.Requests) } else { wantRequests := 1 - if testcase.starterKitIDCheck { + if testcase.starterKitIDCheck || testcase.tarballFromMonorepo { wantRequests = 2 } testutil.AssertLength(t, wantRequests, httpClient.Requests) diff --git a/pkg/starterkit/starterkit.go b/pkg/starterkit/starterkit.go index 786c68068..5bbc47420 100644 --- a/pkg/starterkit/starterkit.go +++ b/pkg/starterkit/starterkit.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "regexp" "strings" "github.com/fastly/cli/pkg/api" @@ -15,6 +16,42 @@ import ( // DefaultEndpoint is the base URL of the starter-kit edge service. const DefaultEndpoint = "https://compute-starter-kits.fastly.dev" +// RepoURL is the GitHub URL of the monorepo backing the starter-kit edge +// service. Kits live under it at "starter-kits//". +const RepoURL = "https://github.com/fastly/compute-starter-kits" + +// sourceURLRegEx matches a URL previously returned by sourceURL, e.g. +// "https://github.com/fastly/compute-starter-kits/tree/main/starter-kits/javascript/default". +var sourceURLRegEx = regexp.MustCompile(`^https://github\.com/fastly/compute-starter-kits/tree/[^/]+/starter-kits/([^/]+)/([^/]+)/?$`) + +// sourceURL builds the canonical, browsable GitHub URL for the given +// language/kit-name pair, on the monorepo's default branch. +func sourceURL(lang, name string) string { + return fmt.Sprintf("%s/tree/main/starter-kits/%s/%s", RepoURL, lang, name) +} + +// ParseSourceURL parses a URL previously returned by sourceURL (i.e. Kit.FromValue +// or CanonicalSourceURL) back into its (lang, name) parts. Any other shape +// returns ok == false. +func ParseSourceURL(u string) (lang, name string, ok bool) { + m := sourceURLRegEx.FindStringSubmatch(u) + if m == nil { + return "", "", false + } + return m[1], m[2], true +} + +// CanonicalSourceURL returns the canonical GitHub URL for a --from value of +// the form "starter-kit//" (see ParseFrom). If from doesn't match +// that shape, it's returned unchanged. +func CanonicalSourceURL(from string) string { + lang, name, ok := ParseFrom(from) + if !ok { + return from + } + return sourceURL(lang, name) +} + // Catalog represents the catalog-specific metadata of a starter kit, i.e. how // it should be surfaced to end users (docs site, CLI, etc). type Catalog struct { @@ -44,10 +81,11 @@ func (k Kit) KitName() string { return strings.TrimPrefix(k.ID, k.Language+"-") } -// FromValue returns the canonical --from value that resolves to this kit, -// e.g. "starter-kit/javascript/typescript-default". +// FromValue returns the canonical --from value that resolves to this kit: a +// browsable GitHub URL into the compute-starter-kits monorepo, e.g. +// "https://github.com/fastly/compute-starter-kits/tree/main/starter-kits/javascript/typescript-default". func (k Kit) FromValue() string { - return "starter-kit/" + k.Language + "/" + k.KitName() + return sourceURL(k.Language, k.KitName()) } // Manifest represents the full /kits response. diff --git a/pkg/starterkit/starterkit_test.go b/pkg/starterkit/starterkit_test.go index 0b1f9eb2a..53cd3daae 100644 --- a/pkg/starterkit/starterkit_test.go +++ b/pkg/starterkit/starterkit_test.go @@ -86,7 +86,106 @@ func TestKitFromValue(t *testing.T) { Language: "javascript", } testutil.AssertString(t, "typescript-kv-store", kit.KitName()) - testutil.AssertString(t, "starter-kit/javascript/typescript-kv-store", kit.FromValue()) + testutil.AssertString(t, "https://github.com/fastly/compute-starter-kits/tree/main/starter-kits/javascript/typescript-kv-store", kit.FromValue()) +} + +func TestParseSourceURL(t *testing.T) { + for _, testcase := range []struct { + name string + input string + wantLang string + wantName string + wantOK bool + }{ + { + name: "valid", + input: "https://github.com/fastly/compute-starter-kits/tree/main/starter-kits/javascript/typescript-default", + wantLang: "javascript", + wantName: "typescript-default", + wantOK: true, + }, + { + name: "valid with hyphenated kit name and trailing slash", + input: "https://github.com/fastly/compute-starter-kits/tree/main/starter-kits/rust/connect-google-bigquery/", + wantLang: "rust", + wantName: "connect-google-bigquery", + wantOK: true, + }, + { + name: "valid on a non-main ref", + input: "https://github.com/fastly/compute-starter-kits/tree/some-branch/starter-kits/go/empty", + wantLang: "go", + wantName: "empty", + wantOK: true, + }, + { + name: "missing starter-kits path segment", + input: "https://github.com/fastly/compute-starter-kits/tree/main/javascript/typescript-default", + wantOK: false, + }, + { + name: "repo root, no kit path", + input: "https://github.com/fastly/compute-starter-kits", + wantOK: false, + }, + { + name: "legacy per-kit repo", + input: "https://github.com/fastly/compute-starter-kit-rust-default", + wantOK: false, + }, + { + name: "starter-kit short form", + input: "starter-kit/javascript/typescript-default", + wantOK: false, + }, + { + name: "unrelated github url", + input: "https://github.com/someuser/somekit", + wantOK: false, + }, + } { + t.Run(testcase.name, func(t *testing.T) { + lang, name, ok := starterkit.ParseSourceURL(testcase.input) + testutil.AssertBool(t, testcase.wantOK, ok) + if testcase.wantOK { + testutil.AssertString(t, testcase.wantLang, lang) + testutil.AssertString(t, testcase.wantName, name) + } + }) + } +} + +func TestCanonicalSourceURL(t *testing.T) { + for _, testcase := range []struct { + name string + input string + want string + }{ + { + name: "starter-kit short form is expanded", + input: "starter-kit/javascript/typescript-default", + want: "https://github.com/fastly/compute-starter-kits/tree/main/starter-kits/javascript/typescript-default", + }, + { + name: "already a URL is unchanged", + input: "https://github.com/fastly/compute-starter-kits/tree/main/starter-kits/javascript/typescript-default", + want: "https://github.com/fastly/compute-starter-kits/tree/main/starter-kits/javascript/typescript-default", + }, + { + name: "unrelated git URL is unchanged", + input: "https://github.com/someuser/somekit", + want: "https://github.com/someuser/somekit", + }, + { + name: "local path is unchanged", + input: "../some/local/dir", + want: "../some/local/dir", + }, + } { + t.Run(testcase.name, func(t *testing.T) { + testutil.AssertString(t, testcase.want, starterkit.CanonicalSourceURL(testcase.input)) + }) + } } func TestClientTarballURL(t *testing.T) { From 099ef741daea977ad9a0fa20e35a7da0a1c182b5 Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Wed, 12 Aug 2026 18:05:44 +0900 Subject: [PATCH 5/7] Correct doc for --from --- pkg/app/metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/app/metadata.json b/pkg/app/metadata.json index f64d19293..c3ac0c87e 100644 --- a/pkg/app/metadata.json +++ b/pkg/app/metadata.json @@ -304,7 +304,7 @@ "title": "Initialize a new Compute package locally" }, { - "cmd": "fastly compute init --from=starter-kits/javascript/typescript-default", + "cmd": "fastly compute init --from=starter-kit/javascript/typescript-default", "description": "Use a [Compute starter kit](https://www.fastly.com/documentation/solutions/starters) as the source for your new package.", "title": "Initialize a new Compute package locally using a Compute Starter Kit" }, From c83866b74d5a029ac05ed3309e242df6619d6535 Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Thu, 13 Aug 2026 07:22:52 +0900 Subject: [PATCH 6/7] Update changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40d3b367d..ab10c6226 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Enhancements: -- feat(compute/init): Source starter kits from service, recording `cloned_from` as a browsable link into the kit's source ([#1846](https://github.com/fastly/cli/pull/1846)) +- feat(compute/init): Offer all available starter kits for each language. ([#1846](https://github.com/fastly/cli/pull/1846)) ### Dependencies: From 0b01e8a210e01a3bfe78800273a4e46eaf325d9b Mon Sep 17 00:00:00 2001 From: Katsuyuki Omuro Date: Thu, 13 Aug 2026 09:55:05 +0900 Subject: [PATCH 7/7] Update init_test for new starterkit.Kit structure --- pkg/commands/compute/init_test.go | 74 ++++++++++++++++--------------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/pkg/commands/compute/init_test.go b/pkg/commands/compute/init_test.go index 2b3f775a2..1a7c45d40 100644 --- a/pkg/commands/compute/init_test.go +++ b/pkg/commands/compute/init_test.go @@ -22,6 +22,7 @@ import ( "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/manifest" "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/starterkit" "github.com/fastly/cli/pkg/testutil" "github.com/fastly/cli/pkg/threadsafe" ) @@ -803,16 +804,16 @@ func TestInit_ExistingService(t *testing.T) { // TestPromptForStarterKitBounds verifies that bounds checks are applied to // starter kit selection in the interactive mode prompt. func TestPromptForStarterKitBounds(t *testing.T) { - kits := []config.StarterKit{ + kits := []starterkit.Kit{ { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default", - Branch: "main", + ID: "rust-default", + Name: "Default", + Language: "rust", }, { - Name: "Empty", - Path: "https://github.com/fastly/compute-starter-kit-rust-empty", - Branch: "main", + ID: "rust-empty", + Name: "Empty", + Language: "rust", }, } @@ -821,56 +822,54 @@ func TestPromptForStarterKitBounds(t *testing.T) { // stdin is the input given at the starter kit prompt. An invalid entry // is rejected and the prompt repeats, so those cases supply a valid // follow-up value. - stdin string - wantPath string - wantBranch string + stdin string + wantFrom string // wantRejected asserts the validation message was shown to the user. wantRejected bool }{ { - name: "first option", - stdin: "1\n", - wantPath: kits[0].Path, - wantBranch: "main", + name: "first option", + stdin: "1\n", + wantFrom: kits[0].FromValue(), }, { - name: "last option", - stdin: "2\n", - wantPath: kits[1].Path, - wantBranch: "main", + name: "last option", + stdin: "2\n", + wantFrom: kits[1].FromValue(), }, { - name: "no input defaults to the first option", - stdin: "\n", - wantPath: kits[0].Path, - wantBranch: "main", + name: "no input defaults to the first option", + stdin: "\n", + wantFrom: kits[0].FromValue(), }, { name: "git URL is passed through", stdin: "https://github.com/fastly/compute-starter-kit-rust-websockets\n", - wantPath: "https://github.com/fastly/compute-starter-kit-rust-websockets", + wantFrom: "https://github.com/fastly/compute-starter-kit-rust-websockets", + }, + { + name: "starter kit reference is passed through", + stdin: "starter-kit/rust/websockets\n", + wantFrom: "starter-kit/rust/websockets", }, { // Without the lower bound this indexed kits[-1] and panicked. name: "zero is rejected", stdin: "0\n1\n", - wantPath: kits[0].Path, - wantBranch: "main", + wantFrom: kits[0].FromValue(), wantRejected: true, }, { // Without the lower bound this indexed kits[-2] and panicked. name: "negative is rejected", stdin: "-1\n2\n", - wantPath: kits[1].Path, - wantBranch: "main", + wantFrom: kits[1].FromValue(), wantRejected: true, }, { name: "above the upper bound is rejected", stdin: "3\n1\n", - wantPath: kits[0].Path, - wantBranch: "main", + wantFrom: kits[0].FromValue(), wantRejected: true, }, } @@ -884,16 +883,19 @@ func TestPromptForStarterKitBounds(t *testing.T) { }, } - from, branch, _, err := c.PromptForStarterKit(kits, strings.NewReader(testcase.stdin), &stdout) + // The starter-kit edge service has no concept of git refs, so the + // branch/tag returned are always empty. + from, branch, tag, err := c.PromptForStarterKit(kits, strings.NewReader(testcase.stdin), &stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } - testutil.AssertEqual(t, testcase.wantPath, from) - testutil.AssertEqual(t, testcase.wantBranch, branch) + testutil.AssertEqual(t, testcase.wantFrom, from) + testutil.AssertEqual(t, "", branch) + testutil.AssertEqual(t, "", tag) if testcase.wantRejected { - testutil.AssertStringContains(t, stdout.String(), "must be a valid option or git URL") + testutil.AssertStringContains(t, stdout.String(), "must be a valid option, git URL, or starter-kit// reference") } }) } @@ -909,8 +911,8 @@ func TestPromptForStarterKitBoundsNonInteractive(t *testing.T) { c := compute.InitCommand{Base: argparser.Base{Globals: g}} - // With defaults accepted and no kits configured, the option falls back to - // "1" with an empty slice, which is out of range. - _, _, _, err := c.PromptForStarterKit([]config.StarterKit{}, strings.NewReader(""), &stdout) + // With defaults accepted and no kits available, the option would otherwise + // fall back to "1" with an empty slice, which is out of range. + _, _, _, err := c.PromptForStarterKit([]starterkit.Kit{}, strings.NewReader(""), &stdout) testutil.AssertErrorContains(t, err, "no default starter kits configured for this language") }