Files
goclaw/internal/skills/npm_workspace_fallback.go
Goon 99460a7f8a fix(packages): repack npm fallback without scripts
Create sanitized npm tarballs directly in Go so workspace dependency fallback does not run package lifecycle scripts or create global symlinks to temporary folders.
2026-05-18 16:04:12 +07:00

373 lines
10 KiB
Go

package skills
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
var npmPackageVersionResolver = npmViewPackageVersion
func installNpmPackage(ctx context.Context, target string) ([]byte, error) {
out, err := runNpmInstall(ctx, target)
if err == nil || !npmOutputHasWorkspaceProtocolError(string(out)) {
return out, err
}
slog.Warn("skills: npm package contains workspace protocol deps; retrying with sanitized tarball", "target", target)
fallbackOut, fallbackErr := installNpmPackageWithWorkspaceRewrite(ctx, target)
if fallbackErr == nil {
return fallbackOut, nil
}
return appendNpmFallbackOutput(out, fallbackOut), fallbackErr
}
func runNpmInstall(ctx context.Context, target string) ([]byte, error) {
if err := os.MkdirAll(npmGlobalPrefix(), 0o750); err != nil {
return nil, fmt.Errorf("npm prefix setup: %w", err)
}
ensureNpmGlobalEnv()
cmd := exec.CommandContext(ctx, npmBinary, "install", "-g", target)
cmd.Env = npmCommandEnv()
cmd.WaitDelay = 2 * time.Second
return cmd.CombinedOutput()
}
func npmOutputHasWorkspaceProtocolError(out string) bool {
return strings.Contains(out, "EUNSUPPORTEDPROTOCOL") &&
(strings.Contains(out, `Unsupported URL Type "workspace:"`) || strings.Contains(out, "workspace:"))
}
func installNpmPackageWithWorkspaceRewrite(ctx context.Context, target string) ([]byte, error) {
tmpDir, err := os.MkdirTemp("", "goclaw-npm-workspace-*")
if err != nil {
return nil, fmt.Errorf("npm workspace fallback temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
tarball, packOut, err := npmPackTarball(ctx, target, tmpDir)
if err != nil {
return packOut, err
}
extractDir := filepath.Join(tmpDir, "extract")
if err := os.MkdirAll(extractDir, 0o750); err != nil {
return packOut, fmt.Errorf("npm workspace fallback extract dir: %w", err)
}
if err := extractNpmTarballToDir(tarball, extractDir); err != nil {
return packOut, err
}
packageDir := filepath.Join(extractDir, "package")
rewrites, err := rewriteWorkspacePackageJSON(ctx, filepath.Join(packageDir, "package.json"))
if err != nil {
return packOut, err
}
if rewrites == 0 {
return packOut, errors.New("npm workspace fallback found no workspace dependencies to rewrite")
}
repackDir := filepath.Join(tmpDir, "repack")
if err := os.MkdirAll(repackDir, 0o750); err != nil {
return packOut, fmt.Errorf("npm workspace fallback repack dir: %w", err)
}
sanitizedTarball, err := packNpmPackageDir(packageDir, repackDir)
if err != nil {
return packOut, err
}
installOut, err := runNpmInstall(ctx, sanitizedTarball)
return appendNpmFallbackOutput(packOut, installOut), err
}
func npmPackTarball(ctx context.Context, target, destination string) (string, []byte, error) {
cmd := exec.CommandContext(ctx, npmBinary, "pack", "--json", "--pack-destination", destination, target)
cmd.Env = npmCommandEnv()
cmd.WaitDelay = 2 * time.Second
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
out := appendNpmFallbackOutput(stdout.Bytes(), stderr.Bytes())
return "", out, fmt.Errorf("npm pack fallback failed: %w", err)
}
var entries []struct {
Filename string `json:"filename"`
}
if err := json.Unmarshal(stdout.Bytes(), &entries); err != nil {
return "", stdout.Bytes(), fmt.Errorf("npm pack fallback parse: %w", err)
}
if len(entries) == 0 || strings.TrimSpace(entries[0].Filename) == "" {
return "", stdout.Bytes(), errors.New("npm pack fallback returned no tarball")
}
tarball := filepath.Join(destination, filepath.Base(entries[0].Filename))
if _, err := os.Stat(tarball); err != nil {
return "", stdout.Bytes(), fmt.Errorf("npm pack fallback tarball missing: %w", err)
}
return tarball, stdout.Bytes(), nil
}
func rewriteWorkspacePackageJSON(ctx context.Context, path string) (int, error) {
raw, err := os.ReadFile(path)
if err != nil {
return 0, fmt.Errorf("read package.json: %w", err)
}
var pkg map[string]any
if err := json.Unmarshal(raw, &pkg); err != nil {
return 0, fmt.Errorf("parse package.json: %w", err)
}
rewrites := 0
for _, section := range []string{"dependencies", "optionalDependencies", "peerDependencies"} {
deps, ok := pkg[section].(map[string]any)
if !ok {
continue
}
for name, value := range deps {
spec, ok := value.(string)
if !ok || !strings.HasPrefix(spec, "workspace:") {
continue
}
resolved, err := resolveWorkspaceDependencySpec(ctx, name, spec)
if err != nil {
return rewrites, err
}
deps[name] = resolved
rewrites++
}
}
if rewrites == 0 {
return 0, nil
}
updated, err := json.MarshalIndent(pkg, "", " ")
if err != nil {
return 0, fmt.Errorf("encode package.json: %w", err)
}
updated = append(updated, '\n')
if err := os.WriteFile(path, updated, 0o600); err != nil {
return 0, fmt.Errorf("write package.json: %w", err)
}
return rewrites, nil
}
func resolveWorkspaceDependencySpec(ctx context.Context, name, spec string) (string, error) {
suffix := strings.TrimSpace(strings.TrimPrefix(spec, "workspace:"))
switch suffix {
case "", "*":
return npmPackageVersionResolver(ctx, name)
case "^", "~":
version, err := npmPackageVersionResolver(ctx, name)
if err != nil {
return "", err
}
return suffix + version, nil
default:
if strings.HasPrefix(suffix, ".") || strings.HasPrefix(suffix, "/") {
return "", fmt.Errorf("unsupported workspace dependency path for %s: %s", name, spec)
}
return suffix, nil
}
}
func npmViewPackageVersion(ctx context.Context, name string) (string, error) {
cmd := exec.CommandContext(ctx, npmBinary, "view", name, "version", "--json")
cmd.Env = npmCommandEnv()
cmd.WaitDelay = 2 * time.Second
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("npm view %s version: %w", name, err)
}
var version string
if err := json.Unmarshal(out, &version); err != nil {
version = strings.Trim(strings.TrimSpace(string(out)), `"`)
}
if version == "" {
return "", fmt.Errorf("npm view %s version returned empty version", name)
}
return version, nil
}
func packNpmPackageDir(packageDir, destination string) (string, error) {
raw, err := os.ReadFile(filepath.Join(packageDir, "package.json"))
if err != nil {
return "", fmt.Errorf("read package.json for repack: %w", err)
}
var pkg struct {
Name string `json:"name"`
Version string `json:"version"`
}
if err := json.Unmarshal(raw, &pkg); err != nil {
return "", fmt.Errorf("parse package.json for repack: %w", err)
}
filename := npmTarballFilename(pkg.Name, pkg.Version)
if filename == "" {
return "", errors.New("npm workspace fallback cannot derive tarball filename")
}
tarball := filepath.Join(destination, filename)
out, err := os.OpenFile(tarball, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return "", fmt.Errorf("create sanitized npm tarball: %w", err)
}
defer out.Close()
gz := gzip.NewWriter(out)
tw := tar.NewWriter(gz)
walkErr := filepath.WalkDir(packageDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("npm workspace fallback unsupported package entry: %s", path)
}
rel, err := filepath.Rel(packageDir, path)
if err != nil {
return err
}
hdr, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
hdr.Name = filepath.ToSlash(filepath.Join("package", rel))
hdr.Mode = int64(info.Mode().Perm())
if err := tw.WriteHeader(hdr); err != nil {
return err
}
in, err := os.Open(path)
if err != nil {
return err
}
_, copyErr := io.Copy(tw, in)
closeErr := in.Close()
if copyErr != nil {
return copyErr
}
return closeErr
})
closeTarErr := tw.Close()
closeGzipErr := gz.Close()
if walkErr != nil {
return "", fmt.Errorf("pack sanitized npm tarball: %w", walkErr)
}
if closeTarErr != nil {
return "", fmt.Errorf("close sanitized npm tarball: %w", closeTarErr)
}
if closeGzipErr != nil {
return "", fmt.Errorf("close sanitized npm gzip: %w", closeGzipErr)
}
return tarball, nil
}
func npmTarballFilename(name, version string) string {
name = strings.TrimSpace(name)
version = strings.TrimSpace(version)
if name == "" || version == "" {
return ""
}
name = strings.TrimPrefix(name, "@")
name = strings.ReplaceAll(name, "/", "-")
name = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z':
return r
case r >= 'A' && r <= 'Z':
return r
case r >= '0' && r <= '9':
return r
case r == '.' || r == '_' || r == '-':
return r
default:
return '-'
}
}, name)
version = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z':
return r
case r >= 'A' && r <= 'Z':
return r
case r >= '0' && r <= '9':
return r
case r == '.' || r == '_' || r == '-':
return r
default:
return '-'
}
}, version)
return name + "-" + version + ".tgz"
}
func extractNpmTarballToDir(tarball, destination string) error {
files, err := ExtractArchiveAs(tarball, filepath.Base(tarball), 50*1024*1024)
if err != nil {
return fmt.Errorf("extract npm tarball: %w", err)
}
cleanDest := filepath.Clean(destination)
for _, file := range files {
target := filepath.Join(cleanDest, file.Name)
if !isPathWithin(target, cleanDest) {
return fmt.Errorf("npm tarball contains unsafe path: %s", file.Name)
}
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
return fmt.Errorf("extract npm tarball parent: %w", err)
}
mode := file.Mode.Perm()
if mode == 0 {
mode = 0o600
}
if err := os.WriteFile(target, file.Content, mode); err != nil {
return fmt.Errorf("extract npm tarball file: %w", err)
}
}
return nil
}
func isPathWithin(path, parent string) bool {
cleanPath := filepath.Clean(path)
cleanParent := filepath.Clean(parent)
if cleanPath == cleanParent {
return true
}
return strings.HasPrefix(cleanPath, cleanParent+string(os.PathSeparator))
}
func appendNpmFallbackOutput(parts ...[]byte) []byte {
var out []byte
for _, part := range parts {
trimmed := bytes.TrimSpace(part)
if len(trimmed) == 0 {
continue
}
if len(out) > 0 {
out = append(out, '\n')
}
out = append(out, trimmed...)
}
return out
}