| Field | Value |
|---|---|
| Feature ID | UNI-005 |
| Phase | 2 - Language Implementations |
| Priority | P0 |
| Effort | M (3-5 days) |
| Dependencies | UNI-002, UNI-003 |
| Packages | internal/providers/ |
Implement the Provider interface for Node.js and Bun-binary projects. These two providers share the same detection file (package.json) and are combined in one feature because their detection logic, version reading, and most infrastructure overlap. They diverge on:
- Build commands (pnpm/npm/yarn vs bun).
- Publish target (npm registry vs GitHub Release asset upload).
- Package manager detection (Node only).
internal/
providers/
node.go
node_test.go
bun.go
bun_test.go
package providers
type NodeProvider struct {
packageManager string // resolved during VerifyEnv: "pnpm", "npm", "yarn", "bun"
}
func (p *NodeProvider) Name() string { return "node" }Detect:
// Detect checks for package.json without "bun build --compile" in scripts.
// Returns (true, 50) if package.json exists and is NOT a bun-binary project.
func (p *NodeProvider) Detect(projectDir string) (bool, int) {
pkgPath := filepath.Join(projectDir, "package.json")
if _, err := os.Stat(pkgPath); err != nil {
return false, 0
}
isBun, _ := isBunBinary(pkgPath)
if isBun {
return false, 0 // Bun provider handles this
}
return true, 50
}ReadVersion:
// ReadVersion parses package.json and returns the "version" field.
func (p *NodeProvider) ReadVersion(projectDir string) (string, error) {
path := filepath.Join(projectDir, "package.json")
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read package.json: %w", err)
}
var pkg struct {
Version string `json:"version"`
}
if err := json.Unmarshal(data, &pkg); err != nil {
return "", fmt.Errorf("parse package.json: %w", err)
}
if pkg.Version == "" {
return "", fmt.Errorf("no 'version' field in package.json")
}
return pkg.Version, nil
}Package Manager Detection:
// detectPackageManager determines the package manager from lockfile presence.
// Priority: pnpm-lock.yaml > bun.lockb > yarn.lock > package-lock.json > npm default.
func detectPackageManager(projectDir string) string {
lockfiles := []struct {
file string
manager string
}{
{"pnpm-lock.yaml", "pnpm"},
{"bun.lockb", "bun"},
{"yarn.lock", "yarn"},
{"package-lock.json", "npm"},
}
for _, lf := range lockfiles {
if _, err := os.Stat(filepath.Join(projectDir, lf.file)); err == nil {
return lf.manager
}
}
return "npm" // default
}VerifyEnv:
// VerifyEnv detects the package manager and checks it is installed.
// Also checks that `npm` is available (needed for npm publish regardless of build tool).
func (p *NodeProvider) VerifyEnv() ([]string, error) {
// Detect package manager (stored for later use by Build/Test)
p.packageManager = detectPackageManager(ctx.ProjectDir)
var missing []string
// Check package manager
if _, err := exec.LookPath(p.packageManager); err != nil {
missing = append(missing, p.packageManager)
}
// npm is always needed for publish
if p.packageManager != "npm" {
if _, err := exec.LookPath("npm"); err != nil {
missing = append(missing, "npm")
}
}
if len(missing) > 0 {
return missing, fmt.Errorf("missing tools: %v", missing)
}
return nil, nil
}Clean:
// Clean removes dist/ and node_modules/.cache/ directories.
// Uses os.RemoveAll for cross-platform compatibility.
func (p *NodeProvider) Clean(ctx *pipeline.PipelineContext) error {
dirs := []string{
filepath.Join(ctx.ProjectDir, "dist"),
filepath.Join(ctx.ProjectDir, "node_modules", ".cache"),
}
for _, dir := range dirs {
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("clean %s: %w", dir, err)
}
}
ctx.UI.Info("Cleaned dist/ and node_modules/.cache/")
return nil
}Build:
// Build runs `<package-manager> build` or `<package-manager> run build`.
// pnpm/bun/yarn: `<pm> build` works.
// npm: `npm run build`.
func (p *NodeProvider) Build(ctx *pipeline.PipelineContext) error {
pm := p.packageManager
if pm == "" {
pm = "npm"
}
args := []string{"build"}
if pm == "npm" {
args = []string{"run", "build"}
}
_, err := ctx.Runner.Run(pm, args...)
return err
}Test:
// Test runs `<package-manager> test`.
func (p *NodeProvider) Test(ctx *pipeline.PipelineContext) error {
pm := p.packageManager
if pm == "" {
pm = "npm"
}
args := []string{"test"}
if pm == "npm" {
args = []string{"run", "test"}
}
_, err := ctx.Runner.Run(pm, args...)
return err
}Publish:
// Publish runs `npm publish --access public`.
// Always uses npm regardless of the build-time package manager.
func (p *NodeProvider) Publish(ctx *pipeline.PipelineContext) error {
_, err := ctx.Runner.Run("npm", "publish", "--access", "public")
return err
}
func (p *NodeProvider) PublishTarget() string { return "npm" }
func (p *NodeProvider) BinaryAssets(ctx *pipeline.PipelineContext) ([]string, error) {
return nil, nil
}package providers
type BunProvider struct{}
func (p *BunProvider) Name() string { return "bun" }Detect:
// Detect checks for package.json with "bun build --compile" in any script value.
// Returns (true, 80) if found.
func (p *BunProvider) Detect(projectDir string) (bool, int) {
pkgPath := filepath.Join(projectDir, "package.json")
if _, err := os.Stat(pkgPath); err != nil {
return false, 0
}
isBun, _ := isBunBinary(pkgPath)
if isBun {
return true, 80
}
return false, 0
}Shared helper (in provider.go or a helpers.go):
// isBunBinary checks if any script in package.json contains "bun build --compile".
func isBunBinary(packageJSONPath string) (bool, error) {
data, err := os.ReadFile(packageJSONPath)
if err != nil {
return false, err
}
var pkg struct {
Scripts map[string]string `json:"scripts"`
}
if err := json.Unmarshal(data, &pkg); err != nil {
return false, err
}
for _, script := range pkg.Scripts {
if strings.Contains(script, "bun build --compile") {
return true, nil
}
}
return false, nil
}ReadVersion: Same as NodeProvider (parse package.json .version).
VerifyEnv:
// VerifyEnv checks that `bun` is in PATH.
func (p *BunProvider) VerifyEnv() ([]string, error) {
if _, err := exec.LookPath("bun"); err != nil {
return []string{"bun"}, fmt.Errorf("bun not found; install from https://bun.sh")
}
return nil, nil
}Clean:
// Clean removes the dist/ directory.
func (p *BunProvider) Clean(ctx *pipeline.PipelineContext) error {
dir := filepath.Join(ctx.ProjectDir, "dist")
return os.RemoveAll(dir)
}Build:
// Build runs `bun run build` to compile the binary.
func (p *BunProvider) Build(ctx *pipeline.PipelineContext) error {
_, err := ctx.Runner.Run("bun", "run", "build")
return err
}Test:
// Test runs `bun test`.
func (p *BunProvider) Test(ctx *pipeline.PipelineContext) error {
_, err := ctx.Runner.Run("bun", "test")
return err
}Publish:
// Publish returns ErrNoPublish -- bun-binary projects upload to GitHub Release,
// not to a package registry. The asset upload is handled by the github_release step.
func (p *BunProvider) Publish(ctx *pipeline.PipelineContext) error {
return ErrNoPublish
}
func (p *BunProvider) PublishTarget() string { return "GitHub Release" }BinaryAssets:
// BinaryAssets returns the compiled binary path(s) for GitHub Release upload.
// Strategy:
// 1. Parse the build script to find --outfile value.
// 2. If --outfile not found, scan dist/ for executable files.
func (p *BunProvider) BinaryAssets(ctx *pipeline.PipelineContext) ([]string, error) {
// Strategy 1: Parse --outfile from build script
pkgPath := filepath.Join(ctx.ProjectDir, "package.json")
data, _ := os.ReadFile(pkgPath)
var pkg struct {
Scripts map[string]string `json:"scripts"`
}
json.Unmarshal(data, &pkg)
for _, script := range pkg.Scripts {
if strings.Contains(script, "bun build --compile") {
// Look for --outfile <path>
parts := strings.Fields(script)
for i, part := range parts {
if part == "--outfile" && i+1 < len(parts) {
outfile := parts[i+1]
absPath := filepath.Join(ctx.ProjectDir, outfile)
if _, err := os.Stat(absPath); err == nil {
return []string{absPath}, nil
}
}
}
}
}
// Strategy 2: Scan dist/ for executable files
distDir := filepath.Join(ctx.ProjectDir, "dist")
entries, err := os.ReadDir(distDir)
if err != nil {
return nil, fmt.Errorf("no binary found: dist/ not found and --outfile not parsed")
}
var assets []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, _ := entry.Info()
// Check if file is executable (Unix) or has no extension (could be binary)
if info.Mode()&0111 != 0 || filepath.Ext(entry.Name()) == "" {
assets = append(assets, filepath.Join(distDir, entry.Name()))
}
}
if len(assets) == 0 {
return nil, fmt.Errorf("no binary assets found in dist/")
}
return assets, nil
}| Test Name | Setup | Expected Result |
|---|---|---|
| TestNodeDetect_PackageJSON | package.json without bun compile | (true, 50) |
| TestNodeDetect_BunBinaryPackageJSON | package.json with bun build --compile | (false, 0) |
| TestNodeDetect_NoPackageJSON | Empty dir | (false, 0) |
| TestNodeReadVersion_Valid | package.json: {"version": "1.2.0"} |
"1.2.0" |
| TestNodeReadVersion_NoVersion | package.json: {"name": "foo"} |
Error |
| TestDetectPM_Pnpm | Dir with pnpm-lock.yaml | "pnpm" |
| TestDetectPM_Bun | Dir with bun.lockb | "bun" |
| TestDetectPM_Yarn | Dir with yarn.lock | "yarn" |
| TestDetectPM_Npm | Dir with package-lock.json | "npm" |
| TestDetectPM_Default | Dir with no lockfile | "npm" |
| TestDetectPM_Priority | Dir with pnpm-lock.yaml AND package-lock.json | "pnpm" |
| TestNodeClean_RemovesDirs | Dir with dist/ and node_modules/.cache/ | Both removed |
| TestNodeBuild_Pnpm | packageManager = "pnpm" | Ran: "pnpm build" |
| TestNodeBuild_Npm | packageManager = "npm" | Ran: "npm run build" |
| TestNodePublish_Command | Mock runner | Ran: "npm publish --access public" |
| TestNodeBinaryAssets_ReturnsNil | Any | nil |
| Test Name | Setup | Expected Result |
|---|---|---|
| TestBunDetect_WithCompile | package.json: scripts.build = "bun build --compile ..." | (true, 80) |
| TestBunDetect_WithoutCompile | package.json: scripts.build = "bun build ..." (no --compile) | (false, 0) |
| TestBunDetect_NoPackageJSON | Empty dir | (false, 0) |
| TestBunBuild_Command | Mock runner | Ran: "bun run build" |
| TestBunTest_Command | Mock runner | Ran: "bun test" |
| TestBunPublish_ReturnsErrNoPublish | Any | ErrNoPublish |
| TestBunBinaryAssets_OutfileFlag | package.json: --outfile dist/myapp, file exists |
["/dist/myapp"] |
| TestBunBinaryAssets_ScanDist | dist/ contains executable file | Returns that file path |
| TestBunBinaryAssets_NoBinary | Empty dist/ or no dist/ | Error |
| release.sh Step | unirelease Node Provider Equivalent |
|---|---|
| Auto-detect name from package.json | ReadVersion() + pipeline detect step |
| Install deps (pnpm install) | Not in MVP (assume deps installed) |
| Clean (rm -rf dist/) | Clean() -> os.RemoveAll |
| Build (pnpm build) | Build() -> <pm> build |
| Check (npm pack --dry-run) | Omitted in MVP (low value) |
| Git tag | Shared git_tag step (UNI-003) |
| GitHub Release | Shared github_release step (UNI-003) |
| npm publish | Publish() -> npm publish --access public |
- Node:
unireleasewith package.json (no bun compile) detects "node". - Bun:
unireleasewith package.json containingbun build --compiledetects "bun". - Node: Package manager detected from lockfile (pnpm-lock.yaml -> pnpm, etc.).
- Node: Build uses detected package manager (pnpm build, npm run build, etc.).
- Node: Publish always uses
npm publish --access public. - Bun: Build runs
bun run build. - Bun: Publish returns ErrNoPublish (pipeline skips publish step).
- Bun: BinaryAssets returns the compiled binary path for GitHub Release upload.
- Bun: Binary asset is uploaded to GitHub Release by the github_release step.
- Clean uses os.RemoveAll (cross-platform, no shell commands).