From b6c30c182630d250986efda78d021002b7a6bc0a Mon Sep 17 00:00:00 2001 From: hushamsaeed Date: Fri, 1 May 2026 18:39:56 +0500 Subject: [PATCH] feat: implement plinth CLI (new / doctor / version) Single-binary, stdlib-only Go CLI that scaffolds modules from the plinth-dev/starter-{web,api} tags via GitHub's tar.gz endpoint. `plinth new ` fetches the starter, rewrites the Go module path and bare service-name tokens, optionally `git init`s the result. The rename engine skips binaries, lockfiles, and dependency dirs; the fetch tarball extractor strips the codeload `-/` prefix and rejects path traversal. `plinth doctor` checks go/git/node/pnpm minimums and reports docker as optional. `plinth version` reports a Version + Commit baked in via -ldflags from the Makefile. Tests cover the rename engine, fetch+extract (with httptest tarball fixtures), the new-command happy path, name validation, target collisions, and the doctor's PASS/FAIL/SKIP states. CI runs vet + race+cover tests + a build smoke on Go 1.25. End-to-end smoke against the real plinth-dev/starter-{web,api}@v0.1.0 tags scaffolds cleanly: go vet passes on the rewritten API and the web package.json/instrumentation/env-schema all carry the new name. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 30 ++++ .gitignore | 4 +- Makefile | 21 +++ README.md | 107 ++++++++++---- cmd/plinth/main.go | 12 ++ go.mod | 3 + internal/cli/doctor.go | 158 ++++++++++++++++++++ internal/cli/doctor_test.go | 130 ++++++++++++++++ internal/cli/new.go | 262 +++++++++++++++++++++++++++++++++ internal/cli/new_test.go | 213 +++++++++++++++++++++++++++ internal/cli/root.go | 44 ++++++ internal/cli/version.go | 18 +++ internal/cli/version_test.go | 25 ++++ internal/fetch/fetch.go | 140 ++++++++++++++++++ internal/fetch/fetch_test.go | 132 +++++++++++++++++ internal/rename/rename.go | 143 ++++++++++++++++++ internal/rename/rename_test.go | 147 ++++++++++++++++++ 17 files changed, 1560 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 Makefile create mode 100644 cmd/plinth/main.go create mode 100644 go.mod create mode 100644 internal/cli/doctor.go create mode 100644 internal/cli/doctor_test.go create mode 100644 internal/cli/new.go create mode 100644 internal/cli/new_test.go create mode 100644 internal/cli/root.go create mode 100644 internal/cli/version.go create mode 100644 internal/cli/version_test.go create mode 100644 internal/fetch/fetch.go create mode 100644 internal/fetch/fetch_test.go create mode 100644 internal/rename/rename.go create mode 100644 internal/rename/rename_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2cd512e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: Test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: "1.25" + cache-dependency-path: go.sum + - name: Vet + run: go vet ./... + - name: Test + run: go test -race -cover ./... + - name: Build + run: go build -o /tmp/plinth ./cmd/plinth + - name: Smoke + run: | + /tmp/plinth version + /tmp/plinth --help diff --git a/.gitignore b/.gitignore index 609c61a..39b4e99 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ # Go bin/ dist/ -plinth -plinth.exe +/plinth +/plinth.exe *.test *.out coverage.txt diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c6111d0 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) +LDFLAGS := -X github.com/plinth-dev/cli/internal/cli.Version=$(VERSION) \ + -X github.com/plinth-dev/cli/internal/cli.Commit=$(COMMIT) + +.PHONY: build test vet install clean + +build: + go build -ldflags "$(LDFLAGS)" -o bin/plinth ./cmd/plinth + +test: + go test -race -cover ./... + +vet: + go vet ./... + +install: + go install -ldflags "$(LDFLAGS)" ./cmd/plinth + +clean: + rm -rf bin/ coverage.txt coverage.html diff --git a/README.md b/README.md index 5298907..dda8116 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,111 @@ # Plinth — CLI -> **Status: not yet released — Phase E in progress.** -> The Homebrew tap and `go install` paths below are the **target** install flow. Neither resolves yet — there's no Go code in this repo and no `plinth-dev/homebrew-tap` repo. Track progress on the [roadmap](https://github.com/plinth-dev/.github/blob/main/ROADMAP.md). +`plinth` is a single-binary Go CLI that scaffolds new modules from the [`starter-web`](https://github.com/plinth-dev/starter-web) and [`starter-api`](https://github.com/plinth-dev/starter-api) starters. It downloads a pinned starter tag, rewrites identifiers to your chosen names, and (optionally) initialises a fresh git repository. -`plinth` will be a single-binary Go CLI that scaffolds new modules from the Plinth starters. Target: five minutes from idea to deployed-in-dev. - -## Install (target — Phase E) +## Install ```bash -# Homebrew tap -brew install plinth-dev/tap/plinth - -# Or via Go -go install github.com/plinth-dev/cli@latest +go install github.com/plinth-dev/cli/cmd/plinth@latest ``` -## Usage (target) +That writes a `plinth` binary into `$(go env GOPATH)/bin`. Make sure that directory is on your `PATH`. + +The Homebrew tap (`brew install plinth-dev/tap/plinth`) is still on the roadmap. + +## Usage ```bash -# Scaffold a module with both web and API -plinth new my-module --web --api --owner=platform-team --data-class=internal +# Scaffold a new module with both web and API tiers. +plinth new billing --module-path github.com/acme/billing-api # Web only -plinth new my-module --web +plinth new billing --web # API only -plinth new my-module --api +plinth new billing --api --module-path github.com/acme/billing-api + +# Pin to a specific starter tag +plinth new billing --ref v0.1.0 -# Verify local toolchain +# Verify your local toolchain plinth doctor # Print version plinth version ``` -## What `plinth new` does +`plinth new ` produces: + +| Tier | Directory | Default identifier | +|-------|--------------------|-------------------------------| +| API | `-api/` | Go module: `github.com/example/-api` | +| Web | `-web/` | npm `name`: `-web` | + +The Go module path can be overridden with `--module-path`. Output directory is the current working directory by default; use `--dir ` to target somewhere else. + +### Flags + +| Flag | Default | Notes | +|------------------|--------------------------------------|-------| +| `--web` | (off — implies both with `--api` off) | scaffold the Next.js starter | +| `--api` | (off — implies both with `--web` off) | scaffold the Go starter | +| `--dir DIR` | `.` | parent directory for the new scaffolds | +| `--module-path PATH` | `github.com/example/-api` | Go module path for the API scaffold | +| `--ref REF` | `v0.1.0` | starter tag to fetch from GitHub | +| `--no-git` | off | skip `git init` inside generated dirs | + +If neither `--web` nor `--api` is passed, both are scaffolded. + +### What `plinth new` does + +1. Fetches `https://codeload.github.com/plinth-dev//tar.gz/refs/tags/` over HTTPS and extracts it. +2. Rewrites a small fixed set of identifier tokens: + - `github.com/plinth-dev/starter-api` → your `--module-path` + - bare `starter-api` (in `cmd/server/main.go`, `docker-compose.yml`, README) → `-api` + - bare `starter-web` (in `package.json`, `src/lib/env.ts`, `instrumentation-client.ts`) → `-web` +3. Skips binary files, lockfiles (`go.sum`, `pnpm-lock.yaml`), `node_modules/`, `.next/`, `dist/`, `vendor/`. +4. Runs `git init -q -b main` in each scaffolded directory unless `--no-git` is given. + +It does **not** rename the sample `Items` resource (Cerbos policies, handlers, repository) — that's documented as a manual step in each starter's README so you can choose your own resource shape. + +### `plinth doctor` + +Reports `OK` / `FAIL` / `SKIP` for each tool the starters need: + +| Tool | Required | Minimum | +|--------|----------|---------| +| `go` | ✓ | 1.25 | +| `git` | ✓ | 2.30 | +| `node` | ✓ | 20.0 | +| `pnpm` | ✓ | 9.0 | +| `docker` | optional | — | + +Exit status is non-zero if any required tool is missing or below its minimum. + +## Build from source + +```bash +git clone https://github.com/plinth-dev/cli && cd cli +make build # writes ./bin/plinth with version baked in via -ldflags +make test # go test -race -cover ./... +make install # go install with ldflags into $GOPATH/bin +``` -1. Clones [`starter-web`](https://github.com/plinth-dev/starter-web) and/or [`starter-api`](https://github.com/plinth-dev/starter-api) into `my-module/` and `my-module-api/`. -2. Renames everything: module name, env var prefixes, package names, container names, Cerbos resource kind. -3. Optionally creates a GitLab project (with `--gitlab-push`) and pushes. -4. Optionally opens MRs against the GitOps repo (Argo Application) and the policies repo (default Cerbos policy) with `--open-mrs`. -5. Optionally registers the module in Backstage with `--register-backstage`. +## Roadmap -Output is **deterministic for the same inputs** — CI compares generated structure against a checked-in golden tree on every change. +These were in scope of the original CLI vision but are deferred: -## Why both a CLI and a Backstage template +- Homebrew tap (`plinth-dev/homebrew-tap`). +- `--gitlab-push`, `--open-mrs`, `--register-backstage` integrations — these are deployment-platform-specific. Plinth's stance is that the CLI emits clean code; wiring it to your GitOps / portal of choice is a thin layer you control. +- Resource-rename helper (Items → your-thing). +- Golden-tree CI verification of generated output. -The [`scaffolder`](https://github.com/plinth-dev/scaffolder) Backstage template is the in-portal flow for app teams who already use Backstage. The CLI is the offline / scripting / first-time-clusters flow. Both produce identical output for the same inputs (CI verifies). +Track progress on the [main project roadmap](https://github.com/plinth-dev/.github/blob/main/ROADMAP.md). ## Related -- [`scaffolder`](https://github.com/plinth-dev/scaffolder) — the Backstage software template. - [`starter-web`](https://github.com/plinth-dev/starter-web) / [`starter-api`](https://github.com/plinth-dev/starter-api) — what gets cloned. -- [`plinth.run/start/try-it`](https://plinth.run/start/try-it/) — the 60-minute end-to-end tutorial. +- [`scaffolder`](https://github.com/plinth-dev/scaffolder) — Backstage software template (parallel scaffolder for portal users). ## License diff --git a/cmd/plinth/main.go b/cmd/plinth/main.go new file mode 100644 index 0000000..8b9b576 --- /dev/null +++ b/cmd/plinth/main.go @@ -0,0 +1,12 @@ +// Package main is the entry point for the plinth CLI. +package main + +import ( + "os" + + "github.com/plinth-dev/cli/internal/cli" +) + +func main() { + os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d04704d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/plinth-dev/cli + +go 1.25.0 diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go new file mode 100644 index 0000000..e5f3fe1 --- /dev/null +++ b/internal/cli/doctor.go @@ -0,0 +1,158 @@ +package cli + +import ( + "flag" + "fmt" + "io" + "os/exec" + "regexp" + "strconv" + "strings" +) + +const doctorUsage = `plinth doctor — check that your local toolchain can build the starters. + +Required for the API starter: go (>= 1.25), git +Required for the web starter: node (>= 20), pnpm (>= 9), git +Optional: docker (for the local Postgres + Cerbos compose) + +Flags: + --verbose show the version-detection command for each tool +` + +// toolCheck describes a single tool the doctor probes. +type toolCheck struct { + name string + required bool + versionArgs []string + // versionRegex captures the semantic version (without leading 'v') from the + // command's stdout/stderr. + versionRegex *regexp.Regexp + // minMajor / minMinor is the minimum acceptable version. Both zero disables + // version comparison (presence-only check). + minMajor int + minMinor int +} + +var doctorChecks = []toolCheck{ + { + name: "go", + required: true, + versionArgs: []string{"version"}, + versionRegex: regexp.MustCompile(`go(\d+)\.(\d+)`), + minMajor: 1, minMinor: 25, + }, + { + name: "git", + required: true, + versionArgs: []string{"--version"}, + versionRegex: regexp.MustCompile(`git version (\d+)\.(\d+)`), + minMajor: 2, minMinor: 30, + }, + { + name: "node", + required: true, + versionArgs: []string{"--version"}, + versionRegex: regexp.MustCompile(`v(\d+)\.(\d+)`), + minMajor: 20, minMinor: 0, + }, + { + name: "pnpm", + required: true, + versionArgs: []string{"--version"}, + versionRegex: regexp.MustCompile(`(\d+)\.(\d+)`), + minMajor: 9, minMinor: 0, + }, + { + name: "docker", + required: false, + versionArgs: []string{"--version"}, + versionRegex: regexp.MustCompile(`(\d+)\.(\d+)`), + }, +} + +// doctorEnv lets tests stub out exec.LookPath / exec.Command. +type doctorEnv struct { + lookPath func(string) (string, error) + output func(name string, args ...string) ([]byte, error) +} + +func runDoctor(args []string, stdout, stderr io.Writer) int { + return runDoctorWithEnv(args, stdout, stderr, doctorEnv{ + lookPath: exec.LookPath, + output: func(name string, a ...string) ([]byte, error) { + return exec.Command(name, a...).CombinedOutput() + }, + }) +} + +func runDoctorWithEnv(args []string, stdout, stderr io.Writer, env doctorEnv) int { + fs := flag.NewFlagSet("doctor", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { fmt.Fprint(stderr, doctorUsage) } + verbose := fs.Bool("verbose", false, "") + if err := fs.Parse(args); err != nil { + return 2 + } + + failed := 0 + for _, c := range doctorChecks { + status, detail := probe(c, env) + fmt.Fprintf(stdout, "%-10s %s", c.name, status) + if detail != "" { + fmt.Fprintf(stdout, " %s", detail) + } + if *verbose { + fmt.Fprintf(stdout, " [%s %s]", c.name, strings.Join(c.versionArgs, " ")) + } + fmt.Fprintln(stdout) + if status == "FAIL" && c.required { + failed++ + } + } + + fmt.Fprintln(stdout) + if failed > 0 { + fmt.Fprintf(stdout, "%d required tool(s) missing or below minimum version.\n", failed) + return 1 + } + fmt.Fprintln(stdout, "All required tools available.") + return 0 +} + +func probe(c toolCheck, env doctorEnv) (status, detail string) { + if _, err := env.lookPath(c.name); err != nil { + if c.required { + return "FAIL", "not found in PATH" + } + return "SKIP", "not found in PATH (optional)" + } + out, err := env.output(c.name, c.versionArgs...) + if err != nil { + return "FAIL", fmt.Sprintf("`%s %s` failed: %v", c.name, strings.Join(c.versionArgs, " "), err) + } + major, minor, raw, ok := parseVersion(c.versionRegex, string(out)) + if !ok { + return "WARN", "could not parse version" + } + if c.minMajor == 0 && c.minMinor == 0 { + return "OK", raw + } + if major < c.minMajor || (major == c.minMajor && minor < c.minMinor) { + return "FAIL", fmt.Sprintf("found %s, need >= %d.%d", raw, c.minMajor, c.minMinor) + } + return "OK", raw +} + +func parseVersion(re *regexp.Regexp, s string) (major, minor int, raw string, ok bool) { + m := re.FindStringSubmatch(s) + if len(m) < 3 { + return 0, 0, "", false + } + a, err1 := strconv.Atoi(m[1]) + b, err2 := strconv.Atoi(m[2]) + if err1 != nil || err2 != nil { + return 0, 0, "", false + } + return a, b, fmt.Sprintf("%d.%d", a, b), true +} diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go new file mode 100644 index 0000000..f2ff67e --- /dev/null +++ b/internal/cli/doctor_test.go @@ -0,0 +1,130 @@ +package cli + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +func TestDoctor_AllPresent(t *testing.T) { + env := doctorEnv{ + lookPath: func(string) (string, error) { return "/usr/bin/x", nil }, + output: func(name string, _ ...string) ([]byte, error) { + switch name { + case "go": + return []byte("go version go1.25.0 darwin/arm64"), nil + case "git": + return []byte("git version 2.45.0"), nil + case "node": + return []byte("v22.0.0"), nil + case "pnpm": + return []byte("9.10.0"), nil + case "docker": + return []byte("Docker version 27.0.0, build abcd1234"), nil + } + return nil, errors.New("unknown") + }, + } + var stdout, stderr bytes.Buffer + code := runDoctorWithEnv(nil, &stdout, &stderr, env) + if code != 0 { + t.Fatalf("exit=%d stdout=%s", code, stdout.String()) + } + if !strings.Contains(stdout.String(), "All required tools available.") { + t.Errorf("missing OK summary: %s", stdout.String()) + } +} + +func TestDoctor_MissingRequired(t *testing.T) { + env := doctorEnv{ + lookPath: func(name string) (string, error) { + if name == "node" { + return "", errors.New("not found") + } + return "/usr/bin/x", nil + }, + output: func(name string, _ ...string) ([]byte, error) { + switch name { + case "go": + return []byte("go version go1.25.0"), nil + case "git": + return []byte("git version 2.45.0"), nil + case "pnpm": + return []byte("9.10.0"), nil + case "docker": + return []byte("Docker version 27.0.0"), nil + } + return nil, errors.New("unknown") + }, + } + var stdout, stderr bytes.Buffer + code := runDoctorWithEnv(nil, &stdout, &stderr, env) + if code != 1 { + t.Fatalf("expected exit 1, got %d, stdout=%s", code, stdout.String()) + } + if !strings.Contains(stdout.String(), "node FAIL") { + t.Errorf("expected node FAIL in stdout: %s", stdout.String()) + } +} + +func TestDoctor_BelowMinVersion(t *testing.T) { + env := doctorEnv{ + lookPath: func(string) (string, error) { return "/usr/bin/x", nil }, + output: func(name string, _ ...string) ([]byte, error) { + switch name { + case "go": + return []byte("go version go1.23.0 darwin/arm64"), nil + case "git": + return []byte("git version 2.45.0"), nil + case "node": + return []byte("v22.0.0"), nil + case "pnpm": + return []byte("9.10.0"), nil + case "docker": + return []byte("Docker version 27.0.0"), nil + } + return nil, errors.New("unknown") + }, + } + var stdout, stderr bytes.Buffer + code := runDoctorWithEnv(nil, &stdout, &stderr, env) + if code != 1 { + t.Fatalf("expected exit 1 for old go, got %d", code) + } + if !strings.Contains(stdout.String(), "need >= 1.25") { + t.Errorf("expected min-version detail: %s", stdout.String()) + } +} + +func TestDoctor_OptionalDockerSkip(t *testing.T) { + env := doctorEnv{ + lookPath: func(name string) (string, error) { + if name == "docker" { + return "", errors.New("not found") + } + return "/usr/bin/x", nil + }, + output: func(name string, _ ...string) ([]byte, error) { + switch name { + case "go": + return []byte("go version go1.25.0"), nil + case "git": + return []byte("git version 2.45.0"), nil + case "node": + return []byte("v22.0.0"), nil + case "pnpm": + return []byte("9.10.0"), nil + } + return nil, errors.New("unknown") + }, + } + var stdout, stderr bytes.Buffer + code := runDoctorWithEnv(nil, &stdout, &stderr, env) + if code != 0 { + t.Fatalf("expected 0 with docker skipped, got %d, stdout=%s", code, stdout.String()) + } + if !strings.Contains(stdout.String(), "docker SKIP") { + t.Errorf("expected docker SKIP: %s", stdout.String()) + } +} diff --git a/internal/cli/new.go b/internal/cli/new.go new file mode 100644 index 0000000..a64a7bb --- /dev/null +++ b/internal/cli/new.go @@ -0,0 +1,262 @@ +package cli + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/plinth-dev/cli/internal/fetch" + "github.com/plinth-dev/cli/internal/rename" +) + +const newUsage = `plinth new — scaffold a new module from the Plinth starters. + +Usage: + plinth new [flags] + + must be lowercase kebab-case (e.g. "billing", "user-profile"). + By default both --web and --api scaffolds are created. Use --web or --api + alone to scaffold only one. + + Web scaffold lands in /-web/ + API scaffold lands in /-api/ + +Flags: + --web scaffold the Next.js starter (default if --api unset) + --api scaffold the Go starter (default if --web unset) + --dir DIR parent directory for the new scaffolds (default ".") + --module-path PATH Go module path for the API scaffold + (default "github.com/example/-api") + --ref REF starter tag to fetch (default "v0.1.0") + --no-git skip "git init" inside generated directories + +Example: + plinth new billing --module-path github.com/acme/billing-api +` + +// scaffoldDeps lets tests inject a fake fetcher and skip the real git binary. +type scaffoldDeps struct { + fetcher starterFetcher + gitInit func(dir string) error +} + +type starterFetcher interface { + FetchAndExtract(ctx context.Context, owner, repo, ref, dst string) error +} + +func runNew(args []string, stdout, stderr io.Writer) int { + return runNewWithDeps(args, stdout, stderr, scaffoldDeps{ + fetcher: fetch.New(), + gitInit: defaultGitInit, + }) +} + +func runNewWithDeps(args []string, stdout, stderr io.Writer, deps scaffoldDeps) int { + fs := flag.NewFlagSet("new", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { fmt.Fprint(stderr, newUsage) } + + var ( + web = fs.Bool("web", false, "") + api = fs.Bool("api", false, "") + dir = fs.String("dir", ".", "") + modulePath = fs.String("module-path", "", "") + ref = fs.String("ref", "v0.1.0", "") + noGit = fs.Bool("no-git", false, "") + ) + + name, rest, ok := extractName(args) + if !ok { + fmt.Fprint(stderr, newUsage) + return 2 + } + if err := fs.Parse(rest); err != nil { + return 2 + } + if fs.NArg() > 0 { + fmt.Fprintf(stderr, "plinth: unexpected extra arguments: %v\n", fs.Args()) + return 2 + } + if !validName(name) { + fmt.Fprintf(stderr, "plinth: invalid name %q — must be lowercase kebab-case (e.g. \"billing\")\n", name) + return 2 + } + + doWeb, doAPI := *web, *api + if !doWeb && !doAPI { + doWeb, doAPI = true, true + } + + if *modulePath == "" { + *modulePath = "github.com/example/" + name + "-api" + } + + parent, err := filepath.Abs(*dir) + if err != nil { + fmt.Fprintf(stderr, "plinth: resolve --dir: %v\n", err) + return 1 + } + if err := os.MkdirAll(parent, 0o755); err != nil { + fmt.Fprintf(stderr, "plinth: mkdir %s: %v\n", parent, err) + return 1 + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + type plan struct { + repo string + dst string + serviceName string + replacements []rename.Replacement + } + var plans []plan + if doAPI { + serviceName := name + "-api" + plans = append(plans, plan{ + repo: "starter-api", + dst: filepath.Join(parent, serviceName), + serviceName: serviceName, + replacements: rename.ForAPI(*modulePath, serviceName), + }) + } + if doWeb { + serviceName := name + "-web" + plans = append(plans, plan{ + repo: "starter-web", + dst: filepath.Join(parent, serviceName), + serviceName: serviceName, + replacements: rename.ForWeb(serviceName), + }) + } + + for _, p := range plans { + if _, err := os.Stat(p.dst); err == nil { + fmt.Fprintf(stderr, "plinth: refusing to scaffold into existing path %s\n", p.dst) + return 1 + } else if !os.IsNotExist(err) { + fmt.Fprintf(stderr, "plinth: stat %s: %v\n", p.dst, err) + return 1 + } + } + + for _, p := range plans { + fmt.Fprintf(stdout, "→ fetching %s@%s into %s\n", p.repo, *ref, p.dst) + if err := deps.fetcher.FetchAndExtract(ctx, "plinth-dev", p.repo, *ref, p.dst); err != nil { + fmt.Fprintf(stderr, "plinth: %v\n", err) + _ = os.RemoveAll(p.dst) + return 1 + } + fmt.Fprintf(stdout, "→ rewriting identifiers in %s\n", p.serviceName) + if err := rename.Apply(p.dst, p.replacements); err != nil { + fmt.Fprintf(stderr, "plinth: %v\n", err) + return 1 + } + if !*noGit { + if err := deps.gitInit(p.dst); err != nil { + fmt.Fprintf(stderr, "plinth: warning: git init failed in %s: %v\n", p.dst, err) + } + } + } + + printNextSteps(stdout, name, parent, doWeb, doAPI, *modulePath) + return 0 +} + +// extractName pulls the first non-flag argument out of args. It treats `-x` and +// `--x` as flag tokens; if a flag uses the space-separated form (`--dir foo`) +// the value `foo` would look like a positional, so we also skip the next token +// for known value-taking flags. +func extractName(args []string) (string, []string, bool) { + valueFlags := map[string]bool{ + "--dir": true, "--module-path": true, "--ref": true, + "-dir": true, "-module-path": true, "-ref": true, + } + rest := make([]string, 0, len(args)) + skipNext := false + name := "" + for _, a := range args { + if skipNext { + rest = append(rest, a) + skipNext = false + continue + } + if strings.HasPrefix(a, "-") { + rest = append(rest, a) + if strings.Contains(a, "=") { + continue + } + if valueFlags[a] { + skipNext = true + } + continue + } + if name == "" { + name = a + continue + } + rest = append(rest, a) + } + if name == "" { + return "", nil, false + } + return name, rest, true +} + +var nameRE = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`) + +func validName(s string) bool { + if len(s) == 0 || len(s) > 64 { + return false + } + return nameRE.MatchString(s) +} + +func defaultGitInit(dir string) error { + if _, err := exec.LookPath("git"); err != nil { + return err + } + cmd := exec.Command("git", "init", "-q", "-b", "main") + cmd.Dir = dir + return cmd.Run() +} + +func printNextSteps(w io.Writer, name, parent string, doWeb, doAPI bool, modulePath string) { + display := func(sub string) string { + full := filepath.Join(parent, sub) + if rel, err := filepath.Rel(mustWD(), full); err == nil && !strings.HasPrefix(rel, "..") { + return rel + } + return full + } + fmt.Fprintln(w, "") + fmt.Fprintln(w, "Done. Next steps:") + if doAPI { + fmt.Fprintf(w, " cd %s\n", display(name+"-api")) + fmt.Fprintf(w, " # Go module path is %s\n", modulePath) + fmt.Fprintln(w, " # Edit cerbos/policies/items.yaml to rename the resource kind") + fmt.Fprintln(w, " docker compose up -d") + fmt.Fprintln(w, " go run ./cmd/server") + fmt.Fprintln(w, "") + } + if doWeb { + fmt.Fprintf(w, " cd %s\n", display(name+"-web")) + fmt.Fprintln(w, " pnpm install") + fmt.Fprintln(w, " pnpm dev") + } +} + +func mustWD() string { + wd, err := os.Getwd() + if err != nil { + return "." + } + return strings.TrimSpace(wd) +} diff --git a/internal/cli/new_test.go b/internal/cli/new_test.go new file mode 100644 index 0000000..5cedbe8 --- /dev/null +++ b/internal/cli/new_test.go @@ -0,0 +1,213 @@ +package cli + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/plinth-dev/cli/internal/fetch" +) + +func TestRunNew_BothScaffolds(t *testing.T) { + srv := starterServer(t, map[string]map[string]string{ + "starter-api": { + "go.mod": "module github.com/plinth-dev/starter-api\n\ngo 1.25.0\n", + "cmd/server/main.go": "package main\n// starter-api\n", + }, + "starter-web": { + "package.json": `{"name": "starter-web"}`, + "instrumentation-client.ts": `serviceName: "starter-web"`, + }, + }) + defer srv.Close() + + tmp := t.TempDir() + deps := scaffoldDeps{ + fetcher: &fetch.Client{BaseURL: srv.URL, HTTPClient: srv.Client()}, + gitInit: func(string) error { return nil }, + } + + var stdout, stderr bytes.Buffer + code := runNewWithDeps( + []string{"billing", "--dir", tmp, "--module-path", "github.com/acme/billing-api", "--ref", "v0.1.0"}, + &stdout, &stderr, deps, + ) + if code != 0 { + t.Fatalf("runNew exit=%d stderr=%s", code, stderr.String()) + } + + gomod := mustRead(t, tmp, "billing-api/go.mod") + if !strings.Contains(gomod, "module github.com/acme/billing-api") { + t.Errorf("api go.mod not rewritten: %s", gomod) + } + main := mustRead(t, tmp, "billing-api/cmd/server/main.go") + if !strings.Contains(main, "// billing-api") { + t.Errorf("api main.go not rewritten: %s", main) + } + + pkg := mustRead(t, tmp, "billing-web/package.json") + if !strings.Contains(pkg, `"name": "billing-web"`) { + t.Errorf("web package.json not rewritten: %s", pkg) + } +} + +func TestRunNew_WebOnly(t *testing.T) { + srv := starterServer(t, map[string]map[string]string{ + "starter-web": {"package.json": `{"name": "starter-web"}`}, + }) + defer srv.Close() + + tmp := t.TempDir() + deps := scaffoldDeps{ + fetcher: &fetch.Client{BaseURL: srv.URL, HTTPClient: srv.Client()}, + gitInit: func(string) error { return nil }, + } + + var stdout, stderr bytes.Buffer + code := runNewWithDeps([]string{"acme", "--web", "--dir", tmp}, &stdout, &stderr, deps) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr.String()) + } + if _, err := os.Stat(filepath.Join(tmp, "acme-api")); !os.IsNotExist(err) { + t.Errorf("acme-api should not exist: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "acme-web")); err != nil { + t.Errorf("acme-web should exist: %v", err) + } +} + +func TestRunNew_RejectsBadName(t *testing.T) { + tmp := t.TempDir() + var stdout, stderr bytes.Buffer + code := runNewWithDeps([]string{"Bad_Name", "--dir", tmp}, &stdout, &stderr, + scaffoldDeps{fetcher: noopFetcher{}, gitInit: func(string) error { return nil }}) + if code != 2 { + t.Errorf("expected exit 2 for bad name, got %d", code) + } + if !strings.Contains(stderr.String(), "invalid name") { + t.Errorf("stderr missing message: %s", stderr.String()) + } +} + +func TestRunNew_MissingName(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runNewWithDeps([]string{}, &stdout, &stderr, + scaffoldDeps{fetcher: noopFetcher{}, gitInit: func(string) error { return nil }}) + if code != 2 { + t.Errorf("expected exit 2, got %d", code) + } +} + +func TestRunNew_RefusesExistingDir(t *testing.T) { + srv := starterServer(t, map[string]map[string]string{ + "starter-api": {"go.mod": "module github.com/plinth-dev/starter-api\n"}, + }) + defer srv.Close() + + tmp := t.TempDir() + if err := os.MkdirAll(filepath.Join(tmp, "billing-api"), 0o755); err != nil { + t.Fatal(err) + } + + deps := scaffoldDeps{ + fetcher: &fetch.Client{BaseURL: srv.URL, HTTPClient: srv.Client()}, + gitInit: func(string) error { return nil }, + } + var stdout, stderr bytes.Buffer + code := runNewWithDeps([]string{"billing", "--api", "--dir", tmp}, &stdout, &stderr, deps) + if code == 0 { + t.Errorf("expected nonzero exit when target exists") + } + if !strings.Contains(stderr.String(), "existing path") { + t.Errorf("stderr missing collision message: %s", stderr.String()) + } +} + +func TestValidName(t *testing.T) { + good := []string{"a", "billing", "user-profile", "x1", "abc-def-ghi", "v2"} + bad := []string{"", "Billing", "user_profile", "-billing", "billing-", "1abc", "billing/api", strings.Repeat("a", 65)} + for _, s := range good { + if !validName(s) { + t.Errorf("validName(%q) = false, want true", s) + } + } + for _, s := range bad { + if validName(s) { + t.Errorf("validName(%q) = true, want false", s) + } + } +} + +// noopFetcher satisfies starterFetcher for tests that error out before fetch. +type noopFetcher struct{} + +func (noopFetcher) FetchAndExtract(_ context.Context, _, _, _, _ string) error { + return nil +} + +// starterServer returns an httptest.Server that maps paths +// "/plinth-dev//tar.gz/refs/tags/" to a tarball built from the +// supplied entries. Each file is prefixed with "-/" inside the +// archive, matching GitHub's codeload format. +func starterServer(t *testing.T, repos map[string]map[string]string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // /plinth-dev//tar.gz/refs/tags/ + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/") + if len(parts) != 6 || parts[0] != "plinth-dev" || parts[2] != "tar.gz" { + http.NotFound(w, r) + return + } + repo, ref := parts[1], parts[5] + entries, ok := repos[repo] + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/gzip") + w.Write(buildTarball(t, repo, ref, entries)) + })) +} + +func buildTarball(t *testing.T, repo, ref string, entries map[string]string) []byte { + t.Helper() + prefix := repo + "-" + strings.TrimPrefix(ref, "v") + "/" + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{Name: prefix, Typeflag: tar.TypeDir, Mode: 0o755}); err != nil { + t.Fatal(err) + } + for name, body := range entries { + hdr := &tar.Header{Name: prefix + name, Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(body))} + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func mustRead(t *testing.T, root, rel string) string { + t.Helper() + body, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + return string(body) +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..f2efa83 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,44 @@ +// Package cli implements the plinth subcommand dispatch. +package cli + +import ( + "fmt" + "io" +) + +const usage = `plinth — scaffold modules from the Plinth starters. + +Usage: + plinth [flags] + +Commands: + new Scaffold a new module from starter-web and/or starter-api + doctor Check that local toolchain prerequisites are installed + version Print the CLI version + +Run "plinth --help" for command-specific flags. +` + +// Run dispatches a subcommand and returns a process exit code. +func Run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprint(stdout, usage) + return 0 + } + + cmd, rest := args[0], args[1:] + switch cmd { + case "new": + return runNew(rest, stdout, stderr) + case "doctor": + return runDoctor(rest, stdout, stderr) + case "version", "--version", "-v": + return runVersion(stdout) + case "help", "--help", "-h": + fmt.Fprint(stdout, usage) + return 0 + default: + fmt.Fprintf(stderr, "plinth: unknown command %q\n\n%s", cmd, usage) + return 2 + } +} diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..7d14956 --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,18 @@ +package cli + +import ( + "fmt" + "io" + "runtime" +) + +// Set via -ldflags "-X github.com/plinth-dev/cli/internal/cli.Version=..." at build. +var ( + Version = "dev" + Commit = "none" +) + +func runVersion(stdout io.Writer) int { + fmt.Fprintf(stdout, "plinth %s (commit %s, %s)\n", Version, Commit, runtime.Version()) + return 0 +} diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go new file mode 100644 index 0000000..952f4c8 --- /dev/null +++ b/internal/cli/version_test.go @@ -0,0 +1,25 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func TestVersionPrints(t *testing.T) { + defer func(v, c string) { Version, Commit = v, c }(Version, Commit) + Version = "0.1.0" + Commit = "abc123" + + var stdout bytes.Buffer + if code := runVersion(&stdout); code != 0 { + t.Fatalf("exit=%d", code) + } + got := stdout.String() + if !strings.Contains(got, "plinth 0.1.0") { + t.Errorf("missing version: %s", got) + } + if !strings.Contains(got, "commit abc123") { + t.Errorf("missing commit: %s", got) + } +} diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go new file mode 100644 index 0000000..193e94d --- /dev/null +++ b/internal/fetch/fetch.go @@ -0,0 +1,140 @@ +// Package fetch downloads and extracts starter tarballs from GitHub. +// +// GitHub serves a tarball of any tag at +// https://codeload.github.com///tar.gz/refs/tags/ +// whose entries are prefixed with -/. Extract strips +// that prefix. +package fetch + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// DefaultBaseURL is GitHub's tarball host. Override in tests. +const DefaultBaseURL = "https://codeload.github.com" + +// Client downloads and extracts starter archives. +type Client struct { + BaseURL string + HTTPClient *http.Client +} + +// New returns a Client with sensible defaults. +func New() *Client { + return &Client{ + BaseURL: DefaultBaseURL, + HTTPClient: &http.Client{Timeout: 60 * time.Second}, + } +} + +// FetchAndExtract downloads /@ and extracts it into dst. +// dst must not exist; it will be created. The leading "-/" path +// component from each archive entry is stripped. +func (c *Client) FetchAndExtract(ctx context.Context, owner, repo, ref, dst string) error { + url := fmt.Sprintf("%s/%s/%s/tar.gz/refs/tags/%s", c.BaseURL, owner, repo, ref) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("fetch: build request: %w", err) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("fetch: GET %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("fetch: GET %s: status %d", url, resp.StatusCode) + } + + if err := os.MkdirAll(dst, 0o755); err != nil { + return fmt.Errorf("fetch: mkdir %s: %w", dst, err) + } + + return extract(resp.Body, dst) +} + +// extract reads a gzipped tar from r and writes its contents to dst, stripping +// the first path component from every entry. +func extract(r io.Reader, dst string) error { + gz, err := gzip.NewReader(r) + if err != nil { + return fmt.Errorf("fetch: gunzip: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + dstAbs, err := filepath.Abs(dst) + if err != nil { + return fmt.Errorf("fetch: abs %s: %w", dst, err) + } + + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("fetch: tar next: %w", err) + } + + name := stripFirstComponent(hdr.Name) + if name == "" { + continue // top-level dir entry + } + + // Reject path traversal: the cleaned absolute target must stay within dst. + target := filepath.Join(dstAbs, name) + if !strings.HasPrefix(target+string(filepath.Separator), dstAbs+string(filepath.Separator)) && target != dstAbs { + return fmt.Errorf("fetch: refusing to extract outside dst: %q", hdr.Name) + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return fmt.Errorf("fetch: mkdir %s: %w", target, err) + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("fetch: mkdir %s: %w", filepath.Dir(target), err) + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)&0o777) + if err != nil { + return fmt.Errorf("fetch: create %s: %w", target, err) + } + if _, err := io.Copy(f, tr); err != nil { + f.Close() + return fmt.Errorf("fetch: write %s: %w", target, err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("fetch: close %s: %w", target, err) + } + case tar.TypeSymlink: + // Skip symlinks — starter trees don't use them and they're a security risk. + continue + default: + // Skip other entry types (hardlinks, devices, etc.). + continue + } + } + return nil +} + +func stripFirstComponent(name string) string { + name = filepath.ToSlash(name) + idx := strings.IndexByte(name, '/') + if idx < 0 { + return "" + } + return name[idx+1:] +} diff --git a/internal/fetch/fetch_test.go b/internal/fetch/fetch_test.go new file mode 100644 index 0000000..348c1aa --- /dev/null +++ b/internal/fetch/fetch_test.go @@ -0,0 +1,132 @@ +package fetch + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFetchAndExtract(t *testing.T) { + body := buildTarball(t, map[string]string{ + "starter-api-0.1.0/": "", + "starter-api-0.1.0/go.mod": "module github.com/plinth-dev/starter-api\n\ngo 1.25.0\n", + "starter-api-0.1.0/cmd/server/main.go": "package main\n", + "starter-api-0.1.0/README.md": "# starter-api\n", + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if want := "/plinth-dev/starter-api/tar.gz/refs/tags/v0.1.0"; r.URL.Path != want { + t.Errorf("unexpected path %q, want %q", r.URL.Path, want) + } + w.Header().Set("Content-Type", "application/gzip") + w.Write(body) + })) + defer srv.Close() + + dst := t.TempDir() + c := &Client{BaseURL: srv.URL, HTTPClient: srv.Client()} + if err := c.FetchAndExtract(context.Background(), "plinth-dev", "starter-api", "v0.1.0", dst); err != nil { + t.Fatalf("FetchAndExtract: %v", err) + } + + gomod, err := os.ReadFile(filepath.Join(dst, "go.mod")) + if err != nil { + t.Fatalf("read go.mod: %v", err) + } + if !strings.Contains(string(gomod), "module github.com/plinth-dev/starter-api") { + t.Errorf("go.mod missing module line: %q", gomod) + } + + if _, err := os.Stat(filepath.Join(dst, "cmd", "server", "main.go")); err != nil { + t.Errorf("missing cmd/server/main.go: %v", err) + } +} + +func TestExtractRejectsTraversal(t *testing.T) { + body := buildTarball(t, map[string]string{ + "starter-api-0.1.0/": "", + "starter-api-0.1.0/../escape.txt": "nope\n", + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(body) + })) + defer srv.Close() + + dst := t.TempDir() + c := &Client{BaseURL: srv.URL, HTTPClient: srv.Client()} + err := c.FetchAndExtract(context.Background(), "plinth-dev", "starter-api", "v0.1.0", dst) + if err == nil || !strings.Contains(err.Error(), "outside dst") { + t.Fatalf("expected traversal error, got %v", err) + } +} + +func TestFetchAndExtractNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + dst := t.TempDir() + c := &Client{BaseURL: srv.URL, HTTPClient: srv.Client()} + err := c.FetchAndExtract(context.Background(), "plinth-dev", "starter-api", "v9.9.9", dst) + if err == nil || !strings.Contains(err.Error(), "404") { + t.Fatalf("expected 404 error, got %v", err) + } +} + +func TestStripFirstComponent(t *testing.T) { + cases := map[string]string{ + "starter-api-0.1.0/": "", + "starter-api-0.1.0/go.mod": "go.mod", + "starter-api-0.1.0/a/b/c.txt": "a/b/c.txt", + "justonecomponent": "", + } + for in, want := range cases { + if got := stripFirstComponent(in); got != want { + t.Errorf("stripFirstComponent(%q) = %q, want %q", in, got, want) + } + } +} + +// buildTarball returns a gzipped tar where each map entry becomes a file +// (or directory if its name ends in "/"). +func buildTarball(t *testing.T, entries map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + for name, body := range entries { + hdr := &tar.Header{Name: name, Mode: 0o644} + if strings.HasSuffix(name, "/") { + hdr.Typeflag = tar.TypeDir + hdr.Mode = 0o755 + } else { + hdr.Typeflag = tar.TypeReg + hdr.Size = int64(len(body)) + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("WriteHeader: %v", err) + } + if hdr.Typeflag == tar.TypeReg { + if _, err := tw.Write([]byte(body)); err != nil { + t.Fatalf("tar write: %v", err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatalf("tw.Close: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("gz.Close: %v", err) + } + return buf.Bytes() +} diff --git a/internal/rename/rename.go b/internal/rename/rename.go new file mode 100644 index 0000000..c960db5 --- /dev/null +++ b/internal/rename/rename.go @@ -0,0 +1,143 @@ +// Package rename rewrites starter scaffolds in place: it replaces a fixed set +// of identifier tokens (Go module path, service name, npm package name) with +// user-chosen values across every text file under a directory. +package rename + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// Replacement is an ordered string substitution applied to file bodies. +// +// Order matters: longer/more-specific tokens MUST come before shorter ones +// they share a prefix with. For example, replacing the full Go module path +// `github.com/plinth-dev/starter-api` must run before the bare service-name +// token `starter-api` so the latter does not corrupt the former. +type Replacement struct { + Old string + New string +} + +// Apply walks root and rewrites every text file body using replacements. +// Binary files, dependency directories, and lockfiles are skipped — see +// shouldSkip / looksBinary for the policy. +func Apply(root string, replacements []Replacement) error { + if len(replacements) == 0 { + return nil + } + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if shouldSkipDir(d.Name()) { + return filepath.SkipDir + } + return nil + } + if shouldSkipFile(d.Name()) { + return nil + } + return rewriteFile(path, replacements) + }) +} + +func rewriteFile(path string, replacements []Replacement) error { + body, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("rename: read %s: %w", path, err) + } + if looksBinary(body) { + return nil + } + + updated := body + for _, r := range replacements { + if r.Old == "" || r.Old == r.New { + continue + } + if !strings.Contains(string(updated), r.Old) { + continue + } + updated = []byte(strings.ReplaceAll(string(updated), r.Old, r.New)) + } + if string(updated) == string(body) { + return nil + } + + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("rename: stat %s: %w", path, err) + } + if err := os.WriteFile(path, updated, info.Mode().Perm()); err != nil { + return fmt.Errorf("rename: write %s: %w", path, err) + } + return nil +} + +func shouldSkipDir(name string) bool { + switch name { + case ".git", "node_modules", ".next", "dist", "build", "vendor", "coverage", ".turbo", ".pnpm-store": + return true + } + return false +} + +func shouldSkipFile(name string) bool { + switch name { + case "go.sum", "pnpm-lock.yaml", "package-lock.json", "yarn.lock": + return true + } + switch filepath.Ext(name) { + case ".tsbuildinfo", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svg", + ".woff", ".woff2", ".ttf", ".otf", ".eot", + ".zip", ".gz", ".tgz", ".tar", ".bz2", ".xz", ".7z", + ".pdf", ".mp3", ".mp4", ".mov", ".webm", + ".wasm": + return true + } + return false +} + +// looksBinary reports whether body appears to be binary content. We treat any +// embedded NUL byte in the first 8 KiB as a binary signal — matches what `git` +// does for diff/grep heuristics. +func looksBinary(body []byte) bool { + const probe = 8 << 10 + if len(body) > probe { + body = body[:probe] + } + for _, b := range body { + if b == 0 { + return true + } + } + return false +} + +// ForAPI returns the standard replacement set for the starter-api scaffold. +// +// modulePath is the full Go module path the user wants +// (e.g. "github.com/acme/billing-api"). serviceName is the bare service +// identifier baked into log lines, OTel operation names, and docker-compose +// (e.g. "billing-api"). The returned replacements are ordered: full module +// path first, then bare service name. +func ForAPI(modulePath, serviceName string) []Replacement { + return []Replacement{ + {Old: "github.com/plinth-dev/starter-api", New: modulePath}, + {Old: "starter-api", New: serviceName}, + } +} + +// ForWeb returns the replacement set for the starter-web scaffold. +// packageName is the npm package name and service identifier +// (e.g. "billing-web"). +func ForWeb(packageName string) []Replacement { + return []Replacement{ + {Old: "starter-web", New: packageName}, + } +} diff --git a/internal/rename/rename_test.go b/internal/rename/rename_test.go new file mode 100644 index 0000000..9bf8071 --- /dev/null +++ b/internal/rename/rename_test.go @@ -0,0 +1,147 @@ +package rename + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestApplyAPI(t *testing.T) { + root := t.TempDir() + mustWrite(t, root, "go.mod", `module github.com/plinth-dev/starter-api + +go 1.25.0 + +require github.com/plinth-dev/sdk-go/audit v0.1.0 +`) + mustWrite(t, root, "cmd/server/main.go", `package main + +import ( + "github.com/plinth-dev/sdk-go/audit" + "github.com/plinth-dev/starter-api/internal/handlers" +) + +// starter-api entry point +var _ = handlers.X +var _ = audit.Event{} +`) + mustWrite(t, root, "docker-compose.yml", "services:\n api:\n environment:\n SERVICE_NAME: starter-api\n") + mustWrite(t, root, "go.sum", "github.com/plinth-dev/starter-api should-not-be-touched\n") + // node_modules content must be skipped wholesale. + mustWrite(t, root, "node_modules/some-pkg/index.js", "starter-api should-not-be-touched\n") + + if err := Apply(root, ForAPI("github.com/acme/billing-api", "billing-api")); err != nil { + t.Fatalf("Apply: %v", err) + } + + gomod := mustRead(t, root, "go.mod") + if !strings.Contains(gomod, "module github.com/acme/billing-api") { + t.Errorf("go.mod module path not rewritten: %s", gomod) + } + if !strings.Contains(gomod, "github.com/plinth-dev/sdk-go/audit") { + t.Errorf("sdk-go dep was corrupted: %s", gomod) + } + + mainGo := mustRead(t, root, "cmd/server/main.go") + if !strings.Contains(mainGo, `"github.com/acme/billing-api/internal/handlers"`) { + t.Errorf("internal import not rewritten: %s", mainGo) + } + if !strings.Contains(mainGo, `"github.com/plinth-dev/sdk-go/audit"`) { + t.Errorf("sdk-go import was corrupted: %s", mainGo) + } + if !strings.Contains(mainGo, "// billing-api entry point") { + t.Errorf("comment service-name not rewritten: %s", mainGo) + } + + dc := mustRead(t, root, "docker-compose.yml") + if !strings.Contains(dc, "SERVICE_NAME: billing-api") { + t.Errorf("docker-compose service name not rewritten: %s", dc) + } + + if got := mustRead(t, root, "go.sum"); !strings.Contains(got, "starter-api should-not-be-touched") { + t.Errorf("go.sum was rewritten (must be skipped): %s", got) + } + if got := mustRead(t, root, "node_modules/some-pkg/index.js"); !strings.Contains(got, "starter-api should-not-be-touched") { + t.Errorf("node_modules was rewritten (must be skipped): %s", got) + } +} + +func TestApplyWeb(t *testing.T) { + root := t.TempDir() + mustWrite(t, root, "package.json", `{"name": "starter-web", "version": "0.1.0"}`) + mustWrite(t, root, "src/lib/env.ts", `SERVICE_NAME: z.string().default("starter-web")`) + mustWrite(t, root, "instrumentation-client.ts", `serviceName: process.env.NEXT_PUBLIC_SERVICE_NAME ?? "starter-web"`) + + if err := Apply(root, ForWeb("billing-web")); err != nil { + t.Fatalf("Apply: %v", err) + } + + pkg := mustRead(t, root, "package.json") + if !strings.Contains(pkg, `"name": "billing-web"`) { + t.Errorf("package.json name not rewritten: %s", pkg) + } + env := mustRead(t, root, "src/lib/env.ts") + if !strings.Contains(env, `default("billing-web")`) { + t.Errorf("env.ts default not rewritten: %s", env) + } +} + +func TestApplyIsIdempotent(t *testing.T) { + root := t.TempDir() + mustWrite(t, root, "go.mod", "module github.com/plinth-dev/starter-api\n") + + repls := ForAPI("github.com/acme/billing-api", "billing-api") + if err := Apply(root, repls); err != nil { + t.Fatalf("first Apply: %v", err) + } + want := mustRead(t, root, "go.mod") + if err := Apply(root, repls); err != nil { + t.Fatalf("second Apply: %v", err) + } + if got := mustRead(t, root, "go.mod"); got != want { + t.Errorf("second Apply changed file: %q vs %q", got, want) + } +} + +func TestApplySkipsBinaries(t *testing.T) { + root := t.TempDir() + binary := append([]byte("starter-web\x00binary"), 0x01, 0x02, 0x03) + mustWriteRaw(t, root, "logo.png", binary) + + if err := Apply(root, ForWeb("billing-web")); err != nil { + t.Fatalf("Apply: %v", err) + } + got, err := os.ReadFile(filepath.Join(root, "logo.png")) + if err != nil { + t.Fatal(err) + } + if string(got) != string(binary) { + t.Errorf("binary file rewritten: %v", got) + } +} + +func mustWrite(t *testing.T, root, rel, body string) { + t.Helper() + mustWriteRaw(t, root, rel, []byte(body)) +} + +func mustWriteRaw(t *testing.T, root, rel string, body []byte) { + t.Helper() + full := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, body, 0o644); err != nil { + t.Fatal(err) + } +} + +func mustRead(t *testing.T, root, rel string) string { + t.Helper() + body, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + t.Fatal(err) + } + return string(body) +}