fix(packages): support npm workspace dependency installs

Retry npm global installs that fail on workspace protocol dependencies by packing the registry tarball, rewriting workspace ranges to published versions, and installing the sanitized package folder.
This commit is contained in:
Goon committed 2026-05-18 15:53:42 +07:00
1 parent b377282164
commit 1de07cf8c4
6 files changed
+373 -26

No files matched your search

+15
View File
@@ -4,6 +4,21 @@ Significant changes, features, and fixes in reverse chronological order.
---
## 2026-05-18
### Packages: npm workspace protocol fallback
**Fixes**
- Fixed Node package installs for registry packages published with `workspace:` dependency ranges, such as `@agenttasks/cli`.
- GoClaw now retries npm `EUNSUPPORTEDPROTOCOL workspace:` failures by packing the registry tarball, rewriting workspace dependency ranges to published package versions, and installing the sanitized package folder.
**Tests**
- Added focused coverage for workspace protocol detection and package.json dependency rewrite behavior.
---
## 2026-05-17
### Agents: provider switch save fix
+2 -10
View File
@@ -119,13 +119,7 @@ func InstallSingleDep(ctx context.Context, dep string) (bool, string) {
}
defer release()
}
if err := os.MkdirAll(npmGlobalPrefix(), 0o750); err != nil {
return false, fmt.Sprintf("npm prefix setup: %v", err)
}
ensureNpmGlobalEnv()
cmd := exec.CommandContext(ctx, npmBinary, "install", "-g", pkg)
cmd.Env = npmCommandEnv()
out, err := cmd.CombinedOutput()
out, err := installNpmPackage(ctx, pkg)
if err != nil {
msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err)
slog.Error("skills: dep install failed", "dep", dep, "error", msg)
@@ -207,9 +201,7 @@ func InstallDeps(ctx context.Context, manifest *SkillManifest, missing []string)
ensureNpmGlobalEnv()
var successful []string
for _, pkg := range npmPkgs {
cmd := exec.CommandContext(ctx, npmBinary, "install", "-g", pkg)
cmd.Env = npmCommandEnv()
if out, err := cmd.CombinedOutput(); err != nil {
if out, err := installNpmPackage(ctx, pkg); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("npm %s: %s (%v)", pkg, strings.TrimSpace(string(out)), err))
} else {
successful = append(successful, pkg)
@@ -2,6 +2,7 @@ package skills
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
@@ -31,6 +32,9 @@ func useFixtureNpm(t *testing.T) {
t.Helper()
restoreNpmBinary(t)
restoreNpmLookPath(t)
if os.Getenv("RUNTIME_DIR") == "" && os.Getenv("NPM_CONFIG_PREFIX") == "" {
t.Setenv("RUNTIME_DIR", t.TempDir())
}
npmBinary = filepath.Join("testdata", "npm", "bin", "npm")
if runtime.GOOS == "windows" {
npmBinary += ".cmd"
+2 -16
View File
@@ -1,12 +1,9 @@
package skills
import (
"bytes"
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"time"
)
@@ -47,23 +44,12 @@ func (e *NpmUpdateExecutor) Update(ctx context.Context, name, toVersion string,
// suffixes, so the only "@" in the token is our version separator.
target := name + "@" + toVersion
if err := os.MkdirAll(npmGlobalPrefix(), 0o750); err != nil {
return fmt.Errorf("npm prefix setup: %w", err)
}
ensureNpmGlobalEnv()
cmd := exec.CommandContext(cctx, npmBinary, "install", "--global", target)
cmd.Env = npmCommandEnv()
cmd.WaitDelay = 2 * time.Second
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
start := time.Now()
runErr := cmd.Run()
out, runErr := installNpmPackage(cctx, target)
durationMs := time.Since(start).Milliseconds()
if runErr != nil {
sentinel, reason := ClassifyNpmStderr(stderr.String())
sentinel, reason := ClassifyNpmStderr(string(out))
if sentinel == nil {
sentinel = fmt.Errorf("npm install failed: %w", runErr)
}
+242
View File
@@ -0,0 +1,242 @@
package skills
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"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")
}
installOut, err := runNpmInstall(ctx, packageDir)
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 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
}
@@ -0,0 +1,108 @@
package skills
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestNpmOutputHasWorkspaceProtocolError(t *testing.T) {
out := `npm error code EUNSUPPORTEDPROTOCOL
npm error Unsupported URL Type "workspace:": workspace:*`
if !npmOutputHasWorkspaceProtocolError(out) {
t.Fatal("expected workspace protocol error to be detected")
}
if npmOutputHasWorkspaceProtocolError("npm error code ERESOLVE") {
t.Fatal("non-workspace npm error should not trigger fallback")
}
}
func TestResolveWorkspaceDependencySpec(t *testing.T) {
orig := npmPackageVersionResolver
t.Cleanup(func() { npmPackageVersionResolver = orig })
npmPackageVersionResolver = func(context.Context, string) (string, error) {
return "1.2.3", nil
}
cases := []struct {
spec string
want string
}{
{"workspace:*", "1.2.3"},
{"workspace:^", "^1.2.3"},
{"workspace:~", "~1.2.3"},
{"workspace:^1.0.0", "^1.0.0"},
}
for _, tc := range cases {
got, err := resolveWorkspaceDependencySpec(context.Background(), "@scope/core", tc.spec)
if err != nil {
t.Fatalf("resolveWorkspaceDependencySpec(%q) error: %v", tc.spec, err)
}
if got != tc.want {
t.Fatalf("resolveWorkspaceDependencySpec(%q) = %q, want %q", tc.spec, got, tc.want)
}
}
}
func TestRewriteWorkspacePackageJSON(t *testing.T) {
orig := npmPackageVersionResolver
t.Cleanup(func() { npmPackageVersionResolver = orig })
npmPackageVersionResolver = func(_ context.Context, name string) (string, error) {
switch name {
case "@agenttasks/core":
return "0.1.0", nil
case "react":
return "0.1.0", nil
default:
t.Fatalf("unexpected version lookup for %s", name)
return "", nil
}
}
dir := t.TempDir()
path := filepath.Join(dir, "package.json")
raw := []byte(`{
"name": "@agenttasks/cli",
"dependencies": {
"@agenttasks/core": "workspace:*",
"ws": "^8.18.3"
},
"peerDependencies": {
"react": "workspace:^"
}
}`)
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
rewrites, err := rewriteWorkspacePackageJSON(context.Background(), path)
if err != nil {
t.Fatalf("rewriteWorkspacePackageJSON error: %v", err)
}
if rewrites != 2 {
t.Fatalf("rewrites = %d, want 2", rewrites)
}
var pkg struct {
Dependencies map[string]string `json:"dependencies"`
PeerDependencies map[string]string `json:"peerDependencies"`
}
updated, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(updated, &pkg); err != nil {
t.Fatal(err)
}
if got := pkg.Dependencies["@agenttasks/core"]; got != "0.1.0" {
t.Fatalf("@agenttasks/core = %q, want 0.1.0", got)
}
if got := pkg.PeerDependencies["react"]; got != "^0.1.0" {
t.Fatalf("react = %q, want ^0.1.0", got)
}
if got := pkg.Dependencies["ws"]; got != "^8.18.3" {
t.Fatalf("ws = %q, want unchanged", got)
}
}