github.com/actions-on-google/gactions@v3.2.0+incompatible/project/studio.go (about)

     1  // Copyright 2020 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     https://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package studio contains a Studio implementation of a project.Project interface.
    16  package studio
    17  
    18  import (
    19  	"archive/zip"
    20  	"bytes"
    21  	"encoding/json"
    22  	"errors"
    23  	"fmt"
    24  	"io/ioutil"
    25  	"net/http"
    26  	"net/url"
    27  	"os"
    28  	"path"
    29  	"path/filepath"
    30  	"runtime"
    31  	"sort"
    32  	"strings"
    33  
    34  	"github.com/actions-on-google/gactions/api/yamlutils"
    35  	"github.com/actions-on-google/gactions/log"
    36  	"github.com/actions-on-google/gactions/project"
    37  	"gopkg.in/yaml.v2"
    38  )
    39  
    40  // Studio is an implementation of the AoG Studio project.
    41  type Studio struct {
    42  	files            map[string][]byte
    43  	clientSecretJSON []byte
    44  	root             string
    45  	projectID        string
    46  }
    47  
    48  // New returns a new instance of Studio.
    49  // Note(atulep): Defined this here to allow testing (otherwise was getting build errors)
    50  func New(secret []byte, projectRoot string) Studio {
    51  	return Studio{clientSecretJSON: secret, root: projectRoot}
    52  }
    53  
    54  // Download places the files from sample project into dest. Returns an error if any.
    55  func (p Studio) Download(sample project.SampleProject, dest string) error {
    56  	return downloadFromGit(sample.Name, sample.HostedURL, dest)
    57  }
    58  
    59  func downloadFromGit(projectTitle, url, dest string) error {
    60  	resp, err := http.Get(url)
    61  	if err != nil {
    62  		return err
    63  	}
    64  	defer resp.Body.Close()
    65  	if resp.StatusCode != 200 {
    66  		return fmt.Errorf("can not download from %v", url)
    67  	}
    68  	b, err := ioutil.ReadAll(resp.Body)
    69  	if err != nil {
    70  		return err
    71  	}
    72  	return unzipZippedDir(dest, b)
    73  }
    74  
    75  func unzipZippedDir(dest string, content []byte) error {
    76  	// Open a zip archive for reading.
    77  	r, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
    78  	if err != nil {
    79  		return err
    80  	}
    81  	if err := os.MkdirAll(dest, 0750); err != nil {
    82  		return err
    83  	}
    84  	// The shortest name will be directory name that was unzipped.
    85  	sort.Slice(r.File, func(i, j int) bool {
    86  		return r.File[i].Name < r.File[j].Name
    87  	})
    88  	dir := filepath.Join(filepath.FromSlash(dest), r.File[0].Name)
    89  	log.Infof("Unzipping %v", dir)
    90  	for _, f := range r.File[1:] {
    91  		fp, err := filepath.Rel(r.File[0].Name, f.Name)
    92  		if err != nil {
    93  			return err
    94  		}
    95  		fp = filepath.Join(dest, fp)
    96  		fp = filepath.FromSlash(fp)
    97  
    98  		if f.Mode().IsDir() {
    99  			if err := os.MkdirAll(fp, 0750); err != nil {
   100  				return err
   101  			}
   102  			continue
   103  		}
   104  
   105  		rc, err := f.Open()
   106  		if err != nil {
   107  			return err
   108  		}
   109  		b, err := ioutil.ReadAll(rc)
   110  		if err != nil {
   111  			return err
   112  		}
   113  		log.Infof("Writing %v\n", fp)
   114  		if err := ioutil.WriteFile(fp, b, 0640); err != nil {
   115  			return err
   116  		}
   117  		if err := rc.Close(); err != nil {
   118  			return err
   119  		}
   120  	}
   121  	return nil
   122  }
   123  
   124  // isLocalizedSettings returns whether a file named filename is a
   125  // localized settings file. An example of localized settings is
   126  // "settings/zh-TW/settings.yaml", and example of non-localized settings is
   127  // "settings/settings.yaml", where "zh-TW" represents a locale.
   128  func isLocalizedSettings(filename string) bool {
   129  	// This is a heuristic that checks if the parent directory of
   130  	// the filename is not "settings", which means it's probably a locale.
   131  	subpaths := strings.Split(filename, string(os.PathSeparator))
   132  	if len(subpaths) < 2 {
   133  		return false
   134  	}
   135  	secondToLast := subpaths[len(subpaths)-2]
   136  	return secondToLast != "settings"
   137  }
   138  
   139  func isConfigFile(filename string) bool {
   140  	return IsVertical(filename) ||
   141  		IsManifest(filename) ||
   142  		IsSettings(filename) ||
   143  		IsActions(filename) ||
   144  		IsIntent(filename) ||
   145  		IsGlobal(filename) ||
   146  		IsScene(filename) ||
   147  		IsType(filename) ||
   148  		IsEntitySet(filename) ||
   149  		IsWebhookDefinition(filename) ||
   150  		IsResourceBundle(filename) ||
   151  		IsPrompt(filename) ||
   152  		IsDeviceFulfillment(filename) ||
   153  		IsAccountLinkingSecret(filename)
   154  }
   155  
   156  // IsWebhookDefinition reteurns true if the file contains a  yaml definition of the webhook.
   157  func IsWebhookDefinition(filename string) bool {
   158  	return IsWebhook(filename) && path.Ext(filename) == ".yaml"
   159  }
   160  
   161  // IsVertical returns true if the file contains vertical config files.
   162  func IsVertical(filename string) bool {
   163  	return strings.HasPrefix(filename, "verticals") && path.Ext(filename) == ".yaml"
   164  }
   165  
   166  // IsManifest returns true if the file contains a manifest of an Actions project.
   167  func IsManifest(filename string) bool {
   168  	return path.Base(filename) == "manifest.yaml"
   169  }
   170  
   171  // IsSettings returns true if the file contains settings of an Actions project.
   172  func IsSettings(filename string) bool {
   173  	return path.Base(filename) == "settings.yaml"
   174  }
   175  
   176  // IsActions returns true if the file contains an Action declaration of an Actions project.
   177  func IsActions(filename string) bool {
   178  	return path.Base(filename) == "actions.yaml"
   179  }
   180  
   181  // IsIntent returns true if the file contains an intent definition of an Actions project.
   182  func IsIntent(filename string) bool {
   183  	return strings.HasPrefix(filename, path.Join("custom", "intents")) && path.Ext(filename) == ".yaml"
   184  }
   185  
   186  // IsGlobal returns true if the file contains a global scene interaction declaration
   187  // of an Actions project.
   188  func IsGlobal(filename string) bool {
   189  	return strings.HasPrefix(filename, path.Join("custom", "global")) && path.Ext(filename) == ".yaml"
   190  }
   191  
   192  // IsScene returns true if the file contains a scene declaration of an Actions project.
   193  func IsScene(filename string) bool {
   194  	return strings.HasPrefix(filename, path.Join("custom", "scenes")) && path.Ext(filename) == ".yaml"
   195  }
   196  
   197  // IsType returns true if the file contains a type declaration of an Actions project.
   198  func IsType(filename string) bool {
   199  	return strings.HasPrefix(filename, path.Join("custom", "types")) && path.Ext(filename) == ".yaml"
   200  }
   201  
   202  // IsEntitySet returns true if the file contains an entity set declaration of an Actions project.
   203  func IsEntitySet(filename string) bool {
   204  	return strings.HasPrefix(filename, path.Join("custom", "entitySets")) && path.Ext(filename) == ".yaml"
   205  }
   206  
   207  // IsWebhook returns true if the file contains a webhook files of an Actions project.
   208  // This includes yaml and code files.
   209  func IsWebhook(filename string) bool {
   210  	return strings.HasPrefix(filename, path.Join("webhooks"))
   211  }
   212  
   213  // IsPrompt returns true if the file contains a prompt of an Actions project.
   214  func IsPrompt(filename string) bool {
   215  	return strings.HasPrefix(filename, path.Join("custom", "prompts")) && path.Ext(filename) == ".yaml"
   216  }
   217  
   218  // IsDeviceFulfillment returns true if the file contains a device fulfillment declaration of a device Actions project.
   219  // Note: This value is not publicly available
   220  func IsDeviceFulfillment(filename string) bool {
   221  	return strings.HasPrefix(filename, "device") && path.Ext(filename) == ".yaml"
   222  }
   223  
   224  // IsResourceBundle returns true if the file contains a resource bundle. This will return true if
   225  // filename for either localized or base resource bundle.
   226  func IsResourceBundle(filename string) bool {
   227  	return strings.HasPrefix(filename, path.Join("resources", "strings")) && path.Ext(filename) == ".yaml"
   228  }
   229  
   230  // IsAccountLinkingSecret returns true if the file contains an account linking secret. The file
   231  // must have the name settings/accountLinkingSecret.yaml.
   232  func IsAccountLinkingSecret(filename string) bool {
   233  	return strings.HasPrefix(filename, path.Join("settings", "accountLinkingSecret.yaml"))
   234  }
   235  
   236  // ConfigFiles finds configuration files from the files of a project.
   237  func ConfigFiles(files map[string][]byte) map[string][]byte {
   238  	configFiles := map[string][]byte{}
   239  	for k, v := range files {
   240  		if isConfigFile(k) {
   241  			configFiles[k] = v
   242  		}
   243  	}
   244  	return configFiles
   245  }
   246  
   247  var askYesNo = func(msg string) (string, error) {
   248  	log.Outf("%v. [y/n]", msg)
   249  	var ans string
   250  	_, err := fmt.Scan(&ans)
   251  	if err != nil {
   252  		return "", err
   253  	}
   254  	norm := strings.ToLower(ans)
   255  	if norm == "y" || norm == "yes" {
   256  		return "yes", nil
   257  	}
   258  	if norm == "n" || norm == "no" {
   259  		return "no", nil
   260  	}
   261  	return "", fmt.Errorf("invalid option specified: %v", ans)
   262  }
   263  
   264  // WriteToDisk writes content into path located in local file system. Path is relative
   265  // to project root (i.e. same level as manifest.yaml). This function will appropriately
   266  // combine value of path with project root to write the file in an appropriate location.
   267  // ContentType needs to be non-empty for data files; config files can have an empty string.
   268  func WriteToDisk(proj project.Project, path string, contentType string, payload []byte, force bool) error {
   269  	path = filepath.FromSlash(path)
   270  	if proj.ProjectRoot() != "" {
   271  		path = filepath.Join(proj.ProjectRoot(), path)
   272  	}
   273  	if contentType == "application/zip;zip_type=cloud_function" {
   274  		path = path[:len(path)-len(".zip")]
   275  	}
   276  	if exists(path) {
   277  		var ans string
   278  		if !force {
   279  			r, err := askYesNo(fmt.Sprintf("%v already exists. Would you like to overwrite it?", path))
   280  			if err != nil {
   281  				return err
   282  			}
   283  			ans = r
   284  		}
   285  		if ans == "yes" || force {
   286  			log.Infof("Removing %v\n", path)
   287  			if err := os.RemoveAll(path); err != nil {
   288  				return err
   289  			}
   290  		} else {
   291  			log.Infof("Skipping %v\n", path)
   292  			return nil
   293  		}
   294  	}
   295  	// proj.ProjectRoot() already exists, but old value of path may have project-specific subdirs that need to be created.
   296  	if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil {
   297  		return err
   298  	}
   299  	if contentType == "application/zip;zip_type=cloud_function" {
   300  		return unzipFiles(path, payload)
   301  	}
   302  	log.Infof("Writing %v\n", path)
   303  	return ioutil.WriteFile(path, payload, 0640)
   304  }
   305  
   306  func unzipFiles(dir string, content []byte) error {
   307  	// Open a zip archive for reading.
   308  	r, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
   309  	if err != nil {
   310  		return err
   311  	}
   312  	for _, f := range r.File {
   313  		fp := filepath.Join(dir, f.Name)
   314  		fp = filepath.FromSlash(fp)
   315  		rc, err := f.Open()
   316  		if err != nil {
   317  			return err
   318  		}
   319  		b, err := ioutil.ReadAll(rc)
   320  		if err != nil {
   321  			return err
   322  		}
   323  		if err := os.MkdirAll(filepath.Dir(fp), 0750); err != nil {
   324  			return err
   325  		}
   326  		log.Infof("Writing %v\n", fp)
   327  		if err := ioutil.WriteFile(fp, b, 0640); err != nil {
   328  			return err
   329  		}
   330  		rc.Close()
   331  	}
   332  	return nil
   333  }
   334  
   335  func zipFiles(files map[string][]byte) ([]byte, error) {
   336  	buf := new(bytes.Buffer)
   337  	w := zip.NewWriter(buf)
   338  	for name, content := range files {
   339  		// Server expects Cloud Functions to have the filePath stripped
   340  		// (i.e. webhooks/myfunction/index.js -> ./index.js)
   341  		f, err := w.Create(path.Base(name))
   342  		if err != nil {
   343  			return nil, err
   344  		}
   345  		_, err = f.Write(content)
   346  		if err != nil {
   347  			return nil, err
   348  		}
   349  	}
   350  	err := w.Close()
   351  	if err != nil {
   352  		return nil, err
   353  	}
   354  	return buf.Bytes(), nil
   355  }
   356  
   357  // addInlineWebhooks adds a zipped inline webhook code, if any, to dataFiles.
   358  func addInlineWebhooks(dataFiles map[string][]byte, files map[string][]byte, root string) error {
   359  	yamls := map[string][]byte{}
   360  	// "code" includes all of the code files under the webhooks directory.
   361  	// This includes both external and inline cloud functions. It will be
   362  	// be used to include inline cloud functions later in the function.
   363  	code := map[string][]byte{}
   364  	for k, v := range files {
   365  		if IsWebhook(k) {
   366  			if IsWebhookDefinition(k) {
   367  				yamls[k] = v
   368  			} else {
   369  				code[k] = v
   370  			}
   371  		}
   372  	}
   373  	for k, v := range yamls {
   374  		mp, err := yamlutils.UnmarshalYAMLToMap(v)
   375  		if err != nil {
   376  			return fmt.Errorf("%v has incorrect syntax: %v", filepath.Join(root, k), err)
   377  		}
   378  		if _, ok := mp["inlineCloudFunction"]; ok {
   379  			filesToZip := map[string][]byte{}
   380  			// Name of the file must match the name of the folder hosting the code for the inline function
   381  			// For example, "webhooks/a.yaml" means "webhooks/a/*" must exist.
   382  			basename := path.Base(k)
   383  			name := basename[:len(basename)-len(path.Ext(basename))]
   384  			funcFolder := path.Join("webhooks", name)
   385  			for k2, v2 := range code {
   386  				// Inline cloud function should just have index.js and package.json
   387  				if strings.HasPrefix(k2, funcFolder) && !strings.Contains(k2, "node_modules") && (path.Ext(k2) == ".js" || path.Ext(k2) == ".json") {
   388  					filesToZip[k2] = v2
   389  				}
   390  			}
   391  			if len(filesToZip) == 0 {
   392  				return fmt.Errorf("folder for inline cloud function is not found for %v", k)
   393  			}
   394  			content, err := zipFiles(filesToZip)
   395  			if err != nil {
   396  				return err
   397  			}
   398  			dataFiles[funcFolder+".zip"] = content
   399  		} else {
   400  			log.Debugf("Found external cloud function: %v\n", filepath.Join(root, k))
   401  		}
   402  	}
   403  	return nil
   404  }
   405  
   406  // DataFiles finds data files from the files of a project.
   407  func DataFiles(p project.Project) (map[string][]byte, error) {
   408  	dataFiles := map[string][]byte{}
   409  	files, err := p.Files()
   410  	if err != nil {
   411  		return nil, err
   412  	}
   413  	for k, v := range files {
   414  		if strings.HasPrefix(k, "resources/") && !IsResourceBundle(k) {
   415  			dataFiles[k] = v
   416  		}
   417  	}
   418  	if err := addInlineWebhooks(dataFiles, files, p.ProjectRoot()); err != nil {
   419  		return nil, err
   420  	}
   421  	return dataFiles, nil
   422  }
   423  
   424  // ProjectID finds a project id of a project.
   425  func ProjectID(proj project.Project) (string, error) {
   426  	// Note: `k` may have some parent subpath that is hard to predict, so
   427  	// forced to iterate through keys instead of indexing directly.
   428  	files, err := proj.Files()
   429  	if err != nil {
   430  		return "", err
   431  	}
   432  	for k, v := range files {
   433  		if path.Base(k) == "settings.yaml" && !isLocalizedSettings(k) {
   434  			mp, err := yamlutils.UnmarshalYAMLToMap(v)
   435  			if err != nil {
   436  				return "", fmt.Errorf("%v has incorrect syntax: %v", k, err)
   437  			}
   438  			if pid, present := mp["projectId"]; present {
   439  				if pid == "placeholder_project" {
   440  					log.Warnf("%v is not a valid project id. Update %s/settings/settings.yaml file with your Google project id found in your GCP console. E.g. \"123456789\"", pid, proj.ProjectRoot())
   441  				}
   442  				spid, ok := pid.(string)
   443  				if !ok {
   444  					return "", fmt.Errorf("invalid project ID: %v", pid)
   445  				}
   446  				return spid, nil
   447  			}
   448  			return "", errors.New("projectId is not present in the settings file")
   449  		}
   450  	}
   451  	return "", errors.New("can't find a project id: settings.yaml not found")
   452  }
   453  
   454  // AlreadySetup returns true if pathToWorkDir already contains a complete
   455  // studio project.
   456  func (p Studio) AlreadySetup(pathToWorkDir string) bool {
   457  	// Note: This will return true when pathToWorkDir contains
   458  	// hidden directories, such .git
   459  	return exists(pathToWorkDir) && !isDirEmpty(pathToWorkDir)
   460  }
   461  
   462  // exists returns whether the given file or directory exists or not
   463  func exists(path string) bool {
   464  	if _, err := os.Stat(path); err != nil {
   465  		return os.IsExist(err)
   466  	}
   467  	return true
   468  }
   469  
   470  // isDirEmpty returns true if the given directory is empty, otherwise false.
   471  func isDirEmpty(dir string) bool {
   472  	l, err := ioutil.ReadDir(dir)
   473  	if err != nil {
   474  		return false
   475  	}
   476  	var norm []os.FileInfo
   477  	// Skip hidden files and directories, such as .git.
   478  	for _, v := range l {
   479  		if !strings.HasPrefix(v.Name(), ".") {
   480  			norm = append(norm, v)
   481  		}
   482  	}
   483  	return len(norm) <= 0
   484  }
   485  
   486  // winToUnix converts path from win to unix
   487  func winToUnix(path string) string {
   488  	return strings.Replace(path, "\\", "/", -1)
   489  }
   490  
   491  // ProjectRoot returns a root directory of a project. If root directory is not found, the
   492  // returned string will be empty (i.e. "")
   493  func (p Studio) ProjectRoot() string {
   494  	return p.root
   495  }
   496  
   497  func isHidden(path string) bool {
   498  	slashed := filepath.ToSlash(path)
   499  	parts := strings.Split(slashed, "/")
   500  	for _, v := range parts {
   501  		if strings.HasPrefix(v, ".") {
   502  			return true
   503  		}
   504  	}
   505  	return false
   506  }
   507  
   508  // Files returns project files as a (filename string, content []byte) pair.
   509  func (p Studio) Files() (map[string][]byte, error) {
   510  	if p.files != nil {
   511  		return p.files, nil
   512  	}
   513  	var m = make(map[string][]byte)
   514  	err := filepath.Walk(p.ProjectRoot(), func(path string, info os.FileInfo, err error) error {
   515  		if err != nil {
   516  			return err
   517  		}
   518  		relPath, err := relativePath(p.ProjectRoot(), path)
   519  		if err != nil {
   520  			return err
   521  		}
   522  		if !info.IsDir() && !isHidden(relPath) {
   523  			// SDK server expects filepath to be separated using a '/'.
   524  			if runtime.GOOS == "windows" {
   525  				m[winToUnix(relPath)], err = ioutil.ReadFile(path)
   526  			} else {
   527  				// Do not convert a Unix path because it may have a mix of \\ and / in the path
   528  				// as Linux allows it (i.e. mkdir hello\\world is valid on Linux)
   529  				m[relPath], err = ioutil.ReadFile(path)
   530  			}
   531  			return err
   532  		}
   533  		return nil
   534  	})
   535  	if err != nil {
   536  		return nil, err
   537  	}
   538  	p.files = m
   539  	return m, nil
   540  }
   541  
   542  // ClientSecretJSON returns a client secret used to communicate with an external API.
   543  func (p Studio) ClientSecretJSON() ([]byte, error) {
   544  	return p.clientSecretJSON, nil
   545  }
   546  
   547  // ProjectID returns a Google Project ID associated with developer's Action, which should be safe to insert into the URL.
   548  func (p Studio) ProjectID() string {
   549  	return url.PathEscape(p.projectID)
   550  }
   551  
   552  // SetProjectID sets projectID for studio. It can come from two possible places:
   553  // settings.yaml or command line flag.
   554  // Case 1: If projectID is missing in both settings.yaml and command line flag, return an error.
   555  // Case 2: If projectID is missing in the command line flag, and projectID in settings.yaml is "placeholder_project", show a warning.
   556  // Case 3: If projectID is missing in the command line flag, and projectID in settings.yaml is something other than "placeholder_project", proceed with no warnings.
   557  // Case 4: If projectID is present in the command line flag, and absent in settings.yaml, proceed with no warnings.
   558  // Case 5: If projectID is present in the command line flag, and projectID in settings.yaml is "placeholder_project", show an info message.
   559  // Case 6: If projectID is present in both places, show an info message.
   560  func (p *Studio) SetProjectID(flag string) error {
   561  	if p.ProjectID() != "" {
   562  		return errors.New("can not reset the project ID")
   563  	}
   564  	pid, err := pidFromSettings(p.ProjectRoot())
   565  	if err != nil && flag == "" {
   566  		// Case 1.
   567  		log.Errorf(`Project ID is missing. Specify the project ID in %s/settings/settings.yaml, or via flag, if applicable.`, p.ProjectRoot())
   568  		return errors.New("no project ID is specified")
   569  	} else if err == nil && flag == "" && pid == "placeholder_project" {
   570  		// Case 2.
   571  		log.Warnf("%v is not a valid project id. Update %v file with your Google project id found in your GCP console. E.g. \"123456789\" or specify a project id via a flag.", pid, filepath.Join(p.ProjectRoot(), "settings", "settings.yaml"))
   572  		p.projectID = pid
   573  	} else if err == nil && flag != "" && flag != pid {
   574  		// Case 5,6.
   575  		log.Infof("Two Google Project IDs are specified: %q via the flag, %q via the settings file. %q takes a priority and will be used in the remainder of the command.", flag, pid, flag)
   576  		p.projectID = flag
   577  	} else if flag != "" {
   578  		// Case 4.
   579  		p.projectID = flag
   580  	} else {
   581  		// Case 3.
   582  		p.projectID = pid
   583  	}
   584  	log.Infof("Using %q.\n", p.ProjectID())
   585  	return nil
   586  }
   587  
   588  // SetProjectRoot sets project a root for studio project. It should only be called
   589  // if project root doesn't yet exist, but will be created as a result of a subroutine
   590  // that called SetProjectRoot. In this case, project root will become current working directory.
   591  func (p *Studio) SetProjectRoot() error {
   592  	if p.root != "" {
   593  		return errors.New("can not reset project root")
   594  	}
   595  	r, err := FindProjectRoot()
   596  	if err != nil {
   597  		// If .gactionsrc exists, but has empty/missing sdkPath key,
   598  		// we should fail.
   599  		if _, err = findFileUp(project.ConfigName); err == nil {
   600  			return errors.New(".gactionsrc was present, but sdkPath key is missing")
   601  		}
   602  		wd, err := os.Getwd()
   603  		if err != nil {
   604  			return err
   605  		}
   606  		p.root = wd
   607  		return nil
   608  	}
   609  	p.root = r
   610  	return nil
   611  }
   612  
   613  func findFileUp(filename string) (string, error) {
   614  	cur, err := os.Getwd()
   615  	if err != nil {
   616  		return "", err
   617  	}
   618  	for !exists(filepath.Join(cur, filename)) {
   619  		parent := filepath.Dir(cur)
   620  		if parent == cur {
   621  			return cur, errors.New(filename)
   622  		}
   623  		cur = parent
   624  	}
   625  	return cur, nil
   626  }
   627  
   628  // FindProjectRoot locates the root of the SDK project.
   629  // It works by obtaining sdkPath field from CLI config (.gactionsrc.yaml),
   630  // which it finds by recursively traversing upwards.
   631  // sdkPath must be a non-empty string representing a path to sdk files.
   632  // Path can be relative or absolute. If CLI config is not found, CLI
   633  // will fallback to finding manifest.yaml.
   634  func FindProjectRoot() (string, error) {
   635  	configPath, err := findFileUp(project.ConfigName)
   636  	if err == nil {
   637  		f, err := ioutil.ReadFile(filepath.Join(configPath, project.ConfigName))
   638  		if err != nil {
   639  			return "", err
   640  		}
   641  		configFile := project.CLIConfig{}
   642  		if err = yaml.Unmarshal(f, &configFile); err != nil {
   643  			return "", err
   644  		}
   645  		// In case, Windows developers use forward slash, we should convert it to \\.
   646  		configFile.SdkPath = filepath.FromSlash(configFile.SdkPath)
   647  		if configFile.SdkPath == "" {
   648  			return "", fmt.Errorf("sdkPath is %s, but must be non-empty", configFile.SdkPath)
   649  		}
   650  		if filepath.IsAbs(configFile.SdkPath) {
   651  			return configFile.SdkPath, nil
   652  		}
   653  		return filepath.Join(configPath, configFile.SdkPath), nil
   654  	}
   655  	log.Infof(`Unable to find %q.`, project.ConfigName)
   656  	sdkDir, err := findFileUp("manifest.yaml")
   657  	if err != nil {
   658  		log.Infof(`Unable to find "manifest.yaml".`)
   659  		return "", err
   660  	}
   661  	return sdkDir, nil
   662  }
   663  
   664  func pidFromSettings(root string) (string, error) {
   665  	fp := filepath.Join(root, "settings", "settings.yaml")
   666  	b, err := ioutil.ReadFile(fp)
   667  	if err != nil {
   668  		return "", err
   669  	}
   670  	mp, err := yamlutils.UnmarshalYAMLToMap(b)
   671  	if err != nil {
   672  		return "", fmt.Errorf("%v has incorrect syntax: %v", fp, err)
   673  	}
   674  	type settings struct {
   675  		ProjectID string `json:"projectId"`
   676  	}
   677  	b, err = json.Marshal(mp)
   678  	if err != nil {
   679  		return "", err
   680  	}
   681  	set := settings{}
   682  	if err := json.Unmarshal(b, &set); err != nil {
   683  		return "", err
   684  	}
   685  	if set.ProjectID == "" {
   686  		return "", errors.New("projectId is not present in the settings file")
   687  	}
   688  	return set.ProjectID, nil
   689  }
   690  
   691  func relativePath(root, path string) (string, error) {
   692  	// root has OS specific separators, but path does not.
   693  	platSpecific := filepath.FromSlash(path)
   694  	return filepath.Rel(root, platSpecific)
   695  }