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
30 changes: 30 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Go
bin/
dist/
plinth
plinth.exe
/plinth
/plinth.exe
*.test
*.out
coverage.txt
Expand Down
21 changes: 21 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
107 changes: 80 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <name>` produces:

| Tier | Directory | Default identifier |
|-------|--------------------|-------------------------------|
| API | `<name>-api/` | Go module: `github.com/example/<name>-api` |
| Web | `<name>-web/` | npm `name`: `<name>-web` |

The Go module path can be overridden with `--module-path`. Output directory is the current working directory by default; use `--dir <path>` 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/<name>-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/<starter>/tar.gz/refs/tags/<ref>` 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) → `<name>-api`
- bare `starter-web` (in `package.json`, `src/lib/env.ts`, `instrumentation-client.ts`) → `<name>-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

Expand Down
12 changes: 12 additions & 0 deletions cmd/plinth/main.go
Original file line number Diff line number Diff line change
@@ -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))
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/plinth-dev/cli

go 1.25.0
158 changes: 158 additions & 0 deletions internal/cli/doctor.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading