mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 15:02:21 -10:00
* feat(npm): ship the native binary as platform optionalDependencies Replace the postinstall downloader with Codex-style platform packages: suffixed versions of @gitlawb/zero (X.Y.Z-<platform>-<arch>) carrying the binary, sandbox helpers, and the vendored agent-browser/tuistory tree, referenced from the wrapper via npm: aliases in optionalDependencies. The published wrapper has no lifecycle scripts and no dependencies, so npm/Bun/pnpm installs are silent: no EBADENGINE from agent-browser's node>=24 engines pin, no allow-scripts warnings, no network outside the npm registry. The old downloader remains as a checksum-verified first-run fallback for --omit=optional installs and unsupported platforms. scripts/npm/build-platform-packages.mjs assembles the payloads from the release archives at publish time: it materializes npm's symlink .bin shims into regular sh shims (npm pack silently drops symlinks) and prunes agent-browser's other-platform binaries (~65 MB per package). The publish job uploads platform versions first under --tag platform, asserts the latest dist-tag survived (platform versions are semver prereleases of the wrapper version), then publishes the wrapper. See docs/NPM_PACKAGING.md for the architecture and publishing rules. * fix(npm): address PR review — LICENSE in payloads, dist-tag lookup guard, accurate fallback and platform docs - Ship the repo LICENSE in both the wrapper and platform payloads (npm only includes a license file physically present in the packed directory). - Guard the platform-publish dist-tag lookup with || true so a transient npm view failure (or a bootstrap publish with no latest tag) cannot abort the job between the platform and wrapper publishes. - Gate the wrapper's fallback downloader on the release matrix so platforms with no asset (windows-arm64 and unknown combos) skip the spawn instead of attempting it every run; keep deliberate retry-on-failure for supported platforms and say so honestly in the notice and docs. - Correct Windows support wording everywhere: x64 only, ARM runs under emulation (README, README_ZH, INSTALL.md incl. the release-target list).
261 lines
10 KiB
Go
261 lines
10 KiB
Go
package npmwrapper
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestBuildPlatformPackagesAssemblesPublishPayloads runs the release-time
|
|
// assembly script against a fixture linux-x64 archive shaped like the output
|
|
// of `zero-release package` and checks the npm publish payloads it emits:
|
|
// the platform payload (suffixed version, os/cpu gate, pack-safe helper
|
|
// shims, pruned agent-browser binaries) and the wrapper (no scripts, no
|
|
// dependencies, full optionalDependencies alias matrix).
|
|
func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("fixture uses POSIX symlinks and shell scripts")
|
|
}
|
|
node := requireNode(t)
|
|
tar := requireCommand(t, "tar")
|
|
version := packageVersion(t)
|
|
|
|
root := t.TempDir()
|
|
staging := filepath.Join(root, "staging")
|
|
|
|
// Payload the platform package must keep.
|
|
writeFixtureFile(t, filepath.Join(staging, "zero"), "#!/usr/bin/env sh\necho fixture-zero\n", 0o755)
|
|
writeFixtureFile(t, filepath.Join(staging, "zero-linux-sandbox"), "#!/usr/bin/env sh\n", 0o755)
|
|
writeFixtureFile(t, filepath.Join(staging, "zero-seccomp"), "#!/usr/bin/env sh\n", 0o755)
|
|
// Wrapper-owned files the platform package must exclude.
|
|
writeFixtureFile(t, filepath.Join(staging, "package.json"), `{"name":"@gitlawb/zero"}`, 0o644)
|
|
writeFixtureFile(t, filepath.Join(staging, "README.md"), "readme\n", 0o644)
|
|
writeFixtureFile(t, filepath.Join(staging, "VERSION"), version+"\n", 0o644)
|
|
writeFixtureFile(t, filepath.Join(staging, "bin", "zero.js"), "#!/usr/bin/env node\n", 0o755)
|
|
// Vendored helpers tree with npm's symlink .bin shims and agent-browser's
|
|
// multi-platform binary set.
|
|
agentBrowserDir := filepath.Join(staging, "helpers", "node_modules", "agent-browser")
|
|
writeFixtureFile(t, filepath.Join(agentBrowserDir, "package.json"), `{"name":"agent-browser"}`, 0o644)
|
|
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", "agent-browser.js"), "#!/usr/bin/env node\nconsole.log('fixture agent-browser')\n", 0o644)
|
|
for _, name := range []string{
|
|
"agent-browser-linux-x64",
|
|
"agent-browser-linux-musl-x64",
|
|
"agent-browser-linux-arm64",
|
|
"agent-browser-darwin-arm64",
|
|
"agent-browser-darwin-x64",
|
|
"agent-browser-win32-x64.exe",
|
|
} {
|
|
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", name), "native\n", 0o755)
|
|
}
|
|
tuistoryDir := filepath.Join(staging, "helpers", "node_modules", "tuistory")
|
|
writeFixtureFile(t, filepath.Join(tuistoryDir, "dist", "cli.js"), "#!/usr/bin/env node\n", 0o644)
|
|
binDir := filepath.Join(staging, "helpers", "node_modules", ".bin")
|
|
mustMkdirAllFixture(t, binDir)
|
|
if err := os.Symlink("../agent-browser/bin/agent-browser.js", filepath.Join(binDir, "agent-browser")); err != nil {
|
|
t.Fatalf("Symlink agent-browser shim: %v", err)
|
|
}
|
|
if err := os.Symlink("../tuistory/dist/cli.js", filepath.Join(binDir, "tuistory")); err != nil {
|
|
t.Fatalf("Symlink tuistory shim: %v", err)
|
|
}
|
|
|
|
// Archive with the payload at the archive root, like zero-release's
|
|
// createArchive, plus the sha256 sidecar the script verifies against.
|
|
assetName := fmt.Sprintf("zero-v%s-linux-x64.tar.gz", version)
|
|
archivePath := filepath.Join(root, assetName)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if output, err := exec.CommandContext(ctx, tar, "-C", staging, "-czf", archivePath, ".").CombinedOutput(); err != nil {
|
|
t.Fatalf("tar failed: %v\n%s", err, output)
|
|
}
|
|
archiveBytes, err := os.ReadFile(archivePath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile archive: %v", err)
|
|
}
|
|
digest := sha256.Sum256(archiveBytes)
|
|
writeFixtureFile(t, archivePath+".sha256", fmt.Sprintf("%x %s\n", digest, assetName), 0o644)
|
|
|
|
outDir := filepath.Join(root, "out")
|
|
script := filepath.Join(repoRoot(t), "scripts", "npm", "build-platform-packages.mjs")
|
|
runCtx, runCancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer runCancel()
|
|
command := exec.CommandContext(runCtx, node, script, "--artifacts-dir", root, "--out-dir", outDir, "--only", "linux-x64")
|
|
command.Env = append(os.Environ(), "NODE_OPTIONS=")
|
|
if output, err := command.CombinedOutput(); err != nil {
|
|
t.Fatalf("build-platform-packages failed: %v\n%s", err, output)
|
|
}
|
|
|
|
platformDir := filepath.Join(outDir, "platforms", "zero-linux-x64")
|
|
var platformPkg struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
OS []string `json:"os"`
|
|
CPU []string `json:"cpu"`
|
|
Scripts map[string]string
|
|
}
|
|
unmarshalJSONFile(t, filepath.Join(platformDir, "package.json"), &platformPkg)
|
|
if platformPkg.Name != "@gitlawb/zero" {
|
|
t.Fatalf("platform package name = %q, want @gitlawb/zero (same-name suffixed versions share one trusted publisher)", platformPkg.Name)
|
|
}
|
|
if want := version + "-linux-x64"; platformPkg.Version != want {
|
|
t.Fatalf("platform package version = %q, want %q", platformPkg.Version, want)
|
|
}
|
|
if len(platformPkg.OS) != 1 || platformPkg.OS[0] != "linux" || len(platformPkg.CPU) != 1 || platformPkg.CPU[0] != "x64" {
|
|
t.Fatalf("platform package os/cpu = %v/%v, want [linux]/[x64]", platformPkg.OS, platformPkg.CPU)
|
|
}
|
|
if len(platformPkg.Scripts) != 0 {
|
|
t.Fatalf("platform package has lifecycle scripts: %v", platformPkg.Scripts)
|
|
}
|
|
|
|
info, err := os.Stat(filepath.Join(platformDir, "zero"))
|
|
if err != nil {
|
|
t.Fatalf("platform package binary missing: %v", err)
|
|
}
|
|
if info.Mode()&0o111 == 0 {
|
|
t.Fatalf("platform package binary is not executable: %v", info.Mode())
|
|
}
|
|
for _, excluded := range []string{"README.md", "VERSION", "bin"} {
|
|
if _, err := os.Stat(filepath.Join(platformDir, excluded)); !os.IsNotExist(err) {
|
|
t.Fatalf("platform package still contains wrapper-owned %q", excluded)
|
|
}
|
|
}
|
|
// npm only ships a license file it finds in the packed directory, so the
|
|
// assembly must place the repo LICENSE into every payload.
|
|
if _, err := os.Stat(filepath.Join(platformDir, "LICENSE")); err != nil {
|
|
t.Fatalf("platform package missing LICENSE: %v", err)
|
|
}
|
|
|
|
// npm pack drops symlinks, so the shim must now be a regular executable file.
|
|
shimPath := filepath.Join(platformDir, "helpers", "node_modules", ".bin", "agent-browser")
|
|
shimInfo, err := os.Lstat(shimPath)
|
|
if err != nil {
|
|
t.Fatalf("agent-browser shim missing: %v", err)
|
|
}
|
|
if shimInfo.Mode()&os.ModeSymlink != 0 {
|
|
t.Fatalf("agent-browser shim is still a symlink; npm pack would drop it")
|
|
}
|
|
if shimInfo.Mode()&0o111 == 0 {
|
|
t.Fatalf("agent-browser shim is not executable: %v", shimInfo.Mode())
|
|
}
|
|
shim, err := os.ReadFile(shimPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile shim: %v", err)
|
|
}
|
|
if !strings.Contains(string(shim), "../agent-browser/bin/agent-browser.js") {
|
|
t.Fatalf("shim does not exec the vendored helper: %q", string(shim))
|
|
}
|
|
if _, err := os.Stat(filepath.Join(platformDir, "helpers", "node_modules", ".bin", "tuistory")); err != nil {
|
|
t.Fatalf("tuistory shim missing: %v", err)
|
|
}
|
|
|
|
// Pruning: only the linux-x64 (glibc + musl) agent-browser binaries survive.
|
|
prunedBinDir := filepath.Join(platformDir, "helpers", "node_modules", "agent-browser", "bin")
|
|
entries, err := os.ReadDir(prunedBinDir)
|
|
if err != nil {
|
|
t.Fatalf("ReadDir pruned bin: %v", err)
|
|
}
|
|
got := []string{}
|
|
for _, entry := range entries {
|
|
got = append(got, entry.Name())
|
|
}
|
|
want := map[string]bool{
|
|
"agent-browser.js": true,
|
|
"agent-browser-linux-x64": true,
|
|
"agent-browser-linux-musl-x64": true,
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("pruned agent-browser bin = %v, want exactly %v", got, want)
|
|
}
|
|
for _, name := range got {
|
|
if !want[name] {
|
|
t.Fatalf("pruning left unexpected %q (full set: %v)", name, got)
|
|
}
|
|
}
|
|
|
|
wrapperDir := filepath.Join(outDir, "wrapper")
|
|
var wrapperPkg struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Scripts map[string]string `json:"scripts"`
|
|
Dependencies map[string]string `json:"dependencies"`
|
|
Optional map[string]string `json:"optionalDependencies"`
|
|
}
|
|
unmarshalJSONFile(t, filepath.Join(wrapperDir, "package.json"), &wrapperPkg)
|
|
if wrapperPkg.Version != version {
|
|
t.Fatalf("wrapper version = %q, want %q", wrapperPkg.Version, version)
|
|
}
|
|
if len(wrapperPkg.Scripts) != 0 {
|
|
t.Fatalf("published wrapper has lifecycle scripts: %v", wrapperPkg.Scripts)
|
|
}
|
|
if len(wrapperPkg.Dependencies) != 0 {
|
|
t.Fatalf("published wrapper has dependencies: %v (helpers are vendored in the platform payloads)", wrapperPkg.Dependencies)
|
|
}
|
|
wantAliases := map[string]string{
|
|
"@gitlawb/zero-darwin-arm64": "npm:@gitlawb/zero@" + version + "-darwin-arm64",
|
|
"@gitlawb/zero-darwin-x64": "npm:@gitlawb/zero@" + version + "-darwin-x64",
|
|
"@gitlawb/zero-linux-arm64": "npm:@gitlawb/zero@" + version + "-linux-arm64",
|
|
"@gitlawb/zero-linux-x64": "npm:@gitlawb/zero@" + version + "-linux-x64",
|
|
"@gitlawb/zero-win32-x64": "npm:@gitlawb/zero@" + version + "-win32-x64",
|
|
}
|
|
if len(wrapperPkg.Optional) != len(wantAliases) {
|
|
t.Fatalf("wrapper optionalDependencies = %v, want %v", wrapperPkg.Optional, wantAliases)
|
|
}
|
|
for alias, spec := range wantAliases {
|
|
if wrapperPkg.Optional[alias] != spec {
|
|
t.Fatalf("wrapper optionalDependencies[%q] = %q, want %q", alias, wrapperPkg.Optional[alias], spec)
|
|
}
|
|
}
|
|
for _, file := range []string{"bin/zero.js", "scripts/postinstall.mjs", "README.md", "LICENSE"} {
|
|
if _, err := os.Stat(filepath.Join(wrapperDir, filepath.FromSlash(file))); err != nil {
|
|
t.Fatalf("wrapper payload missing %s: %v", file, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func unmarshalJSONFile(t *testing.T, path string, target any) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile %s: %v", path, err)
|
|
}
|
|
if err := json.Unmarshal(data, target); err != nil {
|
|
t.Fatalf("Unmarshal %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func writeFixtureFile(t *testing.T, path string, content string, mode os.FileMode) {
|
|
t.Helper()
|
|
mustMkdirAllFixture(t, filepath.Dir(path))
|
|
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
|
t.Fatalf("WriteFile %s: %v", path, err)
|
|
}
|
|
if mode&0o111 != 0 {
|
|
if err := os.Chmod(path, mode); err != nil {
|
|
t.Fatalf("Chmod %s: %v", path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustMkdirAllFixture(t *testing.T, path string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func requireCommand(t *testing.T, name string) string {
|
|
t.Helper()
|
|
path, err := exec.LookPath(name)
|
|
if err != nil {
|
|
t.Skipf("%s is not available", name)
|
|
}
|
|
return path
|
|
}
|