first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
// This file fetches a pi package's contents from npm (#156). Rather than
// implement an npm registry client, pigo shells out to the user's installed
// `npm` — specifically `npm pack`, which downloads a package as a .tgz tarball
// without running install scripts. pigo then extracts that tarball into a
// temporary directory for the classify/distribute steps that follow.
//
// The fetch is deliberately side-effect-light: `npm pack` neither installs
// dependencies nor runs lifecycle scripts, so downloading a package cannot
// execute its code. Running the extracted extension is a separate, later step.
//
// npm packs every package into a top-level "package/" directory inside the
// tarball; Fetch returns the path to that extracted directory.
package pkgmgr
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
// FetchResult describes a package fetched to a temporary directory.
type FetchResult struct {
// Dir is the extracted package directory (the tarball's "package/" root).
Dir string
// TempRoot is the temporary directory holding Dir and the tarball; the
// caller must Cleanup it when done.
TempRoot string
}
// Cleanup removes the temporary directory tree created by Fetch. Safe to call
// on a zero FetchResult (no-op).
func (r FetchResult) Cleanup() error {
if r.TempRoot == "" {
return nil
}
return os.RemoveAll(r.TempRoot)
}
// npmExecutable is the npm binary name; a variable so tests can stub it.
var npmExecutable = "npm"
// EnsureNPM reports an actionable error when npm is not on PATH. The install
// command calls this before doing any work so it fails fast with guidance
// rather than deep inside a fetch.
func EnsureNPM() error {
if _, err := exec.LookPath(npmExecutable); err != nil {
return fmt.Errorf("npm not found; install Node.js/npm to use pigo install")
}
return nil
}
// Fetch downloads the package named by ref using `npm pack` and extracts it into
// a fresh temporary directory. On success the caller owns the returned
// FetchResult and must call Cleanup. On any failure the temporary directory is
// removed before returning, so a failed fetch leaves nothing behind.
//
// npm's own error output (unknown package, network failure, auth) is included
// in the returned error so the user sees why the fetch failed.
func Fetch(ref PackageRef) (FetchResult, error) {
if err := EnsureNPM(); err != nil {
return FetchResult{}, err
}
tmp, err := os.MkdirTemp("", "pigo-pkg-*")
if err != nil {
return FetchResult{}, fmt.Errorf("pkgmgr: create temp dir: %w", err)
}
// From here on, remove tmp on any error path.
fail := func(e error) (FetchResult, error) {
_ = os.RemoveAll(tmp)
return FetchResult{}, e
}
spec := ref.Name
if ref.Version != "" {
spec += "@" + ref.Version
}
// `npm pack <spec>` writes a .tgz into --pack-destination and prints its
// filename. --ignore-scripts guards against packing-time script execution.
cmd := exec.Command(npmExecutable, "pack", spec,
"--pack-destination", tmp,
"--ignore-scripts",
"--loglevel", "error")
var stderr strings.Builder
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return fail(fmt.Errorf("npm pack %s failed: %s", spec, msg))
}
tarball, err := locateTarball(tmp, out)
if err != nil {
return fail(err)
}
dest := filepath.Join(tmp, "extracted")
if err := extractTarGz(tarball, dest); err != nil {
return fail(err)
}
// npm packs into a top-level "package/" directory.
pkgDir := filepath.Join(dest, "package")
if _, err := os.Stat(pkgDir); err != nil {
return fail(fmt.Errorf("pkgmgr: extracted tarball missing package/ dir: %w", err))
}
return FetchResult{Dir: pkgDir, TempRoot: tmp}, nil
}
// locateTarball resolves the .tgz path that `npm pack` produced. npm prints the
// tarball filename on stdout; when that is unhelpful we fall back to scanning
// the destination directory for a single .tgz.
func locateTarball(dir string, packStdout []byte) (string, error) {
if name := strings.TrimSpace(string(packStdout)); name != "" {
// npm may print just the filename; join to dir if it isn't absolute.
cand := name
if !filepath.IsAbs(cand) {
cand = filepath.Join(dir, filepath.Base(name))
}
if _, err := os.Stat(cand); err == nil {
return cand, nil
}
}
entries, err := os.ReadDir(dir)
if err != nil {
return "", fmt.Errorf("pkgmgr: read pack dir: %w", err)
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".tgz") {
return filepath.Join(dir, e.Name()), nil
}
}
return "", fmt.Errorf("pkgmgr: npm pack produced no .tgz in %s", dir)
}
// extractTarGz extracts a gzip-compressed tar archive into dest, creating dest.
// It guards against path traversal (a "../" entry escaping dest) and skips any
// entry that is not a regular file or directory.
func extractTarGz(tarball, dest string) error {
f, err := os.Open(tarball)
if err != nil {
return fmt.Errorf("pkgmgr: open tarball: %w", err)
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("pkgmgr: gzip reader: %w", err)
}
defer gz.Close()
if err := os.MkdirAll(dest, 0o755); err != nil {
return fmt.Errorf("pkgmgr: create extract dir: %w", err)
}
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("pkgmgr: read tar: %w", err)
}
target, err := safeJoin(dest, hdr.Name)
if err != nil {
return err
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return fmt.Errorf("pkgmgr: mkdir %q: %w", target, err)
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("pkgmgr: mkdir parent of %q: %w", target, err)
}
if err := writeFile(target, tr, os.FileMode(hdr.Mode)); err != nil {
return err
}
default:
// Skip symlinks, devices, etc. — npm packages are files + dirs.
}
}
return nil
}
// safeJoin joins name onto base, rejecting any result that escapes base (a
// tarball path-traversal guard).
func safeJoin(base, name string) (string, error) {
target := filepath.Join(base, name)
cleanBase := filepath.Clean(base) + string(os.PathSeparator)
if target != filepath.Clean(base) && !strings.HasPrefix(target, cleanBase) {
return "", fmt.Errorf("pkgmgr: tarball entry %q escapes extract dir", name)
}
return target, nil
}
// writeFile writes the tar entry body to target with the given mode.
func writeFile(target string, r io.Reader, mode os.FileMode) error {
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return fmt.Errorf("pkgmgr: create %q: %w", target, err)
}
defer out.Close()
if _, err := io.Copy(out, r); err != nil {
return fmt.Errorf("pkgmgr: write %q: %w", target, err)
}
return nil
}