Repository: digitalocean/doctl
Pull request #1283 (2022-10-16 10:30:45)

Recode doctl sls init in pure golang
This change replaces the call through the plugin to `nim project create` with pure golang code that does the equivalent.

joshuaauerbachwatson: > when I run `doctl sls init --language=golang2` or any unknown language, a file `sample/hello.` gets created with the extension missing.

Indeed, there was a missing error check.   Adding the error check made the command behave better but subsequent failures of the unit tests showed that not all assumptions were actually correct.   The default language 'javascript' is applied by cobra as a side-effect of parsing, so the unit tests needed to take this into account.    In addition, when the runtime implied by the language is 'nodejs' the command will check for the presence of a `nodejs` runtime, which IMO is unnecessary and illogical (the runtime necessary to support the default language should be assumed to be available _a priori_).   Rather than revise the tests still further to always list a `nodejs` runtime, I gave that runtime name an automatic pass.

The PR is also now "rebased" to `main` rather than `feature/serverless` in keeping with our new practice.   This more or less lost all the previous commit history so it's probably worth doing another quick review to make sure I've covered all the bases.

Patch:
diff --git a/commands/serverless-extra.go b/commands/serverless-extra.go
index 77addb6f..31ee79d4 100644
--- a/commands/serverless-extra.go
+++ b/commands/serverless-extra.go
@@ -14,11 +14,13 @@ limitations under the License.
 package commands
 
 import (
-	"errors"
 	"fmt"
+	"os"
+	"path/filepath"
 	"strings"
 
 	"github.com/digitalocean/doctl/do"
+	"gopkg.in/yaml.v3"
 )
 
 // ServerlessExtras adds commands to the 'serverless' subtree for which the cobra wrappers were autogenerated from
@@ -84,34 +86,78 @@ the contents to the cloud incrementally as it detects changes.`,
 
 // RunServerlessExtraCreate supports the 'serverless init' command
 func RunServerlessExtraCreate(c *CmdConfig) error {
-	err := ensureOneArg(c)
+	if err := ensureOneArg(c); err != nil {
+		return err
+	}
+	project := c.Args[0]
+	overwrite, _ := c.Doit.GetBool(c.NS, "overwrite")
+	language, _ := c.Doit.GetString(c.NS, "language")
+
+	// Determine the kind and sample
+	kind, sample, ts, err := languageToKindAndSample(c, language)
+	if err != nil {
+		return err
+	}
+
+	// Make the config and various paths
+	config := configTemplate()
+	configFile := filepath.Join(project, "project.yml")
+	gitignoreFile := filepath.Join(project, ".gitignore")
+	samplePackage := filepath.Join(project, "packages", "sample")
+
+	// Prepare the project area
+	if err = prepareProjectArea(project, overwrite); err != nil {
+		return err
+	}
+	if err = doMkdir(samplePackage, true); err != nil {
+		return err
+	}
+
+	// Generate the sample
+	actionDir, err := generateSample(kind, &config, sample, samplePackage, ts)
 	if err != nil {
 		return err
 	}
-	output, err := RunServerlessExec(projectCreate, c, []string{flagOverwrite}, []string{flagLanguage})
+
+	// Write the config
+	data, err := yaml.Marshal(&config)
 	if err != nil {
-		// Fix up error message text
-		text := err.Error()
-		if strings.Contains(text, "already exists") {
-			text = strings.Replace(text, "-o", "--overwrite", 1)
-			err = errors.New(text)
-		}
 		return err
 	}
-	// Special processing for output, since PrintServerlessTextOutput will emit the 'nim' hint which
-	// is not quite right for doctl.
-	if jsonOutput, ok := output.Entity.(map[string]interface{}); ok {
-		if created, ok := jsonOutput["project"].(string); ok {
-			fmt.Fprintf(c.Out, `A local functions project directory '%s' was created for you.
-You may deploy it by running the command shown on the next line:
-  doctl serverless deploy %s
-`, created, created)
-			fmt.Fprintln(c.Out)
-			return nil
+	if err = writeAFile(configFile, data); err != nil {
+		return err
+	}
+
+	// Add the .gitignore
+	ignores := gitignores
+	if ts {
+		ignores += ignoreForTypescript
+	}
+	if err = writeAFile(gitignoreFile, []byte(ignores)); err != nil {
+		return err
+	}
+
+	// Add typescript-specific information
+	if ts {
+		pjFile := filepath.Join(actionDir, "package.json")
+		if err = writeAFile(pjFile, []byte(packageJSONForTypescript)); err != nil {
+			return err
+		}
+		tscFile := filepath.Join(actionDir, "tsconfig.json")
+		if err = writeAFile(tscFile, []byte(tsconfigJSON)); err != nil {
+			return err
+		}
+		includeFile := filepath.Join(actionDir, ".include")
+		if err = writeAFile(includeFile, []byte("lib\n")); err != nil {
+			return err
 		}
 	}
-	// Fall back if output is not structured the way we expect
-	fmt.Println("Functions project initialized successfully in the local file system")
+
+	// Print informational success message
+	fmt.Fprintf(c.Out, `A local functions project directory '%s' was created for you.
+You may deploy it by running the command shown on the next line:
+  doctl serverless deploy %s
+`, project, project)
 	return nil
 }
 
@@ -148,6 +194,19 @@ func RunServerlessExtraDeploy(c *CmdConfig) error {
 	return err
 }
 
+// writeAFile is a thin wrapper around os.WriteFile designed to be replaced for testing.
+var writeAFile = func(path string, contents []byte) error {
+	return os.WriteFile(path, contents, 0664)
+}
+
+// doMkdir is a thin wrapper around os.Mkdir or os.MkdirAll designed to be replaced for testing.
+var doMkdir = func(path string, parents bool) error {
+	if parents {
+		return os.MkdirAll(path, 0775)
+	}
+	return os.Mkdir(path, 0775)
+}
+
 // RunServerlessExtraGetMetadata supports the 'serverless get-metadata' command
 func RunServerlessExtraGetMetadata(c *CmdConfig) error {
 	adjustIncludeAndExclude(c)
@@ -184,6 +243,162 @@ func RunServerlessExtraWatch(c *CmdConfig) error {
 		[]string{flagEnv, flagBuildEnv, flagApihost, flagAuth, flagInclude, flagExclude})
 }
 
+// prepareProjectArea prepares a disk area for receiving a project.  If the area exists and is not empty,
+// the overwrite flag must be true else error.  On successful return, the area either did not pre-exist
+// or has been removed.  Note: this function can be replaced for testing.
+var prepareProjectArea = func(project string, overwrite bool) error {
+	_, err := os.Stat(project)
+	if os.IsNotExist(err) {
+		return nil
+	}
+	if err != nil {
+		return err
+	}
+	// project exists in the file system
+	if !overwrite {
+		return fmt.Errorf("%s already exists; use '--overwrite' to replace", project)
+	}
+	// overwrite was specified: it is permitted to remove what's there
+	return os.RemoveAll(project)
+}
+
+// fileExtensionForRuntime maps runtimes to extensions in naming samples (other than typescript, which is handled specially)
+// Considering that we use dynamic information in deciding what runtimes are supported, this is disappointingly static but we
+// don't have a good way to make it dynamic at present.
+func fileExtensionForRuntime(runtime string) string {
+	switch runtime {
+	case "nodejs":
+		return "js"
+	case "python":
+		return "py"
+	}
+	// At present, for others, e.g. 'go' and 'php', runtime == extension
+	return runtime
+}
+
+// generateSample generates a sample function in the sample package
+func generateSample(kind string, config *do.ServerlessSpec, sample string, samplePackage string, ts bool) (string, error) {
+	runtime := strings.Split(kind, ":")[0]
+	var suffix string
+	if ts {
+		suffix = "ts"
+	} else {
+		suffix = fileExtensionForRuntime(runtime)
+	}
+	actionDir := filepath.Join(samplePackage, "hello")
+	if err := doMkdir(actionDir, true); err != nil {
+		return "", err
+	}
+	var file string
+	if ts {
+		srcDir := filepath.Join(actionDir, "src")
+		if err := doMkdir(srcDir, false); err != nil {
+			return "", err
+		}
+		file = filepath.Join(srcDir, "hello."+suffix)
+	} else {
+		file = filepath.Join(actionDir, "hello."+suffix)
+	}
+	if err := writeAFile(file, []byte(sample)); err != nil {
+		return "", err
+	}
+	var sampPkg *do.ServerlessPackage
+	for _, pkg := range config.Packages {
+		if pkg.Name == "sample" {
+			sampPkg = pkg
+			break
+		}
+	}
+	if sampPkg.Name != "sample" {
+		return "", fmt.Errorf("could not find sample package in config (internal error)")
+	}
+	function := do.ServerlessFunction{
+		Name:      "hello",
+		Runtime:   kind,
+		Web:       true,
+		WebSecure: false,
+	}
+	sampPkg.Functions = []*do.ServerlessFunction{&function}
+	return actionDir, nil
+}
+
+// languageToKindAndSample converts a user-specified language name to a runtime kind plus a sample.
+// A third return value indicates that the language is typescript (special support in the sample
+// generation).  Returns an error if the user requests an unsupported language.
+func languageToKindAndSample(c *CmdConfig, language string) (string, string, bool, error) {
+	language = strings.ToLower(language)
+	if !isValidLanguage(c, language) {
+		return "", "", false, fmt.Errorf("%s is not a supported language", language)
+	}
+	runtime := language
+	ts := false
+	switch language {
+	case "ts", "typescript":
+		ts = true
+		runtime = "nodejs"
+	case "js", "javascript":
+		runtime = "nodejs"
+	case "py":
+		runtime = "python"
+	case "golang":
+		runtime = "go"
+	}
+	return runtime + ":default", samples[language], ts, nil
+}
+
+// isValidLanguage uses the languageKeywords table to decide if a language name is valid.
+// If it appears to be valid, a runtime check is run.  Since we don't want to require
+// connectivity to create a project, we accept the keyword if we can't contact the host
+// and only reject it if we do contact the host and the host says there's no such runtime.
+func isValidLanguage(c *CmdConfig, language string) bool {
+	for runtime, kwds := range languageKeywords {
+		for _, kwd := range kwds {
+			if language == kwd {
+				return validateRuntime(c, runtime)
+			}
+		}
+	}
+	return false
+}
+
+// configTemplate builds a minimal project configuration (project.yml) in memory.
+func configTemplate() do.ServerlessSpec {
+	config := do.ServerlessSpec{}
+	defPkg := do.ServerlessPackage{Name: "sample"}
+	config.Packages = []*do.ServerlessPackage{&defPkg}
+	return config
+}
+
+// validateRuntime takes the name of a runtime and tries to contact the host to
+// determine if that runtime exists.  It returns false only if it succeeds in
+// contacting the host and is told the runtime does not exist.
+func validateRuntime(c *CmdConfig, runtime string) bool {
+	if runtime == "nodejs" {
+		// As long as we are using a default language of 'javascript' we must necessarily assume
+		// that the 'nodejs' runtime is valid.  No need to go through the overhead of checking.
+		return true
+	}
+	sls := c.Serverless()
+	err := sls.CheckServerlessStatus()
+	if err != nil {
+		return true // err in the permissive direction
+	}
+	creds, err := sls.ReadCredentials()
+	if err != nil {
+		return true // err in the permissive direction
+	}
+	info, err := sls.GetHostInfo(creds.APIHost)
+	if err != nil {
+		return true // err in the permissive direction
+	}
+	for validRuntime := range info.Runtimes {
+		if runtime == validRuntime {
+			return true
+		}
+	}
+	return false
+}
+
 // adjustIncludeAndExclude deals with the fact that 'web' has special meaning to 'nim'.
 // 1.  If the developer has a package called 'web' and wishes to include or exclude it, 'nim' will be confused unless a trailing
 // slash is added to indicate that the intent is the package called 'web' and not 'web content.'
diff --git a/commands/serverless_samples.go b/commands/serverless_samples.go
new file mode 100644
index 00000000..8865e1df
--- /dev/null
+++ b/commands/serverless_samples.go
@@ -0,0 +1,214 @@
+/*
+Copyright 2018 The Doctl Authors All rights reserved.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+    http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package commands
+
+//  Samples
+//  We include all the samples that we have, historically, from legacy Nimbella.
+//  Not all are used because not all runtimes are deployed.
+
+var (
+	javascriptSample = `function main(args) {
+    let name = args.name || 'stranger'
+    let greeting = 'Hello ' + name + '!'
+    console.log(greeting)
+    return {"body": greeting}
+}
+`
+	typescriptSample = `export function main(args: {}): {} {
+    let name: string = args['name'] || 'stranger'
+    let greeting: string = 'Hello ' + name + '!'
+    console.log(greeting)
+    return { body: greeting }
+}
+`
+	pythonSample = `def main(args):
+      name = args.get("name", "stranger")
+      greeting = "Hello " + name + "!"
+      print(greeting)
+      return {"body": greeting}
+`
+	swiftSample = `func main(args: [String:Any]) -> [String:Any] {
+      if let name = args["name"] as? String {
+          let greeting = "Hello \\(name)!"
+          print(greeting)
+          return [ "greeting" : greeting ]
+      } else {
+          let greeting = "Hello stranger!"
+          print(greeting)
+          return [ "body" : greeting ]
+      }
+  }
+`
+	phpSample = `<?php
+  function main(array $args) : array
+  {
+      $name = $args["name"] ?? "stranger";
+      $greeting = "Hello $name!";
+      echo $greeting;
+      return ["body" => $greeting];
+  }
+`
+	javaSample = `import com.google.gson.JsonObject;
+
+  public class Main {
+      public static JsonObject main(JsonObject args) {
+          String name = "stranger";
+          if (args.has("name"))
+              name = args.getAsJsonPrimitive("name").getAsString();
+          String greeting = "Hello " + name + "!";
+          JsonObject response = new JsonObject();
+          response.addProperty("body", greeting);
+          return response;
+      }
+  }
+`
+	goSample = `package main
+
+func Main(args map[string]interface{}) map[string]interface{} {
+	name, ok := args["name"].(string)
+	if !ok {
+		name = "stranger"
+	}
+	msg := make(map[string]interface{})
+	msg["body"] = "Hello " + name + "!"
+	return msg
+}
+`
+	rustSample = `extern crate serde_json;
+
+use serde_derive::{Deserialize, Serialize};
+use serde_json::{Error, Value};
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+struct Input {
+    #[serde(default = "stranger")]
+    name: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+struct Output {
+    body: String,
+}
+
+fn stranger() -> String {
+    "stranger".to_string()
+}
+
+pub fn main(args: Value) -> Result<Value, Error> {
+    let input: Input = serde_json::from_value(args)?;
+    let output = Output {
+        body: format!("Hello {}", input.name),
+    };
+    serde_json::to_value(output)
+}
+`
+	denoSample = `export default function main(args: {[key: string]: any}) {
+  return {
+    body: ` + "`" + `Hello \${args.name || "stranger"}!\` + "`" + `,
+  };
+};
+`
+	rubySample = `def main(args)
+name = args["name"] || "stranger"
+greeting = "Hello #{name}!"
+puts greeting
+{ "body" => greeting }
+end
+`
+	csharpSample = `using System;
+using Newtonsoft.Json.Linq;
+
+namespace Nimbella.Example.Dotnet
+{
+    public class Hello
+    {
+        public JObject Main(JObject args)
+        {
+            string name = "stranger";
+            if (args.ContainsKey("name")) {
+                name = args["name"].ToString();
+            }
+            JObject message = new JObject();
+            message.Add("body", new JValue($"Hello {name}!"));
+            return (message);
+        }
+    }
+}
+`
+
+	// samples is the official table of samples
+	samples = map[string]string{
+		"deno":       denoSample,
+		"cs":         csharpSample,
+		"csharp":     csharpSample,
+		"go":         goSample,
+		"golang":     goSample,
+		"java":       javaSample,
+		"javascript": javascriptSample,
+		"js":         javascriptSample,
+		"php":        phpSample,
+		"py":         pythonSample,
+		"python":     pythonSample,
+		"ruby":       rubySample,
+		"rust":       rustSample,
+		"swift":      swiftSample,
+		"ts":         typescriptSample,
+		"typescript": typescriptSample,
+	}
+
+	// gitignores contains the contents of a "standard" .gitigore file
+	// Note that we do not attempt to list typical IDE and editor temporaries here.
+	// It is considered best practice for developers to list these in a personal global
+	// ignore file (`core.excludesfile` in the git config) and not in a committed .gitignore.
+	gitignores = `.nimbella
+.deployed
+__deployer__.zip
+__pycache__/
+node_modules
+package-lock.json
+.DS_Store
+`
+
+	// ignoreForTypescript is added when the project is typescript
+	ignoreForTypescript = "lib/\n"
+
+	// packageJSONForTypescript is a canned package.json for a minimal typescript project
+	packageJSONForTypescript = `{
+  "main": "lib/hello.js",
+  "devDependencies": {
+    "typescript": "^4"
+  },
+  "scripts": {
+    "build": "tsc -b"
+  }
+}
+`
+
+	// tsconfigJSON is a canned tsconfig.json for typescript project
+	tsconfigJSON = `{
+  "compilerOptions": {
+    "baseUrl": ".",
+    "esModuleInterop": true,
+    "importHelpers": true,
+    "module": "commonjs",
+    "outDir": "lib",
+    "rootDir": "src",
+    "target": "es2019"
+  },
+  "include": [
+    "src/**/*"
+  ]
+}
+`
+)
diff --git a/do/serverless.go b/do/serverless.go
index 73f57af0..04e457ae 100644
--- a/do/serverless.go
+++ b/do/serverless.go
@@ -125,54 +125,43 @@ type ServerlessProject struct {
 	Strays      []string `json:"strays"`
 }
 
-// ProjectSpec describes a project.yml spec
+// ServerlessSpec describes a project.yml spec
 // reference: https://docs.nimbella.com/configuration/
-type ProjectSpec struct {
+type ServerlessSpec struct {
 	Parameters  map[string]interface{} `json:"parameters,omitempty"`
 	Environment map[string]interface{} `json:"environment,omitempty"`
-	Packages    []*ProjectSpecPackage  `json:"packages,omitempty"`
+	Packages    []*ServerlessPackage   `json:"packages,omitempty"`
 }
 
-// ProjectSpecPackage ...
-type ProjectSpecPackage struct {
+// ServerlessPackage ...
+type ServerlessPackage struct {
 	Name        string                 `json:"name,omitempty"`
 	Shared      bool                   `json:"shared,omitempty"`
 	Environment map[string]interface{} `json:"environment,omitempty"`
 	Parameters  map[string]interface{} `json:"parameters,omitempty"`
 	Annotations map[string]interface{} `json:"annotations,omitempty"`
-	Functions   []*ProjectSpecFunction `json:"functions,omitempty"`
+	Functions   []*ServerlessFunction  `json:"functions,omitempty"`
 }
 
-// ProjectSpecFunction ...
-type ProjectSpecFunction struct {
+// ServerlessFunction ...
+type ServerlessFunction struct {
 	Name    string `json:"name,omitempty"`
-	Package string `json:"package,omitempty"`
 	Binary  bool   `json:"binary,omitempty"`
 	Main    string `json:"main,omitempty"`
 	Runtime string `json:"runtime,omitempty"`
 	// `web` can be either true or "raw". We use interface{} to support both types. If we start consuming the value we
 	// should probably define a custom type with proper validation.
-	Web         interface{}               `json:"web,omitempty"`
-	WebSecure   interface{}               `json:"webSecure,omitempty"`
-	Sequence    []string                  `json:"sequence,omitempty"`
-	Parameters  map[string]interface{}    `json:"parameters,omitempty"`
-	Environment map[string]interface{}    `json:"environment,omitempty"`
-	Annotations map[string]interface{}    `json:"annotations,omitempty"`
-	Limits      ProjectSpecFunctionLimits `json:"limits,omitempty"`
-}
-
-// ProjectSpecFunctionLimits ...
-// ref: https://github.com/apache/openwhisk-client-js/blob/1aba396e8a59afd5a90acb8157f2009746d7a714/lib/main.d.ts#L263-L268
-type ProjectSpecFunctionLimits struct {
-	Timeout     int `json:"timeout,omitempty"`
-	Memory      int `json:"memory,omitempty"`
-	Logs        int `json:"logs,omitempty"`
-	Concurrency int `json:"concurrency,omitempty"`
+	Web         interface{}            `json:"web,omitempty"`
+	WebSecure   interface{}            `json:"webSecure,omitempty"`
+	Parameters  map[string]interface{} `json:"parameters,omitempty"`
+	Environment map[string]interface{} `json:"environment,omitempty"`
+	Annotations map[string]interface{} `json:"annotations,omitempty"`
+	Limits      map[string]int         `json:"limits,omitempty"`
 }
 
 // ProjectMetadata describes the nim project:get-metadata output structure.
 type ProjectMetadata struct {
-	ProjectSpec
+	ServerlessSpec
 	UnresolvedVariables []string `json:"unresolvedVariables,omitempty"`
 }
 
@@ -1035,8 +1024,8 @@ func readTopLevel(project *ServerlessProject) error {
 	return nil
 }
 
-func readProjectConfig(configPath string) (*ProjectSpec, error) {
-	spec := ProjectSpec{}
+func readProjectConfig(configPath string) (*ServerlessSpec, error) {
+	spec := ServerlessSpec{}
 	// reading config file content
 	content, err := ioutil.ReadFile(configPath)
 	if err != nil {
@@ -1062,7 +1051,7 @@ func readProjectConfig(configPath string) (*ProjectSpec, error) {
 	return &spec, nil
 }
 
-func validateConfig(config *ProjectSpec) error {
+func validateConfig(config *ServerlessSpec) error {
 	forbiddenConfigs, err := ListForbiddenConfigs(config)
 
 	if err != nil {
@@ -1241,7 +1230,7 @@ func PreserveCreds(leafDir string, stagingDir string, serverlessDir string) erro
 }
 
 // ListForbiddenConfigs returns a list of forbidden config values in a project spec.
-func ListForbiddenConfigs(serverlessProject *ProjectSpec) ([]string, error) {
+func ListForbiddenConfigs(serverlessProject *ServerlessSpec) ([]string, error) {
 	var forbiddenConfigs []string
 
 	// validate package-level configs
@@ -1266,7 +1255,7 @@ func ListForbiddenConfigs(serverlessProject *ProjectSpec) ([]string, error) {
 
 // ListInvalidWebsecureValues returns a list of forbidden websecure values for an action in a project spec.
 // a valid websecure value is any string other than "true"
-func ListInvalidWebsecureValues(serverlessProject *ProjectSpec) ([]string, error) {
+func ListInvalidWebsecureValues(serverlessProject *ServerlessSpec) ([]string, error) {
 	var invalidValues = []string{}
 
 	for _, p := range serverlessProject.Packages {
@@ -1274,12 +1263,12 @@ func ListInvalidWebsecureValues(serverlessProject *ProjectSpec) ([]string, error
 			switch value := f.WebSecure.(type) {
 			case string:
 				if strings.ToLower(value) == "true" { /* "true" is not a valid value */
-					invalidValues = append(invalidValues, fmt.Sprintf("function %s in package %s configures an invalid value for webSecure: %v", f.Name, f.Package, value))
+					invalidValues = append(invalidValues, fmt.Sprintf("function %s in package %s configures an invalid value for webSecure: %v", f.Name, p.Name, value))
 				}
 				// any other value is fine
 			default: // bool or any other type
 				/* "web-action" must be a string */
-				invalidValues = append(invalidValues, fmt.Sprintf("function %s in package %s configures an invalid value for webSecure: %v", f.Name, f.Package, value))
+				invalidValues = append(invalidValues, fmt.Sprintf("function %s in package %s configures an invalid value for webSecure: %v", f.Name, p.Name, value))
 			}
 		}
 	}
@@ -1288,7 +1277,7 @@ func ListInvalidWebsecureValues(serverlessProject *ProjectSpec) ([]string, error
 }
 
 // validate project-level forbidden configs
-func validateProjectLevelFields(serverlessPackage *ProjectSpecPackage) ([]string, error) {
+func validateProjectLevelFields(serverlessPackage *ServerlessPackage) ([]string, error) {
 	var forbiddenConfigs []string
 
 	if serverlessPackage.Shared {
@@ -1307,13 +1296,9 @@ func validateProjectLevelFields(serverlessPackage *ProjectSpecPackage) ([]string
 }
 
 // validate project-level forbidden configs
-func validateFunctionLevelFields(serverlessAction *ProjectSpecFunction) ([]string, error) {
+func validateFunctionLevelFields(serverlessAction *ServerlessFunction) ([]string, error) {
 	var forbiddenConfigs []string
 
-	if serverlessAction.Sequence != nil {
-		forbiddenConfigs = append(forbiddenConfigs, ForbiddenConfigSequence)
-	}
-
 	if _, ok := serverlessAction.Annotations[ForbiddenAnnotationProvideAPIKey]; ok {
 		forbiddenConfigs = append(forbiddenConfigs, ForbiddenAnnotationProvideAPIKey)
 	}


Test patch:
diff --git a/commands/serverless_test.go b/commands/serverless_test.go
index baa352d4..b3ecaab5 100644
--- a/commands/serverless_test.go
+++ b/commands/serverless_test.go
@@ -333,28 +333,31 @@ func TestServerlessInit(t *testing.T) {
 		name            string
 		doctlArgs       string
 		doctlFlags      map[string]string
-		expectedNimArgs []string
 		out             map[string]interface{}
+		expectCheck     bool
+		expectOverwrite bool
 	}{
 		{
-			name:            "no flags",
-			doctlArgs:       "path/to/foo",
-			expectedNimArgs: []string{"path/to/foo"},
-			out:             map[string]interface{}{"project": "foo"},
+			name:      "no flags",
+			doctlArgs: "path/to/foo",
+			// The language flag has a default normally applied by cobra
+			doctlFlags: map[string]string{"language": "javascript"},
+			out:        map[string]interface{}{"project": "foo"},
 		},
 		{
-			name:            "overwrite",
-			doctlArgs:       "path/to/project",
-			doctlFlags:      map[string]string{"overwrite": ""},
-			expectedNimArgs: []string{"path/to/project", "--overwrite"},
+			name:      "overwrite",
+			doctlArgs: "path/to/foo",
+			// The language flag has a default normally applied by cobra
+			doctlFlags:      map[string]string{"overwrite": "", "language": "javascript"},
 			out:             map[string]interface{}{"project": "foo"},
+			expectOverwrite: true,
 		},
 		{
-			name:            "language flag",
-			doctlArgs:       "path/to/project",
-			doctlFlags:      map[string]string{"language": "go"},
-			expectedNimArgs: []string{"path/to/project", "--language", "go"},
-			out:             map[string]interface{}{"project": "foo"},
+			name:        "language flag",
+			doctlArgs:   "path/to/foo",
+			doctlFlags:  map[string]string{"language": "go"},
+			out:         map[string]interface{}{"project": "foo"},
+			expectCheck: true,
 		},
 	}
 
@@ -363,9 +366,6 @@ func TestServerlessInit(t *testing.T) {
 			withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
 				buf := &bytes.Buffer{}
 				config.Out = buf
-				fakeCmd := &exec.Cmd{
-					Stdout: config.Out,
-				}
 
 				if tt.doctlArgs != "" {
 					config.Args = append(config.Args, tt.doctlArgs)
@@ -381,17 +381,36 @@ func TestServerlessInit(t *testing.T) {
 					}
 				}
 
-				tm.serverless.EXPECT().CheckServerlessStatus().MinTimes(1).Return(nil)
-				tm.serverless.EXPECT().Cmd("project/create", tt.expectedNimArgs).Return(fakeCmd, nil)
-				tm.serverless.EXPECT().Exec(fakeCmd).Return(do.ServerlessOutput{
-					Entity: tt.out,
-				}, nil)
+				sawOverwrite := false
+				// Grab the overrideable commands so they can be mocked
+				writeAFile = func(path string, contents []byte) error {
+					return nil
+				}
+				doMkdir = func(path string, parents bool) error {
+					return nil
+				}
+				prepareProjectArea = func(project string, overwrite bool) error {
+					sawOverwrite = overwrite
+					return nil
+				}
 
+				if tt.expectCheck {
+					tm.serverless.EXPECT().CheckServerlessStatus().Return(nil)
+					creds := do.ServerlessCredentials{APIHost: "https://example.com"}
+					tm.serverless.EXPECT().ReadCredentials().Return(creds, nil)
+					hostInfo := do.ServerlessHostInfo{
+						Runtimes: map[string][]do.ServerlessRuntime{
+							"go": []do.ServerlessRuntime{},
+						},
+					}
+					tm.serverless.EXPECT().GetHostInfo("https://example.com").Return(hostInfo, nil)
+				}
 				err := RunServerlessExtraCreate(config)
 				require.NoError(t, err)
-				assert.Equal(t, `A local functions project directory 'foo' was created for you.
+				assert.Equal(t, tt.expectOverwrite, sawOverwrite)
+				assert.Equal(t, `A local functions project directory 'path/to/foo' was created for you.
 You may deploy it by running the command shown on the next line:
-  doctl serverless deploy foo`+"\n\n", buf.String())
+  doctl serverless deploy path/to/foo`+"\n", buf.String())
 			})
 		})
 	}