Files
zeekayandHanzo Dev 9264049f1d A health address is built in one place, like a chain address
`lux dev start` could not bring up a node built from the current tree, for
three reasons that share a shape with the chain-segment rename: the CLI
spelled out an address the node had already left behind, and nothing failed
to compile.

The health service moved onto typed ops. /v1/health is gone, so every probe
written against it read a healthy node as unhealthy and every printed
endpoint sent a reader to a 404. route.Health, route.Liveness and
route.Readiness compose the three addresses the node answers on, next to
route.Chain and off the same base, and the ten call sites that had it
written out now ask for the one they mean: a probe polls readiness, a
report reads the full checks, a printed endpoint offers the report.

Two luxd flags no longer exist, and passing an unknown flag is fatal, so
the node refused the config before it read any of it:

  --api-keystore-enabled  the keystore API is gone
  --automine-interval     block cadence is the C-Chain's own
                          enable-automining, read by the block builder from
                          the chain config dir, not a node flag

The --automine duration flag went with the second: it named a knob that
does not exist.

`lux dev stop` looked only in the default data-dir, and otherwise pkill'd
anything whose command line carried a quorum size of one — a pattern that
fits every dev node on the machine, not just the one this command started.
It takes the same --data-dir as start now, resolved by the same function,
and reads the PID from there; no PID file is an answer rather than a reason
to guess.

Health gets composition but not the guard that pins chain addresses:
pkg/ssh still asks for health as a JSON-RPC method, and the whole
JSON-RPC-over-SSH family it belongs to is owed the same move onto typed
ops. A guard added before that migration would need an exemption for it,
and an exemption is how a guard rots.

Measured against luxd 1.36.181 (constants v1.6.4): start reaches ready,
prints /v1/chain/C/rpc and /v1/health/ops/health, both answer 200, and
stop interrupts the node it started and reports plainly when there is
none.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-05 19:06:43 -07:00

899 lines
24 KiB
Go

// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package devcmd
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"time"
"github.com/luxfi/cli/pkg/cobrautils"
"github.com/luxfi/cli/pkg/route"
"github.com/luxfi/cli/pkg/ux"
"github.com/luxfi/constants"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
// Stack directory layout under ~/.lux/dev/
const (
stackConfigFile = "stack.yaml"
stackPIDDir = "pids"
stackLogDir = "logs"
stackDataDir = "data"
stackBinDir = "bin"
stackChainsFile = "chains.json"
portStride = 100 // chain i gets port_base + i*100
shutdownTimeout = 10 * time.Second
healthTimeout = 60 * time.Second
healthInterval = 1 * time.Second
)
// StackConfig is the on-disk stack.yaml schema.
type StackConfig struct {
Chains int `yaml:"chains"`
Apps []AppEntry `yaml:"apps"`
DataDir string `yaml:"data_dir"`
LogDir string `yaml:"log_dir"`
}
// AppEntry describes one application in the stack.
type AppEntry struct {
Name string `yaml:"name"`
PortBase int `yaml:"port_base"`
Enabled bool `yaml:"enabled"`
Binary string `yaml:"binary,omitempty"` // image ref or binary name
}
// ChainInfo is written to chains.json for peer discovery.
type ChainInfo struct {
Index int `json:"index"`
RPCHTTP string `json:"rpc_http"`
RPCWS string `json:"rpc_ws"`
StakingP int `json:"staking_port"`
PID int `json:"pid,omitempty"`
}
// ChainsManifest is the top-level chains.json structure.
type ChainsManifest struct {
UpdatedAt string `json:"updated_at"`
Chains []ChainInfo `json:"chains"`
}
var stackChains int
var stackConfigPath string
func newStackCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "stack",
Short: "Orchestrate full local dev stack",
Long: `Manage a multi-app local development stack.
The stack runs luxd (one or more nodes) plus companion apps:
explorer, bridge, exchange, safe, dao, wallet, faucet.
Config lives at ~/.lux/dev/stack.yaml and is auto-created on first run.
Examples:
lux dev stack up # Start stack with defaults
lux dev stack up --chains 3 # Start 3 luxd nodes + apps
lux dev stack down # Graceful shutdown
lux dev stack status # Show running processes
lux dev stack logs explorer # Tail explorer logs`,
RunE: cobrautils.CommandSuiteUsage,
}
cmd.AddCommand(newStackUpCmd())
cmd.AddCommand(newStackDownCmd())
cmd.AddCommand(newStackStatusCmd())
cmd.AddCommand(newStackLogsCmd())
return cmd
}
// --- up ---
func newStackUpCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "up",
Short: "Start the dev stack",
Long: `Start all enabled apps in the dev stack.
luxd nodes start first and must pass health checks before companion
apps are launched. Port deconfliction for multi-chain: chain i gets
ports at port_base + 100*i.`,
RunE: stackUp,
Args: cobra.ExactArgs(0),
SilenceUsage: true,
}
cmd.Flags().IntVar(&stackChains, "chains", 0, "number of luxd nodes (overrides stack.yaml)")
cmd.Flags().StringVar(&stackConfigPath, "config", "", "path to stack.yaml (default: ~/.lux/dev/stack.yaml)")
return cmd
}
func stackUp(*cobra.Command, []string) error {
cfg, err := loadOrCreateConfig()
if err != nil {
return err
}
if stackChains > 0 {
cfg.Chains = stackChains
}
if cfg.Chains < 1 {
cfg.Chains = 1
}
baseDir := stackBaseDir()
pidDir := filepath.Join(baseDir, stackPIDDir)
logDir := expandPath(cfg.LogDir)
for _, dir := range []string{pidDir, logDir, expandPath(cfg.DataDir)} {
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("mkdir %s: %w", dir, err)
}
}
// Phase 1: start luxd nodes
luxdEntry := findApp(cfg, "luxd")
if luxdEntry == nil {
return fmt.Errorf("luxd not found in stack config")
}
manifest := ChainsManifest{
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
}
for i := 0; i < cfg.Chains; i++ {
portOffset := i * portStride
httpPort := luxdEntry.PortBase + portOffset
stakingPort := httpPort + 1
name := chainInstanceName("luxd", i)
if isRunning(pidDir, name) {
ux.Logger.PrintToUser("%s already running, skipping", name)
pid, _ := readPID(pidDir, name)
manifest.Chains = append(manifest.Chains, ChainInfo{
Index: i,
RPCHTTP: fmt.Sprintf("http://127.0.0.1:%d", httpPort),
RPCWS: route.Chain(fmt.Sprintf("ws://127.0.0.1:%d", httpPort), "C") + "/ws",
StakingP: stakingPort,
PID: pid,
})
continue
}
binary, err := resolveBinary("luxd")
if err != nil {
return err
}
dataDir := filepath.Join(expandPath(cfg.DataDir), name)
nodeLogDir := filepath.Join(logDir, name)
for _, d := range []string{dataDir, nodeLogDir} {
if err := os.MkdirAll(d, 0o750); err != nil {
return fmt.Errorf("mkdir %s: %w", d, err)
}
}
args := []string{
"--automine",
"--consensus-sample-size=1",
"--consensus-quorum-size=1",
"--sybil-protection-enabled=false",
"--skip-bootstrap=true",
fmt.Sprintf("--network-id=%d", 1337),
"--http-host=0.0.0.0",
fmt.Sprintf("--http-port=%d", httpPort),
fmt.Sprintf("--staking-port=%d", stakingPort),
fmt.Sprintf("--data-dir=%s", dataDir),
fmt.Sprintf("--log-dir=%s", nodeLogDir),
"--log-level=info",
"--api-admin-enabled=true",
"--index-enabled=true",
}
logFile, err := openLogFile(logDir, name)
if err != nil {
return err
}
ux.Logger.PrintToUser("Starting %s on port %d...", name, httpPort)
pid, err := spawnProcess(binary, args, logFile, pidDir, name)
if err != nil {
_ = logFile.Close()
return fmt.Errorf("start %s: %w", name, err)
}
manifest.Chains = append(manifest.Chains, ChainInfo{
Index: i,
RPCHTTP: fmt.Sprintf("http://127.0.0.1:%d", httpPort),
RPCWS: route.Chain(fmt.Sprintf("ws://127.0.0.1:%d", httpPort), "C") + "/ws",
StakingP: stakingPort,
PID: pid,
})
}
// Write chains.json before waiting (apps can poll it)
if err := writeChainsManifest(baseDir, &manifest); err != nil {
return err
}
// Wait for all luxd nodes to become healthy
for i := 0; i < cfg.Chains; i++ {
httpPort := luxdEntry.PortBase + i*portStride
name := chainInstanceName("luxd", i)
ux.Logger.PrintToUser("Waiting for %s health...", name)
if err := waitForHealth(httpPort); err != nil {
return fmt.Errorf("%s health check failed: %w", name, err)
}
ux.Logger.GreenCheckmarkToUser("%s healthy", name)
}
// Phase 2: start companion apps
for _, app := range cfg.Apps {
if app.Name == "luxd" || !app.Enabled {
continue
}
for i := 0; i < cfg.Chains; i++ {
portOffset := i * portStride
port := app.PortBase + portOffset
name := chainInstanceName(app.Name, i)
if isRunning(pidDir, name) {
ux.Logger.PrintToUser("%s already running, skipping", name)
continue
}
binary, err := resolveBinary(app.Name)
if err != nil {
ux.Logger.PrintToUser("Warning: %s - skipping %s", err, name)
continue
}
logFile, err := openLogFile(logDir, name)
if err != nil {
return err
}
// Pass port and chains.json path via env
appArgs := []string{
fmt.Sprintf("--port=%d", port),
}
ux.Logger.PrintToUser("Starting %s on port %d...", name, port)
if _, err := spawnProcessWithEnv(binary, appArgs, logFile, pidDir, name, []string{
fmt.Sprintf("PORT=%d", port),
fmt.Sprintf("CHAINS_FILE=%s", filepath.Join(baseDir, stackChainsFile)),
fmt.Sprintf("RPC_URL=http://127.0.0.1:%d", luxdEntry.PortBase+i*portStride),
}); err != nil {
_ = logFile.Close()
ux.Logger.PrintToUser("Warning: failed to start %s: %v", name, err)
continue
}
ux.Logger.GreenCheckmarkToUser("%s started", name)
}
}
// Refresh manifest with final PID info
manifest.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
if err := writeChainsManifest(baseDir, &manifest); err != nil {
return err
}
printStackSummary(cfg)
return nil
}
// --- down ---
func newStackDownCmd() *cobra.Command {
return &cobra.Command{
Use: "down",
Short: "Stop the dev stack",
Long: "Gracefully stop all running stack processes. Sends SIGTERM, waits 10s, then SIGKILL.",
RunE: stackDown,
Args: cobra.ExactArgs(0),
SilenceUsage: true,
}
}
func stackDown(*cobra.Command, []string) error {
pidDir := filepath.Join(stackBaseDir(), stackPIDDir)
entries, err := os.ReadDir(pidDir)
if err != nil {
if os.IsNotExist(err) {
ux.Logger.PrintToUser("No stack running")
return nil
}
return err
}
if len(entries) == 0 {
ux.Logger.PrintToUser("No stack running")
return nil
}
// Stop companion apps first (reverse order), then luxd nodes
var luxdPIDs []string
var appPIDs []string
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".pid") {
continue
}
if strings.HasPrefix(e.Name(), "luxd") {
luxdPIDs = append(luxdPIDs, e.Name())
} else {
appPIDs = append(appPIDs, e.Name())
}
}
// Stop apps first
for _, pidFile := range appPIDs {
name := strings.TrimSuffix(pidFile, ".pid")
stopOne(pidDir, name)
}
// Then luxd
for _, pidFile := range luxdPIDs {
name := strings.TrimSuffix(pidFile, ".pid")
stopOne(pidDir, name)
}
// Clean chains.json
_ = os.Remove(filepath.Join(stackBaseDir(), stackChainsFile))
ux.Logger.PrintToUser("Stack stopped")
return nil
}
func stopOne(pidDir, name string) {
pid, err := readPID(pidDir, name)
if err != nil {
ux.Logger.PrintToUser("%s: no PID file", name)
return
}
process, err := os.FindProcess(pid)
if err != nil {
removePIDFile(pidDir, name)
return
}
// Check if process is actually running
if err := process.Signal(syscall.Signal(0)); err != nil {
ux.Logger.PrintToUser("%s (PID %d): not running", name, pid)
removePIDFile(pidDir, name)
return
}
ux.Logger.PrintToUser("Stopping %s (PID %d)...", name, pid)
if err := process.Signal(syscall.SIGTERM); err != nil {
ux.Logger.PrintToUser("%s: SIGTERM failed: %v, trying SIGKILL", name, err)
_ = process.Signal(syscall.SIGKILL)
removePIDFile(pidDir, name)
return
}
// Wait for exit with timeout
done := make(chan struct{})
go func() {
_, _ = process.Wait()
close(done)
}()
select {
case <-done:
ux.Logger.GreenCheckmarkToUser("%s stopped", name)
case <-time.After(shutdownTimeout):
ux.Logger.PrintToUser("%s: shutdown timeout, sending SIGKILL", name)
_ = process.Signal(syscall.SIGKILL)
_, _ = process.Wait()
}
removePIDFile(pidDir, name)
}
// --- status ---
func newStackStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show dev stack status",
Long: "Display a table of all stack processes with PID, port, state, and uptime.",
RunE: stackStatus,
Args: cobra.ExactArgs(0),
SilenceUsage: true,
}
}
func stackStatus(*cobra.Command, []string) error {
cfg, err := loadConfig()
if err != nil {
ux.Logger.PrintToUser("No stack configured (run `lux dev stack up` first)")
return nil
}
pidDir := filepath.Join(stackBaseDir(), stackPIDDir)
table := ux.NewCompatTable()
table.SetHeader([]string{"APP", "INSTANCE", "PID", "PORT", "STATE", "UPTIME"})
for _, app := range cfg.Apps {
for i := 0; i < cfg.Chains; i++ {
name := chainInstanceName(app.Name, i)
port := app.PortBase + i*portStride
pid, err := readPID(pidDir, name)
state := "stopped"
uptime := "-"
pidStr := "-"
if err == nil {
pidStr = strconv.Itoa(pid)
if processAlive(pid) {
state = "running"
uptime = processUptime(pidDir, name)
} else {
state = "crashed"
}
}
if !app.Enabled && state == "stopped" {
state = "disabled"
}
_ = table.Append([]string{app.Name, name, pidStr, strconv.Itoa(port), state, uptime})
}
}
_ = table.Render()
return nil
}
// --- logs ---
func newStackLogsCmd() *cobra.Command {
return &cobra.Command{
Use: "logs <app>",
Short: "Tail logs for a stack app",
Long: `Tail the log file for a stack application.
The app name can be a base name (e.g., "explorer") which tails instance 0,
or a full instance name (e.g., "explorer-1") for a specific chain instance.`,
RunE: stackLogs,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
}
}
func stackLogs(_ *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return fmt.Errorf("no stack configured: %w", err)
}
appName := args[0]
// If bare name, assume instance 0
if !strings.Contains(appName, "-") || !isDigitSuffix(appName) {
appName = chainInstanceName(appName, 0)
}
logDir := expandPath(cfg.LogDir)
// For luxd, logs are in a subdirectory
if strings.HasPrefix(appName, "luxd") {
logPath := filepath.Join(logDir, appName)
// luxd writes to main.log inside its log dir
mainLog := filepath.Join(logPath, "main.log")
if _, err := os.Stat(mainLog); err == nil {
return tailFile(mainLog)
}
// Fallback: first .log file in the dir
entries, err := os.ReadDir(logPath)
if err != nil {
return fmt.Errorf("no logs for %s: %w", appName, err)
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".log") {
return tailFile(filepath.Join(logPath, e.Name()))
}
}
return fmt.Errorf("no log files found in %s", logPath)
}
logPath := filepath.Join(logDir, appName+".log")
if _, err := os.Stat(logPath); err != nil {
return fmt.Errorf("no logs for %s at %s", appName, logPath)
}
return tailFile(logPath)
}
// --- config ---
func defaultConfig() *StackConfig {
return &StackConfig{
Chains: 1,
Apps: []AppEntry{
{Name: "luxd", PortBase: 9650, Enabled: true},
{Name: "explorer", PortBase: 3001, Enabled: true, Binary: "ghcr.io/luxfi/explorer:local"},
{Name: "bridge", PortBase: 3002, Enabled: true},
{Name: "exchange", PortBase: 3003, Enabled: false},
{Name: "safe", PortBase: 3004, Enabled: true},
{Name: "dao", PortBase: 3005, Enabled: true},
{Name: "wallet", PortBase: 3006, Enabled: true},
{Name: "faucet", PortBase: 3007, Enabled: true},
},
DataDir: "~/.lux/dev/data",
LogDir: "~/.lux/dev/logs",
}
}
func stackBaseDir() string {
return filepath.Join(os.Getenv("HOME"), constants.BaseDirName, constants.DevDir)
}
func configPath() string {
if stackConfigPath != "" {
return expandPath(stackConfigPath)
}
return filepath.Join(stackBaseDir(), stackConfigFile)
}
func loadOrCreateConfig() (*StackConfig, error) {
cfg, err := loadConfig()
if err == nil {
return cfg, nil
}
if !os.IsNotExist(err) {
return nil, fmt.Errorf("read config: %w", err)
}
// Create default config
cfg = defaultConfig()
if err := saveConfig(cfg); err != nil {
return nil, err
}
ux.Logger.PrintToUser("Created default stack config at %s", configPath())
return cfg, nil
}
func loadConfig() (*StackConfig, error) {
data, err := os.ReadFile(configPath()) //nolint:gosec // G304: Reading from app's config directory
if err != nil {
return nil, err
}
var cfg StackConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse %s: %w", configPath(), err)
}
if err := validateStackConfig(&cfg); err != nil {
return nil, fmt.Errorf("%s: %w", configPath(), err)
}
return &cfg, nil
}
// appNamePattern restricts stack.yaml `name` fields to a safe alphabet
// — they become PID file names, log file names, and process labels.
var appNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
// validateStackConfig catches obviously-wrong stack.yaml values before
// they become command-line arguments or filesystem paths. Each field
// is validated for the shape it actually needs to take.
func validateStackConfig(cfg *StackConfig) error {
if cfg.Chains < 1 {
return fmt.Errorf("chains: must be >= 1, got %d", cfg.Chains)
}
if cfg.Chains > 32 {
return fmt.Errorf("chains: must be <= 32, got %d", cfg.Chains)
}
for i, app := range cfg.Apps {
if !appNamePattern.MatchString(app.Name) {
return fmt.Errorf("apps[%d].name %q: must match %s", i, app.Name, appNamePattern)
}
if app.PortBase < 1 || app.PortBase > 65535 {
return fmt.Errorf("apps[%d].port_base %d: out of TCP range", i, app.PortBase)
}
if _, err := PortForAppChecked(app.PortBase, cfg.Chains-1); err != nil {
return fmt.Errorf("apps[%d]: %w", i, err)
}
if app.Binary != "" && strings.ContainsAny(app.Binary, " \t\n\r\"'`$;&|<>") {
return fmt.Errorf("apps[%d].binary: contains shell metacharacters", i)
}
}
return nil
}
func saveConfig(cfg *StackConfig) error {
dir := filepath.Dir(configPath())
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return os.WriteFile(configPath(), data, 0o644) //nolint:gosec // G306: Config file needs to be readable
}
// --- process management ---
func resolveBinary(name string) (string, error) {
// Priority 1: $APP_BIN env var (uppercase, dashes to underscores)
envKey := strings.ToUpper(strings.ReplaceAll(name, "-", "_")) + "_BIN"
if p := os.Getenv(envKey); p != "" {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
// Priority 2: ~/.lux/dev/bin/{name}
devBin := filepath.Join(stackBaseDir(), stackBinDir, name)
if _, err := os.Stat(devBin); err == nil {
return devBin, nil
}
// Priority 3: $PATH lookup
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
// For luxd, also check the standard CLI install location
if name == "luxd" {
cliBin := filepath.Join(os.Getenv("HOME"), constants.BaseDirName, constants.LuxCliBinDir, "luxd")
if _, err := os.Stat(cliBin); err == nil {
return cliBin, nil
}
}
return "", fmt.Errorf("binary not found: %s (install it or set %s)", name, envKey)
}
func spawnProcess(binary string, args []string, logFile *os.File, pidDir, name string) (int, error) {
return spawnProcessWithEnv(binary, args, logFile, pidDir, name, nil)
}
func spawnProcessWithEnv(binary string, args []string, logFile *os.File, pidDir, name string, extraEnv []string) (int, error) {
cmd := exec.Command(binary, args...) //nolint:gosec // G204: Running configured dev binaries
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // Detach from parent process group
if len(extraEnv) > 0 {
cmd.Env = append(os.Environ(), extraEnv...)
}
if err := cmd.Start(); err != nil {
return 0, err
}
pid := cmd.Process.Pid
if err := writePID(pidDir, name, pid); err != nil {
return pid, fmt.Errorf("save PID: %w", err)
}
// Release the process so it survives CLI exit
_ = cmd.Process.Release()
return pid, nil
}
func openLogFile(logDir, name string) (*os.File, error) {
path := filepath.Join(logDir, name+".log")
// O_NOFOLLOW blocks the classic symlink-to-arbitrary-file attack on
// the log directory (Red #7 vector). If an attacker replaces the
// expected log path with a symlink pointing at ~/.ssh/authorized_keys
// or similar, OpenFile returns ELOOP instead of truncating / appending
// to the real target. Combined with the restrictive 0o600 mode, a
// shared /tmp-style logDir cannot be weaponised.
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND|syscall.O_NOFOLLOW, 0o600) //nolint:gosec // G304: validated within log dir; O_NOFOLLOW blocks symlink races
}
// --- PID file operations ---
func writePID(pidDir, name string, pid int) error {
if err := os.MkdirAll(pidDir, 0o750); err != nil {
return err
}
path := filepath.Join(pidDir, name+".pid")
return os.WriteFile(path, []byte(strconv.Itoa(pid)), 0o644) //nolint:gosec // G306: PID file needs to be readable
}
func readPID(pidDir, name string) (int, error) {
path := filepath.Join(pidDir, name+".pid")
data, err := os.ReadFile(path) //nolint:gosec // G304: Reading from app's PID directory
if err != nil {
return 0, err
}
return strconv.Atoi(strings.TrimSpace(string(data)))
}
func removePIDFile(pidDir, name string) {
_ = os.Remove(filepath.Join(pidDir, name+".pid"))
}
func isRunning(pidDir, name string) bool {
pid, err := readPID(pidDir, name)
if err != nil {
return false
}
return processAlive(pid)
}
func processAlive(pid int) bool {
process, err := os.FindProcess(pid)
if err != nil {
return false
}
return process.Signal(syscall.Signal(0)) == nil
}
// processUptime returns approximate uptime based on PID file mtime.
func processUptime(pidDir, name string) string {
path := filepath.Join(pidDir, name+".pid")
info, err := os.Stat(path)
if err != nil {
return "-"
}
d := time.Since(info.ModTime())
switch {
case d < time.Minute:
return fmt.Sprintf("%ds", int(d.Seconds()))
case d < time.Hour:
return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60)
default:
return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60)
}
}
// --- chains.json ---
func writeChainsManifest(baseDir string, m *ChainsManifest) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(baseDir, stackChainsFile), data, 0o644) //nolint:gosec // G306: Chains manifest needs to be readable
}
// --- health check ---
func waitForHealth(httpPort int) error {
healthURL := route.Readiness(fmt.Sprintf("http://127.0.0.1:%d", httpPort))
ctx, cancel := context.WithTimeout(context.Background(), healthTimeout)
defer cancel()
client := &http.Client{Timeout: 2 * time.Second}
ticker := time.NewTicker(healthInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return fmt.Errorf("timeout after %s waiting for health on port %d", healthTimeout, httpPort)
case <-ticker.C:
resp, err := client.Get(healthURL) //nolint:noctx // health check in loop with context timeout
if err != nil {
continue
}
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
}
}
// --- tail ---
func tailFile(path string) error {
cmd := exec.Command("tail", "-f", path) //nolint:gosec // G204: Tailing known log file path
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
// --- helpers ---
func chainInstanceName(app string, index int) string {
if index == 0 {
return app
}
return fmt.Sprintf("%s-%d", app, index)
}
func findApp(cfg *StackConfig, name string) *AppEntry {
for i := range cfg.Apps {
if cfg.Apps[i].Name == name {
return &cfg.Apps[i]
}
}
return nil
}
func expandPath(p string) string {
if strings.HasPrefix(p, "~/") {
home, _ := os.UserHomeDir()
return filepath.Join(home, p[2:])
}
return p
}
func isDigitSuffix(s string) bool {
idx := strings.LastIndex(s, "-")
if idx < 0 || idx == len(s)-1 {
return false
}
_, err := strconv.Atoi(s[idx+1:])
return err == nil
}
// maxTCPPort is the upper bound for a valid TCP port number.
const maxTCPPort = 65535
// ErrPortOverflow is returned when port arithmetic would produce a
// number outside the valid TCP port range (e.g. --chains 1000 with a
// base of 9650 lands at 99,650 > 65535).
var ErrPortOverflow = fmt.Errorf("stack: port base + chain offset exceeds TCP port range")
// PortForApp returns the port for an app instance. Exported for testing.
// Callers should prefer PortForAppChecked when the chain index is
// user-controlled.
func PortForApp(portBase, chainIndex int) int {
return portBase + chainIndex*portStride
}
// PortForAppChecked returns an error if the resulting port falls
// outside [1, 65535]. This is the sanctioned path for user-supplied
// --chains values.
func PortForAppChecked(portBase, chainIndex int) (int, error) {
p := PortForApp(portBase, chainIndex)
if p <= 0 || p > maxTCPPort {
return 0, fmt.Errorf("%w: portBase=%d chainIndex=%d → port=%d",
ErrPortOverflow, portBase, chainIndex, p)
}
return p, nil
}
func printStackSummary(cfg *StackConfig) {
ux.Logger.PrintToUser("")
ux.Logger.PrintToUser("Dev stack running (%d chain(s))", cfg.Chains)
ux.Logger.PrintToUser("")
for _, app := range cfg.Apps {
if !app.Enabled {
continue
}
for i := 0; i < cfg.Chains; i++ {
port := PortForApp(app.PortBase, i)
name := chainInstanceName(app.Name, i)
if app.Name == "luxd" {
ux.Logger.PrintToUser(" %s %s", name, route.Health(fmt.Sprintf("http://127.0.0.1:%d", port)))
} else {
ux.Logger.PrintToUser(" %s http://127.0.0.1:%d", name, port)
}
}
}
ux.Logger.PrintToUser("")
ux.Logger.PrintToUser("Config: %s", configPath())
ux.Logger.PrintToUser("Chains: %s", filepath.Join(stackBaseDir(), stackChainsFile))
ux.Logger.PrintToUser("Logs: %s", expandPath(cfg.LogDir))
ux.Logger.PrintToUser("")
ux.Logger.PrintToUser("Stop with: lux dev stack down")
}