Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@

### Enhancements:

- feat(compute/init): Offer all available starter kits for each language. ([#1846](https://github.com/fastly/cli/pull/1846))

### Dependencies:
- build(deps): `github.com/klauspost/compress` from 1.19.1 to 1.19.2 ([#1881](https://github.com/fastly/cli/pull/1881))
- build(deps): `github.com/pierrec/lz4/v4` from 4.1.27 to 4.1.28 ([#1881](https://github.com/fastly/cli/pull/1881))
Expand Down
4 changes: 2 additions & 2 deletions DEVELOPMENT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand Down
5 changes: 5 additions & 0 deletions pkg/app/metadata.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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-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"
},
{
"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.",
Expand Down
215 changes: 202 additions & 13 deletions pkg/commands/compute/init.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand All@@ -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"
)

Expand DownExpand Up@@ -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/<lang>/<name>); (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)

Expand DownExpand Up@@ -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

Expand DownExpand Up@@ -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{
Expand DownExpand Up@@ -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

Expand All@@ -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")
Expand All@@ -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/<lang>/<name> reference"
if input == "" {
return nil
}
Expand All@@ -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)
}
}

Expand All@@ -858,6 +872,28 @@ 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/<lang>/<name>, 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)
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() {
Expand DownExpand Up@@ -1077,9 +1113,162 @@ 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 {
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 {
return c.fetchAndExtractTarball(client.TarballURL(lang, name), file.Archives)
}
}

_, err := exec.LookPath("git")
if err != nil {
return fsterr.RemediationError{
Expand DownExpand Up@@ -1199,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)
}
Expand DownExpand Up@@ -1260,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 {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@

### Enhancements:

- feat(compute/init): Offer all available starter kits for each language. ([#1846](https://github.com/fastly/cli/pull/1846))

### Dependencies:
- build(deps): `github.com/klauspost/compress` from 1.19.1 to 1.19.2 ([#1881](https://github.com/fastly/cli/pull/1881))
- build(deps): `github.com/pierrec/lz4/v4` from 4.1.27 to 4.1.28 ([#1881](https://github.com/fastly/cli/pull/1881))
Expand Down
4 changes: 2 additions & 2 deletions DEVELOPMENT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand Down
5 changes: 5 additions & 0 deletions pkg/app/metadata.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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-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"
},
{
"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.",
Expand Down
215 changes: 202 additions & 13 deletions pkg/commands/compute/init.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand All@@ -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"
)

Expand DownExpand Up@@ -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/<lang>/<name>); (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)

Expand DownExpand Up@@ -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

Expand DownExpand Up@@ -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{
Expand DownExpand Up@@ -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

Expand All@@ -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")
Expand All@@ -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/<lang>/<name> reference"
if input == "" {
return nil
}
Expand All@@ -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)
}
}

Expand All@@ -858,6 +872,28 @@ 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/<lang>/<name>, 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)
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() {
Expand DownExpand Up@@ -1077,9 +1113,162 @@ 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 {
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 {
return c.fetchAndExtractTarball(client.TarballURL(lang, name), file.Archives)
}
}

_, err := exec.LookPath("git")
if err != nil {
return fsterr.RemediationError{
Expand DownExpand Up@@ -1199,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)
}
Expand DownExpand Up@@ -1260,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 {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@

### Enhancements:

- feat(compute/init): Offer all available starter kits for each language. ([#1846](https://github.com/fastly/cli/pull/1846))

### Dependencies:
- build(deps): `github.com/klauspost/compress` from 1.19.1 to 1.19.2 ([#1881](https://github.com/fastly/cli/pull/1881))
- build(deps): `github.com/pierrec/lz4/v4` from 4.1.27 to 4.1.28 ([#1881](https://github.com/fastly/cli/pull/1881))
Expand Down
4 changes: 2 additions & 2 deletions DEVELOPMENT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand Down
5 changes: 5 additions & 0 deletions pkg/app/metadata.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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-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"
},
{
"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.",
Expand Down
215 changes: 202 additions & 13 deletions pkg/commands/compute/init.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand All@@ -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"
)

Expand DownExpand Up@@ -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/<lang>/<name>); (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)

Expand DownExpand Up@@ -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

Expand DownExpand Up@@ -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{
Expand DownExpand Up@@ -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

Expand All@@ -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")
Expand All@@ -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/<lang>/<name> reference"
if input == "" {
return nil
}
Expand All@@ -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)
}
}

Expand All@@ -858,6 +872,28 @@ 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/<lang>/<name>, 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)
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() {
Expand DownExpand Up@@ -1077,9 +1113,162 @@ 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 {
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 {
return c.fetchAndExtractTarball(client.TarballURL(lang, name), file.Archives)
}
}

_, err := exec.LookPath("git")
if err != nil {
return fsterr.RemediationError{
Expand DownExpand Up@@ -1199,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)
}
Expand DownExpand Up@@ -1260,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 {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@

### Enhancements:

- feat(compute/init): Offer all available starter kits for each language. ([#1846](https://github.com/fastly/cli/pull/1846))

### Dependencies:
- build(deps): `github.com/klauspost/compress` from 1.19.1 to 1.19.2 ([#1881](https://github.com/fastly/cli/pull/1881))
- build(deps): `github.com/pierrec/lz4/v4` from 4.1.27 to 4.1.28 ([#1881](https://github.com/fastly/cli/pull/1881))
Expand Down
4 changes: 2 additions & 2 deletions DEVELOPMENT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand Down
5 changes: 5 additions & 0 deletions pkg/app/metadata.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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-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"
},
{
"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.",
Expand Down
215 changes: 202 additions & 13 deletions pkg/commands/compute/init.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand All@@ -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"
)

Expand DownExpand Up@@ -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/<lang>/<name>); (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)

Expand DownExpand Up@@ -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

Expand DownExpand Up@@ -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{
Expand DownExpand Up@@ -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

Expand All@@ -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")
Expand All@@ -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/<lang>/<name> reference"
if input == "" {
return nil
}
Expand All@@ -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)
}
}

Expand All@@ -858,6 +872,28 @@ 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/<lang>/<name>, 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)
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() {
Expand DownExpand Up@@ -1077,9 +1113,162 @@ 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 {
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 {
return c.fetchAndExtractTarball(client.TarballURL(lang, name), file.Archives)
}
}

_, err := exec.LookPath("git")
if err != nil {
return fsterr.RemediationError{
Expand DownExpand Up@@ -1199,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)
}
Expand DownExpand Up@@ -1260,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 {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@

### Enhancements:

- feat(compute/init): Offer all available starter kits for each language. ([#1846](https://github.com/fastly/cli/pull/1846))

### Dependencies:
- build(deps): `github.com/klauspost/compress` from 1.19.1 to 1.19.2 ([#1881](https://github.com/fastly/cli/pull/1881))
- build(deps): `github.com/pierrec/lz4/v4` from 4.1.27 to 4.1.28 ([#1881](https://github.com/fastly/cli/pull/1881))
Expand Down
4 changes: 2 additions & 2 deletions DEVELOPMENT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand Down
5 changes: 5 additions & 0 deletions pkg/app/metadata.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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-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"
},
{
"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.",
Expand Down
215 changes: 202 additions & 13 deletions pkg/commands/compute/init.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand All@@ -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"
)

Expand DownExpand Up@@ -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/<lang>/<name>); (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)

Expand DownExpand Up@@ -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

Expand DownExpand Up@@ -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{
Expand DownExpand Up@@ -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

Expand All@@ -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")
Expand All@@ -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/<lang>/<name> reference"
if input == "" {
return nil
}
Expand All@@ -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)
}
}

Expand All@@ -858,6 +872,28 @@ 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/<lang>/<name>, 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)
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() {
Expand DownExpand Up@@ -1077,9 +1113,162 @@ 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 {
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 {
return c.fetchAndExtractTarball(client.TarballURL(lang, name), file.Archives)
}
}

_, err := exec.LookPath("git")
if err != nil {
return fsterr.RemediationError{
Expand DownExpand Up@@ -1199,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)
}
Expand DownExpand Up@@ -1260,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 {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@

### Enhancements:

- feat(compute/init): Offer all available starter kits for each language. ([#1846](https://github.com/fastly/cli/pull/1846))

### Dependencies:
- build(deps): `github.com/klauspost/compress` from 1.19.1 to 1.19.2 ([#1881](https://github.com/fastly/cli/pull/1881))
- build(deps): `github.com/pierrec/lz4/v4` from 4.1.27 to 4.1.28 ([#1881](https://github.com/fastly/cli/pull/1881))
Expand Down
4 changes: 2 additions & 2 deletions DEVELOPMENT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand Down
5 changes: 5 additions & 0 deletions pkg/app/metadata.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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-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"
},
{
"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.",
Expand Down
215 changes: 202 additions & 13 deletions pkg/commands/compute/init.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand All@@ -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"
)

Expand DownExpand Up@@ -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/<lang>/<name>); (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)

Expand DownExpand Up@@ -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

Expand DownExpand Up@@ -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{
Expand DownExpand Up@@ -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

Expand All@@ -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")
Expand All@@ -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/<lang>/<name> reference"
if input == "" {
return nil
}
Expand All@@ -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)
}
}

Expand All@@ -858,6 +872,28 @@ 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/<lang>/<name>, 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)
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() {
Expand DownExpand Up@@ -1077,9 +1113,162 @@ 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 {
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 {
return c.fetchAndExtractTarball(client.TarballURL(lang, name), file.Archives)
}
}

_, err := exec.LookPath("git")
if err != nil {
return fsterr.RemediationError{
Expand DownExpand Up@@ -1199,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)
}
Expand DownExpand Up@@ -1260,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 {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@

### Enhancements:

- feat(compute/init): Offer all available starter kits for each language. ([#1846](https://github.com/fastly/cli/pull/1846))

### Dependencies:
- build(deps): `github.com/klauspost/compress` from 1.19.1 to 1.19.2 ([#1881](https://github.com/fastly/cli/pull/1881))
- build(deps): `github.com/pierrec/lz4/v4` from 4.1.27 to 4.1.28 ([#1881](https://github.com/fastly/cli/pull/1881))
Expand Down
4 changes: 2 additions & 2 deletions DEVELOPMENT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand Down
5 changes: 5 additions & 0 deletions pkg/app/metadata.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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-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"
},
{
"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.",
Expand Down
215 changes: 202 additions & 13 deletions pkg/commands/compute/init.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand All@@ -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"
)

Expand DownExpand Up@@ -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/<lang>/<name>); (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)

Expand DownExpand Up@@ -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

Expand DownExpand Up@@ -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{
Expand DownExpand Up@@ -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

Expand All@@ -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")
Expand All@@ -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/<lang>/<name> reference"
if input == "" {
return nil
}
Expand All@@ -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)
}
}

Expand All@@ -858,6 +872,28 @@ 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/<lang>/<name>, 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)
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() {
Expand DownExpand Up@@ -1077,9 +1113,162 @@ 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 {
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 {
return c.fetchAndExtractTarball(client.TarballURL(lang, name), file.Archives)
}
}

_, err := exec.LookPath("git")
if err != nil {
return fsterr.RemediationError{
Expand DownExpand Up@@ -1199,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)
}
Expand DownExpand Up@@ -1260,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 {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@

### Enhancements:

- feat(compute/init): Offer all available starter kits for each language. ([#1846](https://github.com/fastly/cli/pull/1846))

### Dependencies:
- build(deps): `github.com/klauspost/compress` from 1.19.1 to 1.19.2 ([#1881](https://github.com/fastly/cli/pull/1881))
- build(deps): `github.com/pierrec/lz4/v4` from 4.1.27 to 4.1.28 ([#1881](https://github.com/fastly/cli/pull/1881))
Expand Down
4 changes: 2 additions & 2 deletions DEVELOPMENT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand Down
5 changes: 5 additions & 0 deletions pkg/app/metadata.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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-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"
},
{
"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.",
Expand Down
215 changes: 202 additions & 13 deletions pkg/commands/compute/init.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand All@@ -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"
)

Expand DownExpand Up@@ -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/<lang>/<name>); (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)

Expand DownExpand Up@@ -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

Expand DownExpand Up@@ -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{
Expand DownExpand Up@@ -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

Expand All@@ -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")
Expand All@@ -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/<lang>/<name> reference"
if input == "" {
return nil
}
Expand All@@ -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)
}
}

Expand All@@ -858,6 +872,28 @@ 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/<lang>/<name>, 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)
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() {
Expand DownExpand Up@@ -1077,9 +1113,162 @@ 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 {
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 {
return c.fetchAndExtractTarball(client.TarballURL(lang, name), file.Archives)
}
}

_, err := exec.LookPath("git")
if err != nil {
return fsterr.RemediationError{
Expand DownExpand Up@@ -1199,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)
}
Expand DownExpand Up@@ -1260,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 {
Expand Down
Loading
Loading