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 }