package main // A workspace and the packages a step runs against (novox/hq ADR 0469 §2 and §5; issues 1490, 1491). A workspace is // //tree, made by the fetch step. The toolchain's packages are copied to // // node_modules, which lies above the tree: node, tsc and esbuild resolve an import by walking up from the importing // file, so @novox/mesh-sdk and the types are found there while the tree itself, which a bundle is packed from, stays // what the commit holds. A copy and not a link, because the mesh makes no symlink. import ( "context" "fmt" "io" "io/fs" "os" "path/filepath" "regexp" "strings" "git.novox.be/novox/mesh-sdk/go/forgekey" ) var workspaceOK = regexp.MustCompile(`^[a-z0-9]+$`) // notCopied are the toolchain's own tools: a bundle imports none of them, and they are tens of megabytes. var notCopied = map[string]bool{"typescript": true, "esbuild": true, "@esbuild": true} // moduleDirOf answers the directory of a module in a workspace and the workspace's root (the step's directory), // refusing an id or a directory that is not what a fetch made. func (m *Module) moduleDirOf(workspace, module string) (root, dir string, err error) { if !workspaceOK.MatchString(workspace) { return "", "", fmt.Errorf("%q is not a workspace id", workspace) } if module == "" { module = "." } clean := filepath.Clean(filepath.FromSlash(module)) if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return "", "", fmt.Errorf("the module directory %q is not inside the workspace", module) } root = filepath.Join(m.Work, workspace) dir = filepath.Join(root, "tree", clean) if info, err := os.Stat(dir); err != nil || !info.IsDir() { return "", "", fmt.Errorf("workspace %s holds no directory %q", workspace, module) } return root, dir, nil } // toolchainDir is the installed toolchain directory of the recipe in force. func (m *Module) toolchainDir() string { return m.Toolchain.Paths.Dir(m.recipe().Version()) } // tsc and esbuild are the toolchain's own programs, run by their real paths. func (m *Module) tsc() string { return filepath.Join(m.toolchainDir(), "node_modules", "typescript", "bin", "tsc") } func (m *Module) esbuild() string { return filepath.Join(m.toolchainDir(), "node_modules", "esbuild", "bin", "esbuild") } // provide copies the toolchain's packages to /node_modules unless they are there. func (m *Module) provide(root string) error { if err := m.writeBin(filepath.Join(root, "bin")); err != nil { return err } dest := filepath.Join(root, "node_modules") if _, err := os.Stat(dest); err == nil { return nil } return copyPackages(filepath.Join(m.toolchainDir(), "node_modules"), dest) } // binDir is the workspace's bin directory: small shell scripts, files and not links, that call the toolchain's tsc and // esbuild, so a package's scripts (`tsc`, `esbuild` by name) find them. npm ci runs with --no-bin-links, which leaves // the toolchain no .bin directory of its own. func binDir(root string) string { return filepath.Join(root, "bin") } func (m *Module) writeBin(dir string) error { if err := os.MkdirAll(dir, 0o755); err != nil { return err } for name, body := range map[string]string{ "tsc": "exec node '" + m.tsc() + "' \"$@\"\n", "esbuild": "exec '" + m.esbuild() + "' \"$@\"\n", } { path := filepath.Join(dir, name) if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o755); err != nil { return err } if err := os.Chmod(path, 0o755); err != nil { return err } } return nil } // copyTree copies a directory of regular files and directories, keeping permission bits, except a build's output and // the repository's own .git. Anything else in it (a link) is an error: the mesh makes no symlink. func copyTree(from, to string) error { return filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } rel, _ := filepath.Rel(from, path) if d.IsDir() && (rel == ".mesh-build" || rel == ".git") { return filepath.SkipDir } switch { case d.IsDir(): return os.MkdirAll(filepath.Join(to, rel), 0o755) case d.Type().IsRegular(): return copyFile(path, filepath.Join(to, rel)) default: return fmt.Errorf("%s is neither a file nor a directory, and the build copies only those", path) } }) } // copyPackages copies the packages of a node_modules directory except the toolchain's own tools. It copies regular // files and directories only and keeps their permission bits; anything else in the source is an error. func copyPackages(from, to string) error { if err := os.MkdirAll(to, 0o755); err != nil { return err } return filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } rel, _ := filepath.Rel(from, path) if rel == "." { return nil } if top := strings.Split(rel, string(filepath.Separator))[0]; notCopied[top] || top == ".package-lock.json" || top == ".bin" { if d.IsDir() { return filepath.SkipDir } return nil } switch { case d.IsDir(): return os.MkdirAll(filepath.Join(to, rel), 0o755) case d.Type().IsRegular(): return copyFile(path, filepath.Join(to, rel)) default: return fmt.Errorf("%s is neither a file nor a directory, and the packages copied carry only those", path) } }) } func copyFile(from, to string) error { in, err := os.Open(from) if err != nil { return err } defer in.Close() info, err := in.Stat() if err != nil { return err } out, err := os.OpenFile(to, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm()) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { out.Close() return err } return out.Close() } // runIn runs a command with dir as its working directory (`env -C`), so it is the plain line the build agent runs from // the module's root, with the same relative paths. The command's environment is baseEnv and extra. func (m *Module) runIn(ctx context.Context, dir string, extra []string, name string, args ...string) forgekey.Ran { return m.Run(ctx, append(m.baseEnv(), extra...), "env", append([]string{"-C", dir, name}, args...)...) } // ranFailed says why a command did not pass, or "" when it did. func ranFailed(r forgekey.Ran) string { if r.Status == 0 && r.Err == "" { return "" } out := strings.TrimSpace(r.Stdout + "\n" + r.Stderr) if len(out) > 1500 { out = out[:1500] + " ..." } return fmt.Sprintf("status %d %s: %s", r.Status, r.Err, out) }