Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ All notable changes to ScriptGo will be documented in this file.
## [0.1.0-alpha.6] - 2026-09-23

### Features
- feat(cli): support direct execution ('scriptgo file.ts', 'scriptgo -e "code"') and 'scg' CLI alias
- feat(pkgmgr): implement scriptgo add to resolve, record, and install dependencies
- feat(benchmarks): add JSON operation suites, standard dataset, and native benchmarks (#22) (a893439)

### Performance
Expand Down
6 changes: 4 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
.PHONY: all build test test-frontend test-parity test-sanitizers audit lint clean release help

BINARY_NAME=scriptgo
ALIAS_NAME=scg
BUILD_DIR=bin

all: build test

## build: Build the scriptgo CLI binary
## build: Build the scriptgo CLI binary and scg alias
build:
@echo "==> Building $(BINARY_NAME)..."
go build -o $(BINARY_NAME) ./cmd/scriptgo
@ln -sf $(BINARY_NAME) $(ALIAS_NAME)

## test: Run full unit and integration test suite
test:
Expand Down Expand Up @@ -51,7 +53,7 @@ release:
## clean: Clean up build artifacts and temporary files
clean:
@echo "==> Cleaning up build artifacts..."
rm -f $(BINARY_NAME)
rm -f $(BINARY_NAME) $(ALIAS_NAME)
rm -rf $(BUILD_DIR)

## help: Display this help message
Expand Down
40 changes: 40 additions & 0 deletions cmd/scg/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package main

import (
"fmt"
"os"
"os/exec"
"path/filepath"
)

func main() {
// 1. Look for sibling "scriptgo" in the same directory as this binary
var scriptgoPath string
if selfExe, err := os.Executable(); err == nil {
candidate := filepath.Join(filepath.Dir(selfExe), "scriptgo")
if info, statErr := os.Stat(candidate); statErr == nil && !info.IsDir() {
scriptgoPath = candidate
}
}
// 2. Look in PATH
if scriptgoPath == "" {
if path, lookErr := exec.LookPath("scriptgo"); lookErr == nil {
scriptgoPath = path
}
}
if scriptgoPath == "" {
fmt.Fprintf(os.Stderr, "scg: could not locate 'scriptgo' binary in PATH or executable directory\n")
os.Exit(1)
}

cmd := exec.Command(scriptgoPath, os.Args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
os.Exit(exitErr.ExitCode())
}
os.Exit(1)
}
}
189 changes: 189 additions & 0 deletions cmd/scriptgo/add_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package main

import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func createMockTarball(t *testing.T, filename, content string) ([]byte, string) {
t.Helper()
var buf bytes.Buffer
gzw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gzw)
hdr := &tar.Header{
Name: "package/" + filename,
Mode: 0o644,
Size: int64(len(content)),
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(content)); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gzw.Close(); err != nil {
t.Fatal(err)
}
archive := buf.Bytes()
digest := sha512.Sum512(archive)
integrity := "sha512-" + base64.StdEncoding.EncodeToString(digest[:])
return archive, integrity
}

func TestCLI_Add(t *testing.T) {
tmpDir := t.TempDir()
binPath := filepath.Join(tmpDir, "scriptgo")
buildCmd := exec.Command("go", "build", "-o", binPath, ".")
if out, err := buildCmd.CombinedOutput(); err != nil {
t.Fatalf("failed to build scriptgo: %v\noutput: %s", err, string(out))
}

pkgArchive, integrity := createMockTarball(t, "index.js", "module.exports = 'from-add-cli';\n")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/my-pkg" {
tarball := "http://" + request.Host + "/my-pkg/-/my-pkg.tgz"
_ = json.NewEncoder(w).Encode(map[string]any{
"dist-tags": map[string]string{"latest": "1.5.0"},
"versions": map[string]any{
"1.5.0": map[string]any{
"name": "my-pkg",
"version": "1.5.0",
"dist": map[string]string{
"tarball": tarball,
"integrity": integrity,
},
},
},
})
return
}
if request.URL.Path == "/my-pkg/-/my-pkg.tgz" {
_, _ = w.Write(pkgArchive)
return
}
http.NotFound(w, request)
}))
defer server.Close()

t.Run("no packages specified returns error", func(t *testing.T) {
cmd := exec.Command(binPath, "add")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("expected error for empty add, got: %s", string(out))
}
if !strings.Contains(string(out), "at least one package must be specified") {
t.Fatalf("unexpected output: %s", string(out))
}
})

t.Run("mutually exclusive flags return error", func(t *testing.T) {
cmd := exec.Command(binPath, "add", "-D", "-O", "my-pkg")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("expected error for mutually exclusive flags, got: %s", string(out))
}
if !strings.Contains(string(out), "mutually exclusive") {
t.Fatalf("unexpected output: %s", string(out))
}
})

t.Run("add package successfully", func(t *testing.T) {
projectDir := filepath.Join(tmpDir, "project1")
if err := os.MkdirAll(projectDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(projectDir, "package.json"), []byte(`{"name":"test1"}`), 0o644); err != nil {
t.Fatal(err)
}

cmd := exec.Command(binPath, "add", "--project", projectDir, "--registry", server.URL, "my-pkg")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("add failed: %v\noutput: %s", err, string(out))
}
if !strings.Contains(string(out), "added my-pkg@^1.5.0") {
t.Fatalf("unexpected stdout: %s", string(out))
}

manifestBytes, err := os.ReadFile(filepath.Join(projectDir, "package.json"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(manifestBytes), `"my-pkg": "^1.5.0"`) {
t.Fatalf("package.json does not contain dependency: %s", string(manifestBytes))
}

installedFile, err := os.ReadFile(filepath.Join(projectDir, "node_modules", "my-pkg", "index.js"))
if err != nil {
t.Fatalf("installed file missing: %v", err)
}
if string(installedFile) != "module.exports = 'from-add-cli';\n" {
t.Fatalf("unexpected installed content: %s", string(installedFile))
}
})

t.Run("add dev dependency with -D flag", func(t *testing.T) {
projectDir := filepath.Join(tmpDir, "project2")
if err := os.MkdirAll(projectDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(projectDir, "package.json"), []byte(`{"name":"test2"}`), 0o644); err != nil {
t.Fatal(err)
}

cmd := exec.Command(binPath, "add", "--project", projectDir, "--registry", server.URL, "-D", "my-pkg")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("add -D failed: %v\noutput: %s", err, string(out))
}

manifestBytes, err := os.ReadFile(filepath.Join(projectDir, "package.json"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(manifestBytes), `"devDependencies"`) || !strings.Contains(string(manifestBytes), `"my-pkg": "^1.5.0"`) {
t.Fatalf("package.json does not contain devDependency: %s", string(manifestBytes))
}
})

t.Run("add exact version with -E flag", func(t *testing.T) {
projectDir := filepath.Join(tmpDir, "project3")
if err := os.MkdirAll(projectDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(projectDir, "package.json"), []byte(`{"name":"test3"}`), 0o644); err != nil {
t.Fatal(err)
}

cmd := exec.Command(binPath, "add", "--project", projectDir, "--registry", server.URL, "-E", "my-pkg@1.5.0")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("add -E failed: %v\noutput: %s", err, string(out))
}
if !strings.Contains(string(out), "added my-pkg@1.5.0") {
t.Fatalf("unexpected stdout: %s", string(out))
}

manifestBytes, err := os.ReadFile(filepath.Join(projectDir, "package.json"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(manifestBytes), `"my-pkg": "1.5.0"`) {
t.Fatalf("package.json does not contain exact version: %s", string(manifestBytes))
}
})
}
76 changes: 76 additions & 0 deletions cmd/scriptgo/coverage_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package main

import (
"flag"
"fmt"
"os"

"github.com/pilotworks/scriptgo/internal/compiler"
)

func handleCoverage(args []string) {
fs := flag.NewFlagSet("coverage", flag.ContinueOnError)
fs.Usage = printCoverageUsage
eval := fs.String("e", "", "evaluate inline script string")
format := fs.String("format", "summary", "coverage output format: summary, json")
output := fs.String("o", "", "write output to this path (default: stdout)")
verbose := fs.Bool("v", false, "print analysis stages to stderr")
dynamic := registerDynamicFlag(fs)
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
os.Exit(0)
}
os.Exit(2)
}

var entryPath string
var cleanup func()
if *eval != "" {
var err error
entryPath, cleanup, err = createInlineSourceFile(*eval)
if err != nil {
fmt.Fprintln(os.Stderr, "scriptgo:", err)
os.Exit(1)
}
defer cleanup()
} else if fs.NArg() == 1 {
var err error
entryPath, cleanup, err = resolveInput(fs.Arg(0))
if err != nil {
fmt.Fprintln(os.Stderr, "scriptgo:", err)
os.Exit(1)
}
defer cleanup()
} else {
printCoverageUsage()
os.Exit(2)
}

if *verbose {
fmt.Fprintf(os.Stderr, "scriptgo: analyzing coverage for %s\n", entryPath)
}
options := compiler.BuildOptions{Dynamic: *dynamic}
var result string
var err error
switch *format {
case "summary":
result, err = compiler.CoverageSummary(entryPath, options)
case "json":
result, err = compiler.CoverageReportJSON(entryPath, options)
default:
fmt.Fprintf(os.Stderr, "scriptgo: unsupported coverage format %q (supported: summary, json)\n", *format)
os.Exit(2)
}
if err != nil {
printError(err)
os.Exit(1)
}
if *output == "" {
fmt.Print(result)
return
}
if err := os.WriteFile(*output, []byte(result), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "scriptgo:", err)
os.Exit(1)
}
}
Loading
Loading