diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f5beca4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go test -count=1 ./... + - run: go vet ./... + + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Race detector + run: go test -race -count=1 ./... + - name: Coverage report + run: | + go test -coverprofile=coverage.out ./... + go tool cover -func=coverage.out + go tool cover -func=coverage.out | awk '/^total:/ { gsub("%", "", $3); if ($3 + 0 < 80) { print "coverage below 80%: " $3 "%"; exit 1 } }' + - name: Installer syntax + run: sh -n install.sh + - name: Cross-compile supported targets + shell: bash + run: | + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do + os=${target%/*} + arch=${target#*/} + extension= + if [ "$os" = windows ]; then extension=.exe; fi + CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -trimpath -o "/tmp/remote-${os}-${arch}${extension}" ./cmd/remote + done + + windows-installer: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Parse PowerShell installer + shell: pwsh + run: | + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path "./install.ps1"), [ref]$tokens, [ref]$errors + ) > $null + if ($errors.Count) { + $errors | ForEach-Object { Write-Error $_ } + exit 1 + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..756fba4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,64 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: false + - name: Test + run: | + go test ./... + go vet ./... + sh -n install.sh + - name: Validate Windows installer + shell: pwsh + run: | + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path "./install.ps1"), + [ref]$tokens, + [ref]$errors + ) > $null + if ($errors.Count) { + $errors | ForEach-Object { Write-Error $_ } + exit 1 + } + - name: Build release binaries + run: | + mkdir -p dist + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do + os=${target%/*} + arch=${target#*/} + extension= + if [ "$os" = windows ]; then extension=.exe; fi + CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build \ + -trimpath \ + -ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" \ + -o "dist/remote-${os}-${arch}${extension}" \ + ./cmd/remote + done + cd dist + sha256sum remote-* > checksums.txt + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + dist/remote-* \ + dist/checksums.txt \ + --verify-tag \ + --generate-notes \ + --title "Remote CLI $GITHUB_REF_NAME" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..317d08a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/bin/ +/dist/ +/coverage.out diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..f1d7874 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,27 @@ +# Architecture + +Dependencies point inward through these layers: + +```text +cmd/remote -> internal/cli -> internal/application + | | + v v + internal/domain + internal/project + internal/bundle +``` + +- `cmd/remote` is the composition root and process exit boundary. +- `internal/cli` parses commands and renders user-facing output. +- `internal/application` coordinates scaffold, validate, and build use cases. +- `internal/domain` owns manifest types and pure validation policy. +- `internal/project` adapts application projects on the filesystem. +- `internal/bundle` owns source collection, infrastructure payloads, + reproducible archives, and atomic artifact writes. + +Domain code must not import CLI, filesystem, or archive packages. The CLI must +not implement domain or packaging policy. Infrastructure packages expose small +operations used by application workflows; they do not format CLI output. + +Put a new manifest rule in `domain`, an archive exclusion in `bundle`, command +syntax in `cli`, and the order of a multi-step use case in `application`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cc3e74c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# Contributing + +## Before opening a pull request + +Run the same core checks as CI: + +```sh +make test +make test-race +make coverage +go vet ./... +``` + +CI runs tests and vet on Linux, macOS, and Windows, validates both installers, +runs the race detector, cross-compiles every supported OS/architecture pair, +and rejects total statement coverage below 80%. + +## Testing changes + +- Put pure domain-rule tests in `internal/domain`. +- Put filesystem adapter tests in `internal/project`. +- Put archive and artifact tests in `internal/bundle`. +- Put use-case and end-to-end tests in `internal/application`. +- Put command parsing, output, aliases, and error mapping tests in `internal/cli`. +- Test success, invalid input, boundary failures, and exact observable output. +- Reproduce every bug with a failing regression test before fixing it. + +Keep commits focused. Structural refactors must preserve observable behavior and +should be separate from feature or bug-fix commits. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cbe6573 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Futrx + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e2d9e09 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: build test test-race coverage install + +VERSION ?= dev + +build: + go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/remote ./cmd/remote + +test: + go test ./... + +test-race: + go test -race ./... + +coverage: + go test -coverprofile=coverage.out ./... + go tool cover -func=coverage.out + +install: + go install -trimpath -ldflags "-s -w -X main.version=$(VERSION)" ./cmd/remote diff --git a/README.md b/README.md new file mode 100644 index 0000000..87e69c0 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# Remote CLI + +The developer CLI for creating and packaging Remote applications. + +See [ARCHITECTURE.md](ARCHITECTURE.md) for package boundaries and +[CONTRIBUTING.md](CONTRIBUTING.md) for local checks and testing expectations. + +## Install + +Install the latest release on Linux or macOS: + +```sh +curl -fsSL https://raw.githubusercontent.com/futrx-com/remote.futrx-cli/main/install.sh | sh +``` + +The installer supports AMD64 and ARM64, verifies the release checksum, and +places `remote` in `~/.local/bin` by default. Install a specific release or +choose another directory with environment variables on the `sh` command: + +```sh +curl -fsSL https://raw.githubusercontent.com/futrx-com/remote.futrx-cli/main/install.sh \ + | REMOTE_VERSION=v0.1.0 REMOTE_INSTALL_DIR="$HOME/bin" sh +``` + +On Windows, run this in PowerShell: + +```powershell +irm https://raw.githubusercontent.com/futrx-com/remote.futrx-cli/main/install.ps1 | iex +``` + +The Windows installer supports AMD64 and ARM64, verifies the release checksum, +installs `remote.exe` under `%LOCALAPPDATA%\Programs\Remote` by default, and +adds that directory to the user `PATH`. `REMOTE_VERSION` and +`REMOTE_INSTALL_DIR` provide the same overrides as on Linux and macOS. + +Platform detection, download, and checksum verification happen once when the +installer runs. The installed CLI does not contact GitHub or repeat installation +checks when you run a command. + +## Install from source + +```sh +go install github.com/futrx-com/remote.futrx-cli/cmd/remote@latest +``` + +During local development: + +```sh +go build -o ./bin/remote ./cmd/remote +``` + +## Create an application + +```sh +remote create app my-app +cd my-app +remote validate +remote build +``` + +`create` produces a complete composable application with `ui/`, `backend/`, +`infra/`, `skills/`, `application.json`, documentation, and an MIT license. +Delete capabilities the application does not need and update the manifest. + +`build` validates the package and writes `-.zip` beside the app. +The ZIP is reproducible. Infrastructure support files are turned into +`infra/payload.tar.gz` in memory, so an app does not need to ship or run a +`package.sh` script. Legacy `infra/package.sh` and generated payloads are +excluded from the package automatically. + +## Commands + +```text +remote create app [--dir PATH] +remote app create [--dir PATH] +remote validate [PATH] +remote build [PATH] [-o FILE] +remote package [PATH] [-o FILE] # alias for build +remote version +``` diff --git a/cmd/remote/main.go b/cmd/remote/main.go new file mode 100644 index 0000000..2564d98 --- /dev/null +++ b/cmd/remote/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + "os" + + "github.com/futrx-com/remote.futrx-cli/internal/cli" +) + +var version = "dev" + +func main() { + if err := cli.Run(os.Args[1:], os.Stdout, os.Stderr, version); err != nil { + fmt.Fprintln(os.Stderr, "remote:", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..76d9ca3 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/futrx-com/remote.futrx-cli + +go 1.22 diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..f04ac05 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,79 @@ +$ErrorActionPreference = "Stop" + +$Repository = "futrx-com/remote.futrx-cli" +$Version = if ($env:REMOTE_VERSION) { $env:REMOTE_VERSION } else { "latest" } +$ReleaseBaseUrl = if ($env:REMOTE_RELEASE_BASE_URL) { + $env:REMOTE_RELEASE_BASE_URL.TrimEnd("/") +} else { + "https://github.com/$Repository/releases" +} + +function Fail([string]$Message) { + throw "remote installer: $Message" +} + +if ($Version -ne "latest" -and $Version -notmatch '^[A-Za-z0-9._-]+$') { + Fail "invalid version: $Version" +} + +$Architecture = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) { + "X64" { "amd64" } + "Arm64" { "arm64" } + default { Fail "unsupported architecture: $_" } +} + +$DownloadRoot = if ($Version -eq "latest") { + "$ReleaseBaseUrl/latest/download" +} else { + "$ReleaseBaseUrl/download/$Version" +} + +$InstallDir = if ($env:REMOTE_INSTALL_DIR) { + $env:REMOTE_INSTALL_DIR +} else { + Join-Path ([Environment]::GetFolderPath("LocalApplicationData")) "Programs\Remote" +} + +$Asset = "remote-windows-$Architecture.exe" +$TemporaryDir = Join-Path ([System.IO.Path]::GetTempPath()) ("remote-install-" + [guid]::NewGuid()) + +try { + New-Item -ItemType Directory -Path $TemporaryDir | Out-Null + $BinaryPath = Join-Path $TemporaryDir $Asset + $ChecksumsPath = Join-Path $TemporaryDir "checksums.txt" + + Invoke-WebRequest -UseBasicParsing -Uri "$DownloadRoot/$Asset" -OutFile $BinaryPath + Invoke-WebRequest -UseBasicParsing -Uri "$DownloadRoot/checksums.txt" -OutFile $ChecksumsPath + + $ChecksumLine = Get-Content $ChecksumsPath | Where-Object { + $_ -match "^([0-9A-Fa-f]{64})\s+\*?$([regex]::Escape($Asset))$" + } | Select-Object -First 1 + if (-not $ChecksumLine) { + Fail "checksums.txt has no valid checksum for $Asset" + } + + $ExpectedChecksum = ($ChecksumLine -split '\s+')[0] + $ActualChecksum = (Get-FileHash -Algorithm SHA256 $BinaryPath).Hash + if ($ActualChecksum -ne $ExpectedChecksum) { + Fail "checksum verification failed for $Asset" + } + + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + $Destination = Join-Path $InstallDir "remote.exe" + Copy-Item -Force $BinaryPath $Destination + + $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") + $PathEntries = @($UserPath -split ';' | Where-Object { $_ }) + if ($PathEntries -notcontains $InstallDir) { + $NewUserPath = (@($PathEntries) + $InstallDir) -join ';' + [Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User") + $env:Path = "$env:Path;$InstallDir" + Write-Host "Added $InstallDir to your user PATH. Open a new terminal to use it." + } + + Write-Host "Installed remote to $Destination" +} finally { + if (Test-Path $TemporaryDir) { + Remove-Item -Recurse -Force $TemporaryDir + } +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..e27a757 --- /dev/null +++ b/install.sh @@ -0,0 +1,96 @@ +#!/bin/sh + +set -eu + +repository="futrx-com/remote.futrx-cli" +version=${REMOTE_VERSION:-latest} +release_base_url=${REMOTE_RELEASE_BASE_URL:-"https://github.com/${repository}/releases"} + +fail() { + printf 'remote installer: %s\n' "$1" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "$1 is required" +} + +download() { + url=$1 + destination=$2 + case "$url" in + https://*) + curl --proto '=https' --tlsv1.2 -fsSL --retry 3 --output "$destination" "$url" + ;; + *) + curl -fsSL --retry 3 --output "$destination" "$url" + ;; + esac +} + +require_command curl +require_command install + +case "$(uname -s)" in +Linux) os=linux ;; +Darwin) os=darwin ;; +*) fail "unsupported operating system: $(uname -s)" ;; +esac + +case "$(uname -m)" in +x86_64 | amd64) arch=amd64 ;; +aarch64 | arm64) arch=arm64 ;; +*) fail "unsupported architecture: $(uname -m)" ;; +esac + +case "$version" in +latest) + download_root="${release_base_url}/latest/download" + ;; +'' | *[!A-Za-z0-9._-]*) + fail "invalid version: $version" + ;; +*) + download_root="${release_base_url}/download/${version}" + ;; +esac + +if [ -n "${REMOTE_INSTALL_DIR:-}" ]; then + install_dir=$REMOTE_INSTALL_DIR +else + [ -n "${HOME:-}" ] || fail "HOME or REMOTE_INSTALL_DIR is required" + install_dir="${HOME}/.local/bin" +fi + +asset="remote-${os}-${arch}" +temporary_dir=$(mktemp -d) +trap 'rm -rf "$temporary_dir"' 0 +trap 'exit 1' HUP INT TERM + +download "${download_root}/${asset}" "${temporary_dir}/${asset}" +download "${download_root}/checksums.txt" "${temporary_dir}/checksums.txt" + +expected_checksum=$(awk -v asset="$asset" '$2 == asset || $2 == "*" asset { print $1; exit }' "${temporary_dir}/checksums.txt") +case "$expected_checksum" in +'' | *[!0-9a-fA-F]*) fail "checksums.txt has no valid checksum for ${asset}" ;; +esac +[ "${#expected_checksum}" -eq 64 ] || fail "checksums.txt has no valid checksum for ${asset}" + +if command -v sha256sum >/dev/null 2>&1; then + actual_checksum=$(sha256sum "${temporary_dir}/${asset}" | awk '{ print $1 }') +elif command -v shasum >/dev/null 2>&1; then + actual_checksum=$(shasum -a 256 "${temporary_dir}/${asset}" | awk '{ print $1 }') +else + fail "sha256sum or shasum is required" +fi + +[ "$actual_checksum" = "$expected_checksum" ] || fail "checksum verification failed for ${asset}" + +mkdir -p "$install_dir" +install -m 0755 "${temporary_dir}/${asset}" "${install_dir}/remote" + +printf 'Installed remote to %s\n' "${install_dir}/remote" +case ":${PATH}:" in +*":${install_dir}:"*) ;; +*) printf 'Add %s to PATH to run remote from any directory.\n' "$install_dir" ;; +esac diff --git a/internal/application/application_test.go b/internal/application/application_test.go new file mode 100644 index 0000000..db3eca4 --- /dev/null +++ b/internal/application/application_test.go @@ -0,0 +1,78 @@ +package application + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestScaffoldValidateAndBuildReproducibly(t *testing.T) { + parent := t.TempDir() + dir, err := Scaffold(parent, "Demo App") + if err != nil { + t.Fatal(err) + } + manifest, err := Validate(dir) + if err != nil { + t.Fatal(err) + } + if manifest.ID != "demo-app" { + t.Fatalf("id = %q", manifest.ID) + } + if err := os.WriteFile(filepath.Join(dir, "infra", "package.sh"), []byte("#!/bin/sh\nexit 99\n"), 0755); err != nil { + t.Fatal(err) + } + + one := filepath.Join(parent, "one.zip") + two := filepath.Join(parent, "two.zip") + first, err := Build(dir, one) + if err != nil { + t.Fatal(err) + } + second, err := Build(dir, two) + if err != nil { + t.Fatal(err) + } + if first.SHA256 != second.SHA256 { + t.Fatalf("build is not reproducible: %s != %s", first.SHA256, second.SHA256) + } + + raw, err := os.ReadFile(one) + if err != nil { + t.Fatal(err) + } + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatal(err) + } + want := map[string]bool{"application.json": false, "infra/install.sh": false, "infra/payload.tar.gz": false, "backend/main.go": false, "ui/scripts/main.js": false, "skills/demo-app/SKILL.md": false} + for _, file := range zr.File { + if _, ok := want[file.Name]; ok { + want[file.Name] = true + } + if file.Name == ".gitignore" { + t.Error("development .gitignore was packaged") + } + if file.Name == "infra/package.sh" { + t.Error("legacy package.sh was packaged") + } + } + for name, found := range want { + if !found { + t.Errorf("archive missing %s", name) + } + } +} + +func TestValidateRejectsUnsafeInstallPath(t *testing.T) { + dir := t.TempDir() + raw := `{"id":"bad","name":"Bad","version":"1","scopes":["project"],"install":"../install.sh"}` + if err := os.WriteFile(filepath.Join(dir, "application.json"), []byte(raw), 0644); err != nil { + t.Fatal(err) + } + if _, err := Validate(dir); err == nil { + t.Fatal("Validate accepted install path outside infra") + } +} diff --git a/internal/application/build.go b/internal/application/build.go new file mode 100644 index 0000000..6b33202 --- /dev/null +++ b/internal/application/build.go @@ -0,0 +1,79 @@ +package application + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/futrx-com/remote.futrx-cli/internal/bundle" +) + +type BuildResult struct { + Path string + ID string + Version string + SHA256 string + Files int +} + +func Build(dir, output string) (BuildResult, error) { + manifest, err := Validate(dir) + if err != nil { + return BuildResult{}, err + } + dir, err = filepath.Abs(dir) + if err != nil { + return BuildResult{}, err + } + if output == "" { + output = filepath.Join(filepath.Dir(dir), fmt.Sprintf("%s-%s.zip", manifest.ID, safeVersion(manifest.Version))) + } + output, err = filepath.Abs(output) + if err != nil { + return BuildResult{}, err + } + if strings.HasPrefix(output+string(os.PathSeparator), dir+string(os.PathSeparator)) { + return BuildResult{}, fmt.Errorf("output must be outside the application directory") + } + files, err := bundle.Collect(dir) + if err != nil { + return BuildResult{}, err + } + payload, hasInfra, err := bundle.InfraPayload(files) + if err != nil { + return BuildResult{}, err + } + if hasInfra { + files = bundle.Replace(files, "infra/payload.tar.gz", payload) + } + archive, err := bundle.Archive(files) + if err != nil { + return BuildResult{}, err + } + if err := bundle.Write(output, archive); err != nil { + return BuildResult{}, err + } + sum := sha256.Sum256(archive) + return BuildResult{ + Path: output, + ID: manifest.ID, + Version: manifest.Version, + SHA256: hex.EncodeToString(sum[:]), + Files: len(files), + }, nil +} + +func safeVersion(v string) string { + var b strings.Builder + for _, r := range v { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("._-", r) { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + return strings.Trim(b.String(), "-") +} diff --git a/internal/application/build_test.go b/internal/application/build_test.go new file mode 100644 index 0000000..12d9f49 --- /dev/null +++ b/internal/application/build_test.go @@ -0,0 +1,41 @@ +package application + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestSafeVersion(t *testing.T) { + for input, want := range map[string]string{"1.2.3": "1.2.3", " release 1 / beta ": "release-1---beta", "---": ""} { + if got := safeVersion(input); got != want { + t.Errorf("safeVersion(%q) = %q, want %q", input, got, want) + } + } +} + +func TestBuildDefaultOutputAndResult(t *testing.T) { + parent := t.TempDir() + dir, err := Scaffold(parent, "demo") + if err != nil { + t.Fatal(err) + } + result, err := Build(dir, "") + if err != nil { + t.Fatal(err) + } + if result.Path != filepath.Join(parent, "demo-0.1.0.zip") || result.ID != "demo" || result.Version != "0.1.0" || len(result.SHA256) != 64 || result.Files == 0 { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestBuildRejectsOutputInsideApplication(t *testing.T) { + dir, err := Scaffold(t.TempDir(), "demo") + if err != nil { + t.Fatal(err) + } + _, err = Build(dir, filepath.Join(dir, "dist", "demo.zip")) + if err == nil || !strings.Contains(err.Error(), "outside the application") { + t.Fatalf("error = %v", err) + } +} diff --git a/internal/application/manifest.go b/internal/application/manifest.go new file mode 100644 index 0000000..0ca1c25 --- /dev/null +++ b/internal/application/manifest.go @@ -0,0 +1,8 @@ +package application + +import "github.com/futrx-com/remote.futrx-cli/internal/domain" + +type Manifest = domain.Manifest +type Port = domain.Port +type Env = domain.Env +type Healthcheck = domain.Healthcheck diff --git a/internal/application/manifest_validation_test.go b/internal/application/manifest_validation_test.go new file mode 100644 index 0000000..715b68b --- /dev/null +++ b/internal/application/manifest_validation_test.go @@ -0,0 +1,22 @@ +package application + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/futrx-com/remote.futrx-cli/internal/project" +) + +func TestReadManifestRejectsMalformedAndUnknownFields(t *testing.T) { + for _, raw := range []string{`{"id":`, `{"id":"demo","unknown":true}`} { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "application.json"), []byte(raw), 0644); err != nil { + t.Fatal(err) + } + if _, err := project.ReadManifest(dir); err == nil || !strings.Contains(err.Error(), "parse application.json") { + t.Fatalf("ReadManifest(%q) error = %v", raw, err) + } + } +} diff --git a/internal/application/scaffold.go b/internal/application/scaffold.go new file mode 100644 index 0000000..7f8cb37 --- /dev/null +++ b/internal/application/scaffold.go @@ -0,0 +1,61 @@ +package application + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/futrx-com/remote.futrx-cli/internal/domain" +) + +func Scaffold(parent, name string) (string, error) { + id := slug(name) + if !domain.ApplicationID.MatchString(id) { + return "", fmt.Errorf("%q does not produce a valid application id", name) + } + dir := filepath.Join(parent, id) + if _, err := os.Stat(dir); err == nil { + return "", fmt.Errorf("%s already exists", dir) + } else if !os.IsNotExist(err) { + return "", err + } + for rel, body := range scaffoldFiles(id, displayName(name)) { + path := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return "", err + } + mode := os.FileMode(0644) + if rel == "infra/install.sh" { + mode = 0755 + } + if err := os.WriteFile(path, []byte(body), mode); err != nil { + return "", err + } + } + abs, _ := filepath.Abs(dir) + return abs, nil +} + +func slug(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + dash := false + for _, r := range s { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + b.WriteRune(r) + dash = false + } else if b.Len() > 0 && !dash { + b.WriteByte('-') + dash = true + } + } + return strings.Trim(b.String(), "-") +} +func displayName(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/internal/application/scaffold_test.go b/internal/application/scaffold_test.go new file mode 100644 index 0000000..2f5b9f7 --- /dev/null +++ b/internal/application/scaffold_test.go @@ -0,0 +1,49 @@ +package application + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSlug(t *testing.T) { + for input, want := range map[string]string{" Demo App ": "demo-app", "a---b": "a-b", "Hello_world!": "hello-world", "---": ""} { + if got := slug(input); got != want { + t.Errorf("slug(%q) = %q, want %q", input, got, want) + } + } +} + +func TestScaffoldCreatesExpectedFilesAndModes(t *testing.T) { + dir, err := Scaffold(t.TempDir(), "demo app") + if err != nil { + t.Fatal(err) + } + for name := range scaffoldFiles("demo-app", "Demo app") { + info, err := os.Stat(filepath.Join(dir, filepath.FromSlash(name))) + if err != nil { + t.Errorf("%s: %v", name, err) + continue + } + want := os.FileMode(0644) + if name == "infra/install.sh" { + want = 0755 + } + if info.Mode().Perm() != want { + t.Errorf("%s mode = %o, want %o", name, info.Mode().Perm(), want) + } + } +} + +func TestScaffoldRejectsInvalidOrExistingDestination(t *testing.T) { + parent := t.TempDir() + if _, err := Scaffold(parent, "---"); err == nil { + t.Fatal("invalid name accepted") + } + if _, err := Scaffold(parent, "demo"); err != nil { + t.Fatal(err) + } + if _, err := Scaffold(parent, "demo"); err == nil { + t.Fatal("existing destination accepted") + } +} diff --git a/internal/application/templates.go b/internal/application/templates.go new file mode 100644 index 0000000..32442c0 --- /dev/null +++ b/internal/application/templates.go @@ -0,0 +1,103 @@ +package application + +import "fmt" + +func scaffoldFiles(id, name string) map[string]string { + return map[string]string{ + "application.json": manifestTemplate(id, name), + "README.md": "# " + name + "\n\nA Remote application.\n\n## Development\n\n```sh\nremote validate\nremote build\n```\n", + "infra/LICENSE": mitLicense, + "infra/install.sh": installTemplate(id), + "infra/README.md": "# Infrastructure\n\n`install.sh` runs as root in the target container and must be idempotent. Every other file in this directory is bundled into `APP_PACKAGE_DIR` by `remote build`.\n", + "backend/main.go": backendTemplate(id), + "ui/scripts/main.js": uiTemplate, + "ui/style/style.css": ".remote-app-message { color: var(--text-secondary, inherit); }\n", + "ui/views/panel.html": "

" + name + " is running.

\n", + "skills/" + id + "/SKILL.md": "# " + name + "\n\nUse this application when the installed capability is needed.\n", + ".gitignore": "dist/\n*.zip\ninfra/payload.tar.gz\n", + } +} + +func manifestTemplate(id, name string) string { + return fmt.Sprintf(`{ + "id": %q, + "name": %q, + "description": "Describe what this application provides.", + "category": "development", + "version": "0.1.0", + "icon": "layers", + "scopes": ["project"], + "env": [], + "service": %q, + "install": "infra/install.sh", + "backend": { "access": "registered", "timeoutMs": 10000 }, + "ui": { + "entry": "scripts/main.js", + "styles": ["style/style.css"], + "views": { "panel": "views/panel.html" } + } +} +`, id, name, id) +} + +func installTemplate(id string) string { + return fmt.Sprintf(`#!/usr/bin/env bash +set -euo pipefail + +# This script runs as root and may run repeatedly. Keep it idempotent. +# Auxiliary infra files are available under $APP_PACKAGE_DIR/infra. +install -d -m 0755 /etc/%s + +echo "install: %s is ready" +`, id, id) +} + +func backendTemplate(id string) string { + return fmt.Sprintf(`package main + +import ( + "github.com/futrx-com/remote.futrx.com/pkg/appplugin" + "github.com/futrx-com/remote.futrx.com/pkg/appplugin/pluginrpc" +) + +type backend struct { mux *appplugin.Mux } + +func main() { pluginrpc.Serve(newBackend()) } +func newBackend() *backend { return &backend{mux: appplugin.NewMux()} } +func (b *backend) Describe() (appplugin.Descriptor, error) { + return appplugin.Descriptor{Name: %q, Version: "1", APIVersion: appplugin.APIVersion, Routes: b.mux.Routes()}, nil +} +func (b *backend) Init(appplugin.Instance) error { return nil } +func (b *backend) Handle(r appplugin.Request) (appplugin.Response, error) { return b.mux.Serve(r), nil } +`, id) +} + +const uiTemplate = `export default function activate(remote) { + remote.ui.register(remote.slots.applicationsPanel, async (host) => { + host.innerHTML = await remote.views.load("panel"); + }); +} +` + +const mitLicense = `MIT License + +Copyright (c) 2026 Application author + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +` diff --git a/internal/application/validate.go b/internal/application/validate.go new file mode 100644 index 0000000..0b6ef59 --- /dev/null +++ b/internal/application/validate.go @@ -0,0 +1,37 @@ +package application + +import ( + "fmt" + "path/filepath" + + "github.com/futrx-com/remote.futrx-cli/internal/domain" + "github.com/futrx-com/remote.futrx-cli/internal/project" +) + +func Validate(dir string) (Manifest, error) { + dir, err := filepath.Abs(dir) + if err != nil { + return Manifest{}, err + } + manifest, err := project.ReadManifest(dir) + if err != nil { + return manifest, err + } + if err := domain.ValidateMetadata(manifest); err != nil { + return manifest, err + } + install, err := project.ResolveInstall(dir, manifest) + if err != nil { + return manifest, err + } + if err := domain.ValidateRuntime(manifest); err != nil { + return manifest, err + } + if !project.HasCapability(dir, install) { + return manifest, fmt.Errorf("application has no infra, UI, backend, or skills capability") + } + if err := project.ValidateTree(dir); err != nil { + return manifest, err + } + return manifest, nil +} diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go new file mode 100644 index 0000000..efe9982 --- /dev/null +++ b/internal/bundle/bundle.go @@ -0,0 +1,146 @@ +package bundle + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +type File struct { + Name string + Data []byte +} + +var archiveEpoch = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + +func Collect(root string) ([]File, error) { + var files []File + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative, _ := filepath.Rel(root, path) + relative = filepath.ToSlash(relative) + if entry.IsDir() { + if relative == ".git" || relative == "dist" { + return filepath.SkipDir + } + return nil + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("symlinks are not supported: %s", relative) + } + if !entry.Type().IsRegular() { + return fmt.Errorf("special files are not supported: %s", relative) + } + if relative == ".gitignore" || relative == "infra/package.sh" || relative == "infra/payload.tar.gz" || strings.HasSuffix(relative, ".zip") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + files = append(files, File{Name: relative, Data: data}) + return nil + }) + return files, err +} + +func InfraPayload(files []File) ([]byte, bool, error) { + var infra []File + for _, file := range files { + if strings.HasPrefix(file.Name, "infra/") && file.Name != "infra/install.sh" && file.Name != "infra/payload.tar.gz" { + infra = append(infra, file) + } + } + if len(infra) == 0 { + return nil, false, nil + } + sort.Slice(infra, func(i, j int) bool { return infra[i].Name < infra[j].Name }) + var payload bytes.Buffer + gzipWriter, _ := gzip.NewWriterLevel(&payload, gzip.BestCompression) + gzipWriter.Header.ModTime = time.Unix(0, 0) + gzipWriter.Header.OS = 255 + tarWriter := tar.NewWriter(gzipWriter) + for _, file := range infra { + header := &tar.Header{Name: file.Name, Mode: 0644, Size: int64(len(file.Data)), ModTime: time.Unix(0, 0), Uid: 0, Gid: 0, Format: tar.FormatUSTAR} + if err := tarWriter.WriteHeader(header); err != nil { + return nil, false, err + } + if _, err := tarWriter.Write(file.Data); err != nil { + return nil, false, err + } + } + if err := tarWriter.Close(); err != nil { + return nil, false, err + } + if err := gzipWriter.Close(); err != nil { + return nil, false, err + } + if payload.Len() > 8<<20 { + return nil, false, fmt.Errorf("compressed infra payload exceeds 8 MiB") + } + return payload.Bytes(), true, nil +} + +func Replace(files []File, name string, data []byte) []File { + filtered := files[:0] + for _, file := range files { + if file.Name != name { + filtered = append(filtered, file) + } + } + return append(filtered, File{Name: name, Data: data}) +} + +func Archive(files []File) ([]byte, error) { + sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name }) + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + for _, file := range files { + header := &zip.FileHeader{Name: file.Name, Method: zip.Deflate} + header.SetModTime(archiveEpoch) + header.SetMode(0644) + entry, err := writer.CreateHeader(header) + if err != nil { + return nil, err + } + if _, err := entry.Write(file.Data); err != nil { + return nil, err + } + } + if err := writer.Close(); err != nil { + return nil, err + } + return archive.Bytes(), nil +} + +func Write(output string, archive []byte) error { + if err := os.MkdirAll(filepath.Dir(output), 0755); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(output), ".remote-build-*.zip") + if err != nil { + return err + } + name := temporary.Name() + defer os.Remove(name) + if _, err = temporary.Write(archive); err == nil { + err = temporary.Sync() + } + if closeErr := temporary.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + return os.Rename(name, output) +} diff --git a/internal/bundle/bundle_test.go b/internal/bundle/bundle_test.go new file mode 100644 index 0000000..bac3fe6 --- /dev/null +++ b/internal/bundle/bundle_test.go @@ -0,0 +1,115 @@ +package bundle + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestArchiveIsSortedAndNormalized(t *testing.T) { + raw, err := Archive([]File{{Name: "z", Data: []byte("z")}, {Name: "a", Data: []byte("a")}}) + if err != nil { + t.Fatal(err) + } + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatal(err) + } + if zr.File[0].Name != "a" || zr.File[1].Name != "z" { + t.Fatalf("order = %s, %s", zr.File[0].Name, zr.File[1].Name) + } + for _, file := range zr.File { + if !file.Modified.Equal(time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)) || file.Mode().Perm() != 0644 { + t.Errorf("metadata for %s not normalized", file.Name) + } + } +} + +func TestInfraPayloadSelectionAndContents(t *testing.T) { + files := []File{{Name: "infra/install.sh", Data: []byte("install")}, {Name: "infra/config", Data: []byte("config")}, {Name: "backend/main.go", Data: []byte("go")}} + raw, ok, err := InfraPayload(files) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + gz, err := gzip.NewReader(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + reader := tar.NewReader(gz) + header, err := reader.Next() + if err != nil { + t.Fatal(err) + } + if header.Name != "infra/config" { + t.Fatalf("entry=%q", header.Name) + } + if _, err := reader.Next(); err == nil { + t.Fatal("unexpected second entry") + } + if _, ok, err := InfraPayload([]File{{Name: "infra/install.sh"}}); err != nil || ok { + t.Fatalf("empty payload ok=%v err=%v", ok, err) + } +} + +func TestReplace(t *testing.T) { + files := Replace([]File{{Name: "keep", Data: []byte("old")}, {Name: "replace", Data: []byte("old")}}, "replace", []byte("new")) + if len(files) != 2 || files[0].Name != "keep" || files[1].Name != "replace" || string(files[1].Data) != "new" { + t.Fatalf("files=%+v", files) + } +} + +func TestCollectExcludesDevelopmentArtifacts(t *testing.T) { + root := t.TempDir() + for name, body := range map[string]string{"keep.txt": "yes", ".git/config": "no", "dist/out": "no", ".gitignore": "no", "old.zip": "no", "infra/package.sh": "no", "infra/payload.tar.gz": "no"} { + path := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatal(err) + } + } + files, err := Collect(root) + if err != nil { + t.Fatal(err) + } + if len(files) != 1 || files[0].Name != "keep.txt" { + t.Fatalf("files=%+v", files) + } +} + +func TestCollectRejectsSymlink(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "target"), nil, 0644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("target", filepath.Join(root, "link")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := Collect(root); err == nil || !strings.Contains(err.Error(), "symlinks") { + t.Fatalf("error=%v", err) + } +} + +func TestWriteCreatesParentsAndReplacesArtifact(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "app.zip") + if err := Write(path, []byte("first")); err != nil { + t.Fatal(err) + } + if err := Write(path, []byte("second")); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(raw) != "second" { + t.Fatalf("content=%q", raw) + } +} diff --git a/internal/cli/build.go b/internal/cli/build.go new file mode 100644 index 0000000..187b9de --- /dev/null +++ b/internal/cli/build.go @@ -0,0 +1,33 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + + "github.com/futrx-com/remote.futrx-cli/internal/application" +) + +func runBuild(args []string, stdout, stderr io.Writer) error { + fs := flag.NewFlagSet("build", flag.ContinueOnError) + fs.SetOutput(stderr) + out := fs.String("o", "", "output ZIP path") + fs.StringVar(out, "output", "", "output ZIP path") + if err := fs.Parse(normalizeValueFlags(args, map[string]bool{"-o": true, "--output": true})); err != nil { + return err + } + if fs.NArg() > 1 { + return errors.New("usage: remote build [PATH] [-o FILE]") + } + dir := "." + if fs.NArg() == 1 { + dir = fs.Arg(0) + } + result, err := application.Build(dir, *out) + if err != nil { + return err + } + fmt.Fprintf(stdout, "Built %s\n application: %s@%s\n sha256: %s\n files: %d\n", result.Path, result.ID, result.Version, result.SHA256, result.Files) + return nil +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..c833566 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,44 @@ +package cli + +import ( + "fmt" + "io" +) + +const usage = `Remote application developer CLI + +Usage: + remote create app [--dir PATH] + remote app create [--dir PATH] + remote validate [PATH] + remote build [PATH] [-o FILE] + remote version + +Create scaffolds UI, backend, infrastructure, a skill, documentation, license, +and application.json. Build validates the source and writes a reproducible ZIP; +it also generates the infrastructure payload in-memory, replacing package.sh.` + +func Run(args []string, stdout, stderr io.Writer, version string) error { + if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" { + fmt.Fprintln(stdout, usage) + return nil + } + if args[0] == "version" || args[0] == "--version" { + fmt.Fprintln(stdout, version) + return nil + } + if len(args) >= 2 && args[0] == "create" && args[1] == "app" { + return runCreate(args[2:], stdout, stderr) + } + if len(args) >= 2 && args[0] == "app" && args[1] == "create" { + return runCreate(args[2:], stdout, stderr) + } + switch args[0] { + case "validate": + return runValidate(args[1:], stdout, stderr) + case "build", "package": + return runBuild(args[1:], stdout, stderr) + default: + return fmt.Errorf("unknown command %q\n\n%s", args[0], usage) + } +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..3592299 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,83 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/futrx-com/remote.futrx-cli/internal/application" +) + +func TestCreateSyntaxes(t *testing.T) { + for _, args := range [][]string{{"create", "app", "first", "--dir", t.TempDir()}, {"app", "create", "second", "--dir", t.TempDir()}} { + var out, stderr bytes.Buffer + if err := Run(args, &out, &stderr, "test"); err != nil { + t.Fatalf("Run(%v): %v (%s)", args, err, stderr.String()) + } + if !strings.Contains(out.String(), "Created") { + t.Fatalf("output = %q", out.String()) + } + } +} + +func TestInformationalCommands(t *testing.T) { + for _, tt := range []struct { + name string + args []string + want string + }{ + {name: "no arguments", args: nil, want: usage + "\n"}, + {name: "help command", args: []string{"help"}, want: usage + "\n"}, + {name: "long help flag", args: []string{"--help"}, want: usage + "\n"}, + {name: "short help flag", args: []string{"-h"}, want: usage + "\n"}, + {name: "version command", args: []string{"version"}, want: "test-version\n"}, + {name: "version flag", args: []string{"--version"}, want: "test-version\n"}, + } { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + if err := Run(tt.args, &stdout, &stderr, "test-version"); err != nil { + t.Fatalf("Run(%v): %v", tt.args, err) + } + if stdout.String() != tt.want { + t.Fatalf("stdout = %q, want %q", stdout.String(), tt.want) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + }) + } +} + +func TestUnknownCommand(t *testing.T) { + var stdout, stderr bytes.Buffer + err := Run([]string{"unknown"}, &stdout, &stderr, "test") + want := "unknown command \"unknown\"\n\n" + usage + if err == nil || err.Error() != want { + t.Fatalf("error = %v, want %q", err, want) + } + if stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("stdout = %q, stderr = %q; want both empty", stdout.String(), stderr.String()) + } +} + +func TestBuildDefaultsToCurrentDirectory(t *testing.T) { + parent := t.TempDir() + dir, err := application.Scaffold(parent, "sample") + if err != nil { + t.Fatal(err) + } + old, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(old) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + var out, stderr bytes.Buffer + if err := Run([]string{"build", ".", "-o", filepath.Join(parent, "sample.zip")}, &out, &stderr, "test"); err != nil { + t.Fatalf("build: %v (%s)", err, stderr.String()) + } +} diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go new file mode 100644 index 0000000..6d2b2ae --- /dev/null +++ b/internal/cli/commands_test.go @@ -0,0 +1,126 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/futrx-com/remote.futrx-cli/internal/application" +) + +func runCLI(t *testing.T, args ...string) (string, string, error) { + t.Helper() + var stdout, stderr bytes.Buffer + err := Run(args, &stdout, &stderr, "test-version") + return stdout.String(), stderr.String(), err +} + +func TestCreateCommandOutputAndFlagPositions(t *testing.T) { + for _, tt := range []struct { + name string + args func(string) []string + }{ + {"canonical flags first", func(parent string) []string { return []string{"create", "app", "--dir", parent, "demo"} }}, + {"canonical flags last", func(parent string) []string { return []string{"create", "app", "demo", "--dir", parent} }}, + {"alias", func(parent string) []string { return []string{"app", "create", "demo", "--dir=" + parent} }}, + } { + t.Run(tt.name, func(t *testing.T) { + parent := t.TempDir() + stdout, stderr, err := runCLI(t, tt.args(parent)...) + if err != nil { + t.Fatalf("error = %v, stderr = %q", err, stderr) + } + dir := filepath.Join(parent, "demo") + want := "Created " + dir + "\n\nNext:\n cd " + dir + "\n remote validate\n remote build\n" + if stdout != want || stderr != "" { + t.Fatalf("stdout = %q, stderr = %q", stdout, stderr) + } + }) + } +} + +func TestValidateCommandSuccessAndFailures(t *testing.T) { + dir, err := application.Scaffold(t.TempDir(), "demo") + if err != nil { + t.Fatal(err) + } + stdout, stderr, err := runCLI(t, "validate", dir) + if err != nil || stdout != "Valid Remote application Demo (0.1.0)\n" || stderr != "" { + t.Fatalf("stdout=%q stderr=%q err=%v", stdout, stderr, err) + } + for _, args := range [][]string{{"validate", "one", "two"}, {"validate", filepath.Join(dir, "missing")}} { + stdout, _, err = runCLI(t, args...) + if err == nil || stdout != "" { + t.Fatalf("Run(%v): stdout=%q err=%v", args, stdout, err) + } + } +} + +func TestBuildCommandAndPackageAlias(t *testing.T) { + for _, command := range []string{"build", "package"} { + t.Run(command, func(t *testing.T) { + parent := t.TempDir() + dir, err := application.Scaffold(parent, "demo") + if err != nil { + t.Fatal(err) + } + output := filepath.Join(parent, command+".zip") + stdout, stderr, err := runCLI(t, command, dir, "--output="+output) + if err != nil || stderr != "" { + t.Fatalf("stdout=%q stderr=%q err=%v", stdout, stderr, err) + } + for _, fragment := range []string{"Built " + output, "application: demo@0.1.0", "sha256:", "files:"} { + if !strings.Contains(stdout, fragment) { + t.Errorf("stdout %q missing %q", stdout, fragment) + } + } + if _, err := os.Stat(output); err != nil { + t.Fatalf("artifact: %v", err) + } + }) + } +} + +func TestCommandUsageErrors(t *testing.T) { + for _, tt := range []struct { + args []string + want string + }{ + {[]string{"create", "app"}, "usage: remote create app [--dir PATH]"}, + {[]string{"create", "app", "one", "two"}, "usage: remote create app [--dir PATH]"}, + {[]string{"validate", "one", "two"}, "usage: remote validate [PATH]"}, + {[]string{"build", "one", "two"}, "usage: remote build [PATH] [-o FILE]"}, + } { + stdout, _, err := runCLI(t, tt.args...) + if err == nil || err.Error() != tt.want { + t.Fatalf("Run(%v): error = %v", tt.args, err) + } + if stdout != "" { + t.Fatalf("Run(%v): stdout = %q", tt.args, stdout) + } + } +} + +func TestFlagParsingErrorsUseStderr(t *testing.T) { + stdout, stderr, err := runCLI(t, "build", "--unknown") + if err == nil || stdout != "" || !strings.Contains(stderr, "flag provided but not defined") { + t.Fatalf("stdout=%q stderr=%q err=%v", stdout, stderr, err) + } +} + +func TestNormalizeValueFlags(t *testing.T) { + got := normalizeValueFlags([]string{"app", "--output", "one.zip", "--other", "--output=two.zip"}, map[string]bool{"--output": true}) + want := []string{"--output", "one.zip", "--output=two.zip", "app", "--other"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestShellPathQuotesWhitespace(t *testing.T) { + got := shellPath(filepath.Join(t.TempDir(), "space here")) + if !strings.HasPrefix(got, `"`) || !strings.HasSuffix(got, `"`) { + t.Fatalf("shellPath = %q", got) + } +} diff --git a/internal/cli/create.go b/internal/cli/create.go new file mode 100644 index 0000000..7dc947e --- /dev/null +++ b/internal/cli/create.go @@ -0,0 +1,40 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/futrx-com/remote.futrx-cli/internal/application" +) + +func runCreate(args []string, stdout, stderr io.Writer) error { + fs := flag.NewFlagSet("create app", flag.ContinueOnError) + fs.SetOutput(stderr) + parent := fs.String("dir", ".", "parent directory") + if err := fs.Parse(normalizeValueFlags(args, map[string]bool{"--dir": true})); err != nil { + return err + } + if fs.NArg() != 1 { + return errors.New("usage: remote create app [--dir PATH]") + } + dir, err := application.Scaffold(*parent, fs.Arg(0)) + if err != nil { + return err + } + fmt.Fprintf(stdout, "Created %s\n\nNext:\n cd %s\n remote validate\n remote build\n", dir, shellPath(dir)) + return nil +} + +func shellPath(path string) string { + if abs, err := filepath.Abs(path); err == nil { + path = abs + } + if strings.ContainsAny(path, " \t'\"") { + return fmt.Sprintf("%q", path) + } + return path +} diff --git a/internal/cli/flags.go b/internal/cli/flags.go new file mode 100644 index 0000000..ab3164b --- /dev/null +++ b/internal/cli/flags.go @@ -0,0 +1,29 @@ +package cli + +import "strings" + +// The standard flag package stops at the first positional argument. CLI users +// reasonably write both `build -o x .` and `build . -o x`, so normalize known +// value flags before parsing. +func normalizeValueFlags(args []string, valueFlags map[string]bool) []string { + var flags, positional []string + for i := 0; i < len(args); i++ { + if valueFlags[args[i]] && i+1 < len(args) { + flags = append(flags, args[i], args[i+1]) + i++ + continue + } + matched := false + for name := range valueFlags { + if strings.HasPrefix(args[i], name+"=") { + flags = append(flags, args[i]) + matched = true + break + } + } + if !matched { + positional = append(positional, args[i]) + } + } + return append(flags, positional...) +} diff --git a/internal/cli/validate.go b/internal/cli/validate.go new file mode 100644 index 0000000..507d317 --- /dev/null +++ b/internal/cli/validate.go @@ -0,0 +1,31 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + + "github.com/futrx-com/remote.futrx-cli/internal/application" +) + +func runValidate(args []string, stdout, stderr io.Writer) error { + fs := flag.NewFlagSet("validate", flag.ContinueOnError) + fs.SetOutput(stderr) + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() > 1 { + return errors.New("usage: remote validate [PATH]") + } + dir := "." + if fs.NArg() == 1 { + dir = fs.Arg(0) + } + manifest, err := application.Validate(dir) + if err != nil { + return err + } + fmt.Fprintf(stdout, "Valid Remote application %s (%s)\n", manifest.Name, manifest.Version) + return nil +} diff --git a/internal/domain/manifest.go b/internal/domain/manifest.go new file mode 100644 index 0000000..bd029a2 --- /dev/null +++ b/internal/domain/manifest.go @@ -0,0 +1,43 @@ +package domain + +import "encoding/json" + +type Manifest struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Category string `json:"category,omitempty"` + Version string `json:"version"` + Icon string `json:"icon,omitempty"` + Scopes []string `json:"scopes"` + Port Port `json:"port,omitempty"` + Env []Env `json:"env,omitempty"` + Service string `json:"service,omitempty"` + Install string `json:"install,omitempty"` + Healthcheck Healthcheck `json:"healthcheck,omitempty"` + Connection json.RawMessage `json:"connection,omitempty"` + UI json.RawMessage `json:"ui,omitempty"` + Backend json.RawMessage `json:"backend,omitempty"` + Base string `json:"base,omitempty"` + HostTools json.RawMessage `json:"hostTools,omitempty"` +} + +type Port struct { + Internal int `json:"internal,omitempty"` + DefaultExternal int `json:"defaultExternal,omitempty"` + Protocol string `json:"protocol,omitempty"` + BindAddress string `json:"bindAddress,omitempty"` +} + +type Env struct { + Key string `json:"key"` + Label string `json:"label,omitempty"` + Required bool `json:"required,omitempty"` + Secret bool `json:"secret,omitempty"` + Default string `json:"default,omitempty"` + Generate string `json:"generate,omitempty"` +} + +type Healthcheck struct { + Command string `json:"command,omitempty"` +} diff --git a/internal/domain/validation.go b/internal/domain/validation.go new file mode 100644 index 0000000..f76ffde --- /dev/null +++ b/internal/domain/validation.go @@ -0,0 +1,52 @@ +package domain + +import ( + "fmt" + "regexp" + "strings" +) + +var ApplicationID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) +var environmentKey = regexp.MustCompile(`^[A-Z_][A-Z0-9_]*$`) + +func ValidateMetadata(manifest Manifest) error { + if !ApplicationID.MatchString(manifest.ID) { + return fmt.Errorf("application id %q must use lowercase letters, numbers, and hyphens", manifest.ID) + } + if strings.TrimSpace(manifest.Name) == "" { + return fmt.Errorf("application.json: name is required") + } + if strings.TrimSpace(manifest.Version) == "" { + return fmt.Errorf("application.json: version is required") + } + if len(manifest.Scopes) == 0 { + return fmt.Errorf("application.json: at least one scope is required") + } + for _, scope := range manifest.Scopes { + if scope != "global" && scope != "project" { + return fmt.Errorf("application.json: invalid scope %q", scope) + } + } + for _, variable := range manifest.Env { + if !environmentKey.MatchString(variable.Key) { + return fmt.Errorf("application.json: invalid env key %q", variable.Key) + } + if variable.Generate != "" && variable.Generate != "password" { + return fmt.Errorf("application.json: unsupported generator %q", variable.Generate) + } + } + return nil +} + +func ValidateRuntime(manifest Manifest) error { + if manifest.Port.Internal < 0 || manifest.Port.Internal > 65535 || manifest.Port.DefaultExternal < 0 || manifest.Port.DefaultExternal > 65535 { + return fmt.Errorf("application.json: ports must be between 1 and 65535") + } + if manifest.Port.Internal == 0 && (manifest.Port.DefaultExternal != 0 || manifest.Healthcheck.Command != "") { + return fmt.Errorf("defaultExternal and healthcheck require port.internal") + } + if manifest.Port.Protocol != "" && manifest.Port.Protocol != "tcp" && manifest.Port.Protocol != "udp" { + return fmt.Errorf("application.json: protocol must be tcp or udp") + } + return nil +} diff --git a/internal/domain/validation_test.go b/internal/domain/validation_test.go new file mode 100644 index 0000000..dfaf9e7 --- /dev/null +++ b/internal/domain/validation_test.go @@ -0,0 +1,67 @@ +package domain + +import ( + "strings" + "testing" +) + +func validManifest() Manifest { + return Manifest{ID: "demo", Name: "Demo", Version: "1.0.0", Scopes: []string{"project"}} +} + +func TestValidateMetadata(t *testing.T) { + tests := []struct { + name string + edit func(*Manifest) + want string + }{ + {"invalid id", func(m *Manifest) { m.ID = "Bad_ID" }, `application id "Bad_ID"`}, + {"missing name", func(m *Manifest) { m.Name = " " }, "name is required"}, + {"missing version", func(m *Manifest) { m.Version = "" }, "version is required"}, + {"missing scopes", func(m *Manifest) { m.Scopes = nil }, "at least one scope"}, + {"invalid scope", func(m *Manifest) { m.Scopes = []string{"team"} }, `invalid scope "team"`}, + {"invalid env key", func(m *Manifest) { m.Env = []Env{{Key: "lower"}} }, `invalid env key "lower"`}, + {"invalid generator", func(m *Manifest) { m.Env = []Env{{Key: "TOKEN", Generate: "uuid"}} }, `unsupported generator "uuid"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manifest := validManifest() + tt.edit(&manifest) + err := ValidateMetadata(manifest) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error=%v, want %q", err, tt.want) + } + }) + } + if err := ValidateMetadata(validManifest()); err != nil { + t.Fatalf("valid manifest: %v", err) + } +} + +func TestValidateRuntime(t *testing.T) { + tests := []struct { + name string + port Port + health, want string + }{ + {"negative internal", Port{Internal: -1}, "", "ports must be"}, + {"oversized external", Port{Internal: 1, DefaultExternal: 65536}, "", "ports must be"}, + {"external without internal", Port{DefaultExternal: 80}, "", "require port.internal"}, + {"healthcheck without internal", Port{}, "curl localhost", "require port.internal"}, + {"invalid protocol", Port{Internal: 80, Protocol: "http"}, "", "protocol must be"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manifest := validManifest() + manifest.Port = tt.port + manifest.Healthcheck.Command = tt.health + err := ValidateRuntime(manifest) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error=%v, want %q", err, tt.want) + } + }) + } + if err := ValidateRuntime(validManifest()); err != nil { + t.Fatalf("valid runtime: %v", err) + } +} diff --git a/internal/project/manifest.go b/internal/project/manifest.go new file mode 100644 index 0000000..3b17139 --- /dev/null +++ b/internal/project/manifest.go @@ -0,0 +1,25 @@ +package project + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/futrx-com/remote.futrx-cli/internal/domain" +) + +func ReadManifest(dir string) (domain.Manifest, error) { + raw, err := os.ReadFile(filepath.Join(dir, "application.json")) + if err != nil { + return domain.Manifest{}, fmt.Errorf("read application.json: %w", err) + } + var manifest domain.Manifest + decoder := json.NewDecoder(strings.NewReader(string(raw))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return manifest, fmt.Errorf("parse application.json: %w", err) + } + return manifest, nil +} diff --git a/internal/project/project_test.go b/internal/project/project_test.go new file mode 100644 index 0000000..3c06d28 --- /dev/null +++ b/internal/project/project_test.go @@ -0,0 +1,90 @@ +package project + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/futrx-com/remote.futrx-cli/internal/domain" +) + +func TestReadManifest(t *testing.T) { + dir := t.TempDir() + raw := `{"id":"demo","name":"Demo","version":"1","scopes":["project"]}` + if err := os.WriteFile(filepath.Join(dir, "application.json"), []byte(raw), 0644); err != nil { + t.Fatal(err) + } + manifest, err := ReadManifest(dir) + if err != nil || manifest.ID != "demo" { + t.Fatalf("manifest=%+v err=%v", manifest, err) + } +} + +func TestReadManifestMissing(t *testing.T) { + if _, err := ReadManifest(t.TempDir()); err == nil || !strings.Contains(err.Error(), "read application.json") { + t.Fatalf("error=%v", err) + } +} + +func TestResolveInstall(t *testing.T) { + dir := t.TempDir() + infra := filepath.Join(dir, "infra") + if err := os.Mkdir(infra, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(infra, "install.sh"), nil, 0644); err != nil { + t.Fatal(err) + } + install, err := ResolveInstall(dir, domain.Manifest{}) + if err != nil || install != "infra/install.sh" { + t.Fatalf("install=%q err=%v", install, err) + } + for _, path := range []string{"../install.sh", "infra/missing.sh", "infra/../install.sh"} { + if _, err := ResolveInstall(dir, domain.Manifest{Install: path}); err == nil { + t.Errorf("accepted %q", path) + } + } +} + +func TestResolveInstallRequiredByRuntime(t *testing.T) { + for _, manifest := range []domain.Manifest{{Port: domain.Port{Internal: 80}}, {Service: "demo"}, {Healthcheck: domain.Healthcheck{Command: "ok"}}} { + if _, err := ResolveInstall(t.TempDir(), manifest); err == nil || !strings.Contains(err.Error(), "require infra/install.sh") { + t.Errorf("manifest=%+v err=%v", manifest, err) + } + } +} + +func TestHasCapability(t *testing.T) { + dir := t.TempDir() + if HasCapability(dir, "") { + t.Fatal("empty project has capability") + } + for _, capability := range []string{"ui", "backend", "skills"} { + if err := os.Mkdir(filepath.Join(dir, capability), 0755); err != nil { + t.Fatal(err) + } + if !HasCapability(dir, "") { + t.Errorf("%s directory not detected", capability) + } + if err := os.Remove(filepath.Join(dir, capability)); err != nil { + t.Fatal(err) + } + } + if !HasCapability(dir, "infra/install.sh") { + t.Fatal("install not detected") + } +} + +func TestValidateTreeRejectsNestedSymlink(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "nested"), 0755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing", filepath.Join(dir, "nested", "link")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if err := ValidateTree(dir); err == nil || !strings.Contains(err.Error(), "nested/link") { + t.Fatalf("error=%v", err) + } +} diff --git a/internal/project/tree.go b/internal/project/tree.go new file mode 100644 index 0000000..959a3d4 --- /dev/null +++ b/internal/project/tree.go @@ -0,0 +1,50 @@ +package project + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/futrx-com/remote.futrx-cli/internal/domain" +) + +func ResolveInstall(dir string, manifest domain.Manifest) (string, error) { + install := manifest.Install + if install == "" && isRegular(filepath.Join(dir, "infra", "install.sh")) { + install = "infra/install.sh" + } + if install != "" { + clean := filepath.ToSlash(filepath.Clean(install)) + if clean != install || !strings.HasPrefix(clean, "infra/") || !isRegular(filepath.Join(dir, filepath.FromSlash(clean))) { + return "", fmt.Errorf("application.json: install must name a regular file inside infra/") + } + } else if manifest.Port.Internal != 0 || manifest.Port.DefaultExternal != 0 || manifest.Service != "" || manifest.Healthcheck.Command != "" { + return "", fmt.Errorf("port, service, and healthcheck require infra/install.sh") + } + return install, nil +} + +func HasCapability(dir, install string) bool { + return install != "" || isDirectory(filepath.Join(dir, "ui")) || isDirectory(filepath.Join(dir, "backend")) || isDirectory(filepath.Join(dir, "skills")) +} + +func ValidateTree(dir string) error { + return filepath.WalkDir(dir, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink != 0 { + relative, _ := filepath.Rel(dir, path) + return fmt.Errorf("symlinks are not supported: %s", relative) + } + return nil + }) +} + +func isRegular(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} +func isDirectory(path string) bool { info, err := os.Stat(path); return err == nil && info.IsDir() }