188 lines
6.4 KiB
Go
188 lines
6.4 KiB
Go
package main
|
|
|
|
// A workspace and the packages a step runs against (novox/hq ADR 0469 §2 and §5; issues 1490, 1491). A workspace is
|
|
// <work dir>/<step id>/tree, made by the fetch step. The toolchain's packages are copied to <work dir>/<step id>/
|
|
// 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 <root>/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)
|
|
}
|