mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-03-27 13:04:12 +00:00
feat(sandbox): add preflight checks and auto-build archive to create
- validate agent, API token, archive before provisioning - auto-build archive via `make build-archive` if missing - add tests and Makefile install target
This commit is contained in:
parent
8ea4499052
commit
5456d57aeb
5
Makefile
5
Makefile
@ -102,6 +102,10 @@ install-hooks:
|
|||||||
@echo "Installing git hooks..."
|
@echo "Installing git hooks..."
|
||||||
@bash scripts/install-hooks.sh
|
@bash scripts/install-hooks.sh
|
||||||
|
|
||||||
|
# Install orama CLI to ~/.local/bin and configure PATH
|
||||||
|
install: build
|
||||||
|
@bash scripts/install.sh
|
||||||
|
|
||||||
# Clean build artifacts
|
# Clean build artifacts
|
||||||
clean:
|
clean:
|
||||||
@echo "Cleaning build artifacts..."
|
@echo "Cleaning build artifacts..."
|
||||||
@ -142,6 +146,7 @@ health:
|
|||||||
help:
|
help:
|
||||||
@echo "Available targets:"
|
@echo "Available targets:"
|
||||||
@echo " build - Build all executables"
|
@echo " build - Build all executables"
|
||||||
|
@echo " install - Build and install 'orama' CLI to ~/.local/bin"
|
||||||
@echo " clean - Clean build artifacts"
|
@echo " clean - Clean build artifacts"
|
||||||
@echo " test - Run unit tests"
|
@echo " test - Run unit tests"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package sandbox
|
package sandbox
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@ -10,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
|
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
|
||||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
"github.com/DeBrosOfficial/network/pkg/inspector"
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/rwagent"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create orchestrates the creation of a new sandbox cluster.
|
// Create orchestrates the creation of a new sandbox cluster.
|
||||||
@ -19,14 +21,10 @@ func Create(name string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve wallet SSH key once for all phases
|
// --- Preflight: validate everything BEFORE spending money ---
|
||||||
sshKeyPath, cleanup, err := resolveVaultKeyOnce(cfg.SSHKey.VaultTarget)
|
fmt.Println("Preflight checks:")
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("prepare SSH key: %w", err)
|
|
||||||
}
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
// Check for existing active sandbox
|
// 1. Check for existing active sandbox
|
||||||
active, err := FindActiveSandbox()
|
active, err := FindActiveSandbox()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -35,6 +33,52 @@ func Create(name string) error {
|
|||||||
return fmt.Errorf("sandbox %q is already active (status: %s)\nDestroy it first: orama sandbox destroy --name %s",
|
return fmt.Errorf("sandbox %q is already active (status: %s)\nDestroy it first: orama sandbox destroy --name %s",
|
||||||
active.Name, active.Status, active.Name)
|
active.Name, active.Status, active.Name)
|
||||||
}
|
}
|
||||||
|
fmt.Println(" [ok] No active sandbox")
|
||||||
|
|
||||||
|
// 2. Check rootwallet agent is running and unlocked before the slow SSH key call
|
||||||
|
if err := checkAgentReady(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(" [ok] Rootwallet agent running and unlocked")
|
||||||
|
|
||||||
|
// 3. Resolve SSH key (may trigger approval prompt in RootWallet app)
|
||||||
|
fmt.Print(" [..] Resolving SSH key from vault...")
|
||||||
|
sshKeyPath, cleanup, err := resolveVaultKeyOnce(cfg.SSHKey.VaultTarget)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(" FAILED")
|
||||||
|
return fmt.Errorf("prepare SSH key: %w", err)
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
fmt.Println(" ok")
|
||||||
|
|
||||||
|
// 4. Check binary archive — auto-build if missing
|
||||||
|
archivePath := findNewestArchive()
|
||||||
|
if archivePath == "" {
|
||||||
|
fmt.Println(" [--] No binary archive found, building...")
|
||||||
|
if err := autoBuildArchive(); err != nil {
|
||||||
|
return fmt.Errorf("auto-build archive: %w", err)
|
||||||
|
}
|
||||||
|
archivePath = findNewestArchive()
|
||||||
|
if archivePath == "" {
|
||||||
|
return fmt.Errorf("build succeeded but no archive found in /tmp/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, err := os.Stat(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat archive %s: %w", archivePath, err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" [ok] Binary archive: %s (%s)\n", filepath.Base(archivePath), formatBytes(info.Size()))
|
||||||
|
|
||||||
|
// 5. Verify Hetzner API token works
|
||||||
|
client := NewHetznerClient(cfg.HetznerAPIToken)
|
||||||
|
if err := client.ValidateToken(); err != nil {
|
||||||
|
return fmt.Errorf("hetzner API: %w\n Check your token in ~/.orama/sandbox.yaml", err)
|
||||||
|
}
|
||||||
|
fmt.Println(" [ok] Hetzner API token valid")
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// --- All preflight checks passed, proceed ---
|
||||||
|
|
||||||
// Generate name if not provided
|
// Generate name if not provided
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@ -43,8 +87,6 @@ func Create(name string) error {
|
|||||||
|
|
||||||
fmt.Printf("Creating sandbox %q (%s, %d nodes)\n\n", name, cfg.Domain, 5)
|
fmt.Printf("Creating sandbox %q (%s, %d nodes)\n\n", name, cfg.Domain, 5)
|
||||||
|
|
||||||
client := NewHetznerClient(cfg.HetznerAPIToken)
|
|
||||||
|
|
||||||
state := &SandboxState{
|
state := &SandboxState{
|
||||||
Name: name,
|
Name: name,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
@ -58,18 +100,22 @@ func Create(name string) error {
|
|||||||
cleanupFailedCreate(client, state)
|
cleanupFailedCreate(client, state)
|
||||||
return fmt.Errorf("provision servers: %w", err)
|
return fmt.Errorf("provision servers: %w", err)
|
||||||
}
|
}
|
||||||
SaveState(state)
|
if err := SaveState(state); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: save state after provisioning: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 2: Assign floating IPs
|
// Phase 2: Assign floating IPs
|
||||||
fmt.Println("\nPhase 2: Assigning floating IPs...")
|
fmt.Println("\nPhase 2: Assigning floating IPs...")
|
||||||
if err := phase2AssignFloatingIPs(client, cfg, state, sshKeyPath); err != nil {
|
if err := phase2AssignFloatingIPs(client, cfg, state, sshKeyPath); err != nil {
|
||||||
return fmt.Errorf("assign floating IPs: %w", err)
|
return fmt.Errorf("assign floating IPs: %w", err)
|
||||||
}
|
}
|
||||||
SaveState(state)
|
if err := SaveState(state); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: save state after floating IPs: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 3: Upload binary archive
|
// Phase 3: Upload binary archive
|
||||||
fmt.Println("\nPhase 3: Uploading binary archive...")
|
fmt.Println("\nPhase 3: Uploading binary archive...")
|
||||||
if err := phase3UploadArchive(state, sshKeyPath); err != nil {
|
if err := phase3UploadArchive(state, sshKeyPath, archivePath); err != nil {
|
||||||
return fmt.Errorf("upload archive: %w", err)
|
return fmt.Errorf("upload archive: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,7 +123,7 @@ func Create(name string) error {
|
|||||||
fmt.Println("\nPhase 4: Installing genesis node...")
|
fmt.Println("\nPhase 4: Installing genesis node...")
|
||||||
if err := phase4InstallGenesis(cfg, state, sshKeyPath); err != nil {
|
if err := phase4InstallGenesis(cfg, state, sshKeyPath); err != nil {
|
||||||
state.Status = StatusError
|
state.Status = StatusError
|
||||||
SaveState(state)
|
_ = SaveState(state)
|
||||||
return fmt.Errorf("install genesis: %w", err)
|
return fmt.Errorf("install genesis: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,7 +131,7 @@ func Create(name string) error {
|
|||||||
fmt.Println("\nPhase 5: Joining remaining nodes...")
|
fmt.Println("\nPhase 5: Joining remaining nodes...")
|
||||||
if err := phase5JoinNodes(cfg, state, sshKeyPath); err != nil {
|
if err := phase5JoinNodes(cfg, state, sshKeyPath); err != nil {
|
||||||
state.Status = StatusError
|
state.Status = StatusError
|
||||||
SaveState(state)
|
_ = SaveState(state)
|
||||||
return fmt.Errorf("join nodes: %w", err)
|
return fmt.Errorf("join nodes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,12 +140,49 @@ func Create(name string) error {
|
|||||||
phase6Verify(cfg, state, sshKeyPath)
|
phase6Verify(cfg, state, sshKeyPath)
|
||||||
|
|
||||||
state.Status = StatusRunning
|
state.Status = StatusRunning
|
||||||
SaveState(state)
|
if err := SaveState(state); err != nil {
|
||||||
|
return fmt.Errorf("save final state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
printCreateSummary(cfg, state)
|
printCreateSummary(cfg, state)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkAgentReady verifies the rootwallet agent is running, unlocked, and
|
||||||
|
// that the desktop app is connected (required for first-time app approval).
|
||||||
|
func checkAgentReady() error {
|
||||||
|
client := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
status, err := client.Status(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if rwagent.IsNotRunning(err) {
|
||||||
|
return fmt.Errorf("rootwallet agent is not running\n\n Start it with:\n rw agent start && rw agent unlock")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("rootwallet agent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateAgentStatus(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAgentStatus checks that the agent status indicates readiness.
|
||||||
|
// Separated from checkAgentReady for testability.
|
||||||
|
func validateAgentStatus(status *rwagent.StatusResponse) error {
|
||||||
|
if status.Locked {
|
||||||
|
return fmt.Errorf("rootwallet agent is locked\n\n Unlock it with:\n rw agent unlock")
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.ConnectedApps == 0 {
|
||||||
|
fmt.Println(" [!!] RootWallet desktop app is not open")
|
||||||
|
fmt.Println(" First-time use requires the desktop app to approve access.")
|
||||||
|
fmt.Println(" Open the RootWallet app, then re-run this command.")
|
||||||
|
return fmt.Errorf("RootWallet desktop app required for approval — open it and retry")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// resolveVaultKeyOnce resolves a wallet SSH key to a temp file.
|
// resolveVaultKeyOnce resolves a wallet SSH key to a temp file.
|
||||||
// Returns the key path, cleanup function, and any error.
|
// Returns the key path, cleanup function, and any error.
|
||||||
func resolveVaultKeyOnce(vaultTarget string) (string, func(), error) {
|
func resolveVaultKeyOnce(vaultTarget string) (string, func(), error) {
|
||||||
@ -259,17 +342,46 @@ func waitForSSH(node inspector.Node, timeout time.Duration) error {
|
|||||||
return fmt.Errorf("timeout after %s", timeout)
|
return fmt.Errorf("timeout after %s", timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// phase3UploadArchive uploads the binary archive to the genesis node, then fans out
|
// autoBuildArchive runs `make build-archive` from the project root.
|
||||||
// to the remaining nodes server-to-server (much faster than uploading from local machine).
|
func autoBuildArchive() error {
|
||||||
func phase3UploadArchive(state *SandboxState, sshKeyPath string) error {
|
// Find project root by looking for go.mod
|
||||||
archivePath := findNewestArchive()
|
dir, err := findProjectRoot()
|
||||||
if archivePath == "" {
|
if err != nil {
|
||||||
fmt.Println(" No binary archive found, run `orama build` first")
|
return fmt.Errorf("find project root: %w", err)
|
||||||
return fmt.Errorf("no binary archive found in /tmp/ (run `orama build` first)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
info, _ := os.Stat(archivePath)
|
cmd := exec.Command("make", "build-archive")
|
||||||
fmt.Printf(" Archive: %s (%s)\n", filepath.Base(archivePath), formatBytes(info.Size()))
|
cmd.Dir = dir
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("make build-archive failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findProjectRoot walks up from the current working directory to find go.mod.
|
||||||
|
func findProjectRoot() (string, error) {
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
return "", fmt.Errorf("could not find go.mod in any parent directory")
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// phase3UploadArchive uploads the binary archive to the genesis node, then fans out
|
||||||
|
// to the remaining nodes server-to-server (much faster than uploading from local machine).
|
||||||
|
func phase3UploadArchive(state *SandboxState, sshKeyPath, archivePath string) error {
|
||||||
|
fmt.Printf(" Archive: %s\n", filepath.Base(archivePath))
|
||||||
|
|
||||||
if err := fanoutArchive(state.Servers, sshKeyPath, archivePath); err != nil {
|
if err := fanoutArchive(state.Servers, sshKeyPath, archivePath); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
158
pkg/cli/sandbox/create_test.go
Normal file
158
pkg/cli/sandbox/create_test.go
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/rwagent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFindProjectRoot_FromSubDir(t *testing.T) {
|
||||||
|
// Create a temp dir with go.mod (resolve symlinks for macOS /private/var)
|
||||||
|
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module test"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a nested subdir
|
||||||
|
sub := filepath.Join(root, "pkg", "foo")
|
||||||
|
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change to subdir and find root
|
||||||
|
orig, _ := os.Getwd()
|
||||||
|
defer os.Chdir(orig)
|
||||||
|
os.Chdir(sub)
|
||||||
|
|
||||||
|
got, err := findProjectRoot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findProjectRoot() error: %v", err)
|
||||||
|
}
|
||||||
|
if got != root {
|
||||||
|
t.Errorf("findProjectRoot() = %q, want %q", got, root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindProjectRoot_NoGoMod(t *testing.T) {
|
||||||
|
// Create a temp dir without go.mod
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
orig, _ := os.Getwd()
|
||||||
|
defer os.Chdir(orig)
|
||||||
|
os.Chdir(dir)
|
||||||
|
|
||||||
|
_, err := findProjectRoot()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("findProjectRoot() should error when no go.mod exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindNewestArchive_NoArchives(t *testing.T) {
|
||||||
|
// findNewestArchive scans /tmp — just verify it returns "" when
|
||||||
|
// no matching files exist (this is the normal case in CI).
|
||||||
|
// We can't fully control /tmp, but we can verify the function doesn't crash.
|
||||||
|
result := findNewestArchive()
|
||||||
|
// Result is either "" or a valid path — both are acceptable
|
||||||
|
if result != "" {
|
||||||
|
if _, err := os.Stat(result); err != nil {
|
||||||
|
t.Errorf("findNewestArchive() returned non-existent path: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSafeDNSName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"example.com", true},
|
||||||
|
{"test-cluster.orama.network", true},
|
||||||
|
{"a", true},
|
||||||
|
{"", false},
|
||||||
|
{"test;rm -rf /", false},
|
||||||
|
{"test$(whoami)", false},
|
||||||
|
{"test space", false},
|
||||||
|
{"test_underscore", false},
|
||||||
|
{"UPPER.case.OK", true},
|
||||||
|
{"123.456", true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := isSafeDNSName(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("isSafeDNSName(%q) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsHex(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"abcdef0123456789", true},
|
||||||
|
{"ABCDEF", true},
|
||||||
|
{"0", true},
|
||||||
|
{"", true}, // vacuous truth, but guarded by len check in caller
|
||||||
|
{"xyz", false},
|
||||||
|
{"abcg", false},
|
||||||
|
{"abc def", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := isHex(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("isHex(%q) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAgentStatus_Locked(t *testing.T) {
|
||||||
|
status := &rwagent.StatusResponse{Locked: true, ConnectedApps: 1}
|
||||||
|
err := validateAgentStatus(status)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for locked agent")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "locked") {
|
||||||
|
t.Errorf("error should mention locked, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAgentStatus_NoDesktopApp(t *testing.T) {
|
||||||
|
status := &rwagent.StatusResponse{Locked: false, ConnectedApps: 0}
|
||||||
|
err := validateAgentStatus(status)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when no desktop app connected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "desktop app") {
|
||||||
|
t.Errorf("error should mention desktop app, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAgentStatus_Ready(t *testing.T) {
|
||||||
|
status := &rwagent.StatusResponse{Locked: false, ConnectedApps: 1}
|
||||||
|
if err := validateAgentStatus(status); err != nil {
|
||||||
|
t.Errorf("expected no error for ready agent, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBytes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{0, "0 B"},
|
||||||
|
{500, "500 B"},
|
||||||
|
{1024, "1.0 KB"},
|
||||||
|
{1536, "1.5 KB"},
|
||||||
|
{1048576, "1.0 MB"},
|
||||||
|
{1073741824, "1.0 GB"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := formatBytes(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("formatBytes(%d) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
scripts/install.sh
Executable file
95
scripts/install.sh
Executable file
@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Orama CLI installer
|
||||||
|
# Builds the CLI and adds `orama` to your PATH.
|
||||||
|
# Usage: ./scripts/install.sh [--shell fish|zsh|bash]
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
BIN_DIR="$HOME/.local/bin"
|
||||||
|
BIN_PATH="$BIN_DIR/orama"
|
||||||
|
|
||||||
|
# --- Parse args ---
|
||||||
|
SHELL_NAME=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--shell) SHELL_NAME="$2"; shift 2 ;;
|
||||||
|
-h|--help)
|
||||||
|
echo "Usage: ./scripts/install.sh [--shell fish|zsh|bash]"
|
||||||
|
echo ""
|
||||||
|
echo "Builds the Orama CLI and installs 'orama' to ~/.local/bin."
|
||||||
|
echo "If --shell is not provided, auto-detects from \$SHELL."
|
||||||
|
exit 0 ;;
|
||||||
|
*) echo "Unknown option: $1"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Auto-detect shell
|
||||||
|
if [[ -z "$SHELL_NAME" ]]; then
|
||||||
|
case "$SHELL" in
|
||||||
|
*/fish) SHELL_NAME="fish" ;;
|
||||||
|
*/zsh) SHELL_NAME="zsh" ;;
|
||||||
|
*/bash) SHELL_NAME="bash" ;;
|
||||||
|
*) SHELL_NAME="unknown" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Shell: $SHELL_NAME"
|
||||||
|
|
||||||
|
# --- Build ---
|
||||||
|
echo "==> Building Orama CLI..."
|
||||||
|
(cd "$PROJECT_DIR" && make build)
|
||||||
|
|
||||||
|
# --- Install binary ---
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
cp -f "$PROJECT_DIR/bin/orama" "$BIN_PATH"
|
||||||
|
chmod +x "$BIN_PATH"
|
||||||
|
echo "==> Installed $BIN_PATH"
|
||||||
|
|
||||||
|
# --- Ensure PATH ---
|
||||||
|
add_to_path() {
|
||||||
|
local rc_file="$1"
|
||||||
|
local line="$2"
|
||||||
|
|
||||||
|
if [[ -f "$rc_file" ]] && grep -qF "$line" "$rc_file"; then
|
||||||
|
echo "==> PATH already configured in $rc_file"
|
||||||
|
else
|
||||||
|
echo "" >> "$rc_file"
|
||||||
|
echo "$line" >> "$rc_file"
|
||||||
|
echo "==> Added PATH to $rc_file"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$SHELL_NAME" in
|
||||||
|
fish)
|
||||||
|
FISH_CONFIG="$HOME/.config/fish/config.fish"
|
||||||
|
mkdir -p "$(dirname "$FISH_CONFIG")"
|
||||||
|
add_to_path "$FISH_CONFIG" "fish_add_path $BIN_DIR"
|
||||||
|
;;
|
||||||
|
zsh)
|
||||||
|
add_to_path "$HOME/.zshrc" "export PATH=\"$BIN_DIR:\$PATH\""
|
||||||
|
;;
|
||||||
|
bash)
|
||||||
|
add_to_path "$HOME/.bashrc" "export PATH=\"$BIN_DIR:\$PATH\""
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "==> Unknown shell. Add this to your shell config manually:"
|
||||||
|
echo " export PATH=\"$BIN_DIR:\$PATH\""
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Verify ---
|
||||||
|
VERSION=$("$BIN_PATH" version 2>/dev/null || echo "unknown")
|
||||||
|
echo ""
|
||||||
|
echo "==> Orama CLI ${VERSION} installed!"
|
||||||
|
echo " Run: orama --help"
|
||||||
|
echo ""
|
||||||
|
if [[ "$SHELL_NAME" != "unknown" ]]; then
|
||||||
|
echo " Restart your terminal or run:"
|
||||||
|
case "$SHELL_NAME" in
|
||||||
|
fish) echo " source ~/.config/fish/config.fish" ;;
|
||||||
|
zsh) echo " source ~/.zshrc" ;;
|
||||||
|
bash) echo " source ~/.bashrc" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
Loading…
x
Reference in New Issue
Block a user