diff --git a/apps/lcp-quickstart/Makefile b/apps/lcp-quickstart/Makefile new file mode 100644 index 0000000..7500a2e --- /dev/null +++ b/apps/lcp-quickstart/Makefile @@ -0,0 +1,11 @@ +.PHONY: test lint fmt + +test: + go test ./... + +lint: + golangci-lint run $(shell go list -f '{{.Dir}}' ./...) + +fmt: + golangci-lint fmt $(shell go list -f '{{.Dir}}' ./...) + diff --git a/apps/lcp-quickstart/README.md b/apps/lcp-quickstart/README.md new file mode 100644 index 0000000..40bbf35 --- /dev/null +++ b/apps/lcp-quickstart/README.md @@ -0,0 +1,118 @@ +# lcp-quickstart + +`lcp-quickstart` is a small CLI that bootstraps a local “Requester” stack: + +- your local `lnd` (managed by you; optionally started by the tool) +- `lcpd-grpcd` (Requester) from `go-lcpd/` +- `openai-serve` (OpenAI-compatible HTTP) from `apps/openai-serve/` + +It targets only `testnet` and `mainnet`. + +## Build / Install + +```sh +cd apps/lcp-quickstart +go install ./cmd/lcp-quickstart +``` + +## Prerequisites + +- `lcp-quickstart` will auto-install `lnd` + `lncli` (downloaded into `~/.lcp-quickstart/bin/`) if they are missing. +- If you prefer a system-installed `lncli`, pass `--lncli `. +- Wallet creation/unlock is **not** automated (seed safety). The tool will tell you when to run: + - `lcp-quickstart lncli create` + - `lcp-quickstart lncli unlock` + +## Run (testnet) + +`testnet` requires an explicit Provider: + +```sh +lcp-quickstart testnet up --provider "@" +``` + +If you need to open a channel (you see a “channel/route required” error): + +```sh +lcp-quickstart testnet up \ + --provider "@" \ + --open-channel --channel-sats +``` + +## Run (mainnet) + +Mainnet is dangerous by default; you must explicitly opt in: + +```sh +lcp-quickstart mainnet up --i-understand-mainnet +``` + +By default, mainnet uses this Provider node (override with `--provider`): + +```text +03737b4a2e44b45f786a18e43c3cf462ab97891e9f8992a0d493394691ac0db983@54.214.32.132:20309 +``` + +## Status / Logs / Down / Reset + +```sh +lcp-quickstart status +lcp-quickstart lncli +lcp-quickstart logs +lcp-quickstart down +lcp-quickstart reset --force +``` + +`status` prints “next steps” guidance (unlock wallet, connect Provider, open a channel, etc). + +## Verify (HTTP) + +Health check: + +```sh +curl -sS http://127.0.0.1:8080/healthz +``` + +Expected output: + +```text +ok +``` + +Chat completions: + +```sh +curl -sS http://127.0.0.1:8080/v1/chat/completions \ + -H 'content-type: application/json' \ + -H 'authorization: Bearer lcp-dev' \ + -d '{"model":"gpt-5.2","messages":[{"role":"user","content":"Say hello."}]}' +``` + +## Workspace layout + +By default the app writes to `~/.lcp-quickstart/`: + +- `state.json`: machine-readable state snapshot +- `logs/.log`: logs (components: `lnd`, `lcpd-grpcd`, `openai-serve`) +- `pids/.pid`: PID tracking (for managed processes) +- `bin/`: managed binaries (`lnd`, `lncli`, `lcpd-grpcd`, `openai-serve`) + +Override the workspace location with `--home `. + +## Safety notes + +- `lcpd-grpcd` is always required to bind to loopback (`127.0.0.1` / `localhost`). +- `openai-serve` binds to `127.0.0.1:8080` by default. + - If you bind it to a non-loopback address, you must pass `--i-understand-exposing-openai` **and** set `--openai-api-keys` non-empty. +- Mainnet defaults `OPENAI_SERVE_MAX_PRICE_MSAT` to a conservative cap; override with `--openai-max-price-msat`. + +## Development + +```sh +cd apps/lcp-quickstart +make test +make lint +make fmt +``` + +This repo expects `golangci-lint v2` installed locally. diff --git a/apps/lcp-quickstart/cmd/lcp-quickstart/main.go b/apps/lcp-quickstart/cmd/lcp-quickstart/main.go new file mode 100644 index 0000000..3cda001 --- /dev/null +++ b/apps/lcp-quickstart/cmd/lcp-quickstart/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "os" + + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/cli" +) + +func main() { + os.Exit(cli.Main(os.Args[1:])) +} + diff --git a/apps/lcp-quickstart/go.mod b/apps/lcp-quickstart/go.mod new file mode 100644 index 0000000..b3f5059 --- /dev/null +++ b/apps/lcp-quickstart/go.mod @@ -0,0 +1,4 @@ +module github.com/bruwbird/lcp/apps/lcp-quickstart + +go 1.24.4 + diff --git a/apps/lcp-quickstart/internal/build/go_build.go b/apps/lcp-quickstart/internal/build/go_build.go new file mode 100644 index 0000000..00fc67d --- /dev/null +++ b/apps/lcp-quickstart/internal/build/go_build.go @@ -0,0 +1,28 @@ +package build + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +func GoBinary(ctx context.Context, moduleDir, pkg, outPath string) error { + cmd := exec.CommandContext(ctx, "go", "build", "-o", outPath, pkg) + cmd.Dir = moduleDir + + var combined bytes.Buffer + cmd.Stdout = &combined + cmd.Stderr = &combined + + if err := cmd.Run(); err != nil { + out := strings.TrimSpace(combined.String()) + if out != "" { + return fmt.Errorf("go build failed: %w\n%s", err, out) + } + return fmt.Errorf("go build failed: %w", err) + } + return nil +} + diff --git a/apps/lcp-quickstart/internal/cli/main.go b/apps/lcp-quickstart/internal/cli/main.go new file mode 100644 index 0000000..707b93c --- /dev/null +++ b/apps/lcp-quickstart/internal/cli/main.go @@ -0,0 +1,573 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/controller" + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/deps" + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/lncli" + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/model" + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/reporoot" + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/workspace" +) + +const ( + exitOK = 0 + exitError = 1 + exitUsage = 2 +) + +func Main(args []string) int { + return MainWithIO(args, os.Stdout, os.Stderr) +} + +func MainWithIO(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("lcp-quickstart", flag.ContinueOnError) + fs.SetOutput(stderr) + + home := fs.String("home", "", "workspace directory (default: ~/.lcp-quickstart)") + repoRoot := fs.String("repo-root", "", "repo root (auto-detected from cwd if empty)") + lncliPath := fs.String("lncli", "lncli", "path to lncli binary") + autoInstall := fs.Bool("auto-install", true, "auto-install missing lnd/lncli into workspace/bin") + version := fs.Bool("version", false, "print version and exit") + + if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + return exitOK + } + fprintln(stderr, err) + return exitUsage + } + + if *version { + fprintln(stdout, "lcp-quickstart (dev)") + return exitOK + } + + rest := fs.Args() + if len(rest) < 2 { + printUsage(stderr) + return exitUsage + } + + network, err := model.ParseNetwork(rest[0]) + if err != nil { + fprintln(stderr, err) + return exitUsage + } + cmd := strings.ToLower(strings.TrimSpace(rest[1])) + cmdArgs := rest[2:] + + ws := workspace.Resolve(*home) + + root := strings.TrimSpace(*repoRoot) + if root == "" && cmd == "up" { + cwd, err := os.Getwd() + if err != nil { + fprintln(stderr, err) + return exitError + } + root, err = reporoot.Detect(cwd) + if err != nil { + fprintln(stderr, err) + return exitError + } + } + + ctl := controller.Controller{ + Workspace: ws, + RepoRoot: root, + LNCLI: lncli.Client{Path: *lncliPath}, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + switch cmd { + case "up": + return runUp(ctx, ctl, network, *autoInstall, cmdArgs, stdout, stderr) + case "down": + return runDown(ctx, ctl, network, cmdArgs, stdout, stderr) + case "status": + return runStatus(ctx, ctl, network, *autoInstall, cmdArgs, stdout, stderr) + case "lncli": + return runLNCLI(ctx, ctl, network, *autoInstall, cmdArgs, stdout, stderr) + case "logs": + return runLogs(ctx, ws, network, cmdArgs, stdout, stderr) + case "reset": + return runReset(ctx, ctl, network, cmdArgs, stdout, stderr) + case "-h", "--help", "help": + printUsage(stdout) + return exitOK + default: + fprintf(stderr, "unknown command: %q\n", cmd) + printUsage(stderr) + return exitUsage + } +} + +func runUp(ctx context.Context, ctl controller.Controller, network model.Network, autoInstall bool, args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(string(network)+" up", flag.ContinueOnError) + fs.SetOutput(stderr) + + provider := fs.String("provider", "", "Provider node: @ (required for testnet)") + startLND := fs.Bool("start-lnd", false, "start lnd if lncli getinfo fails (best-effort)") + openChannel := fs.Bool("open-channel", false, "attempt to open a channel if liquidity is insufficient") + channelSats := fs.Int64("channel-sats", 0, "channel size in sats (required with --open-channel)") + iUnderstandMainnet := fs.Bool("i-understand-mainnet", false, "required for mainnet up") + iUnderstandExposingOpenAI := fs.Bool("i-understand-exposing-openai", false, "allow openai-serve to bind non-loopback addr (dangerous)") + + lcpdGRPCAddr := fs.String("lcpd-grpc-addr", defaultLCPDGRPCAddr, "lcpd-grpcd listen addr") + lndRPCAddr := fs.String("lnd-rpc-addr", defaultLNDRPCAddr, "lnd gRPC addr host:port") + lndTLSCert := fs.String("lnd-tls-cert-path", "", "lnd tls.cert path (default: ~/.lnd/tls.cert)") + lndMacaroon := fs.String("lnd-admin-macaroon-path", "", "lnd admin.macaroon path (default: ~/.lnd/.../admin.macaroon)") + + openAIHTTPAddr := fs.String("openai-http-addr", defaultOpenAIHTTPAddr, "openai-serve HTTP listen addr") + openAIAPIKeys := fs.String("openai-api-keys", defaultOpenAIAPIKeys, "openai-serve API keys CSV (empty disables auth)") + openAIMaxPrice := fs.String("openai-max-price-msat", "", "openai-serve max price msat (empty uses network default; 0 disables)") + + if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + return exitOK + } + fprintln(stderr, err) + return exitUsage + } + + var maxPrice uint64 + var maxPriceSet bool + if raw := strings.TrimSpace(*openAIMaxPrice); raw != "" { + parsed, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + fprintln(stderr, "invalid --openai-max-price-msat:", err) + return exitUsage + } + maxPrice = parsed + maxPriceSet = true + } + + resolvedLNCLI, err := ensureLNDTools(ctx, ctl.Workspace, ctl.LNCLI.Path, autoInstall, *startLND) + if err != nil { + fprintln(stderr, err) + return exitError + } + ctl.LNCLI.Path = resolvedLNCLI + + opts := controller.UpOptions{ + Network: network, + Provider: *provider, + StartLND: *startLND, + OpenChannel: *openChannel, + ChannelSats: *channelSats, + IUnderstandMainnet: *iUnderstandMainnet, + IUnderstandExposingOpenAI: *iUnderstandExposingOpenAI, + LCPDGRPCAddr: *lcpdGRPCAddr, + LNDRPCAddr: *lndRPCAddr, + LNDTLSCertPath: *lndTLSCert, + LNDAdminMacaroonPath: *lndMacaroon, + OpenAIHTTPAddr: *openAIHTTPAddr, + OpenAIAPIKeysCSV: *openAIAPIKeys, + OpenAIMaxPriceMsat: maxPrice, + OpenAIMaxPriceSet: maxPriceSet, + } + + ctxTimeout, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + + if err := ctl.Up(ctxTimeout, opts); err != nil { + fprintln(stderr, err) + return exitError + } + + fprintf(stdout, "ok: %s up\n", network) + fprintf(stdout, "healthz: curl -sS http://%s/healthz\n", *openAIHTTPAddr) + fprintf(stdout, "openai base_url: http://%s/v1\n", *openAIHTTPAddr) + fprintf(stdout, "status: lcp-quickstart %s status\n", network) + fprintf(stdout, "down: lcp-quickstart %s down\n", network) + return exitOK +} + +func runDown(ctx context.Context, ctl controller.Controller, network model.Network, args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(string(network)+" down", flag.ContinueOnError) + fs.SetOutput(stderr) + if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + return exitOK + } + fprintln(stderr, err) + return exitUsage + } + + if err := ctl.Down(ctx, network); err != nil { + fprintln(stderr, err) + return exitError + } + fprintf(stdout, "ok: %s down\n", network) + return exitOK +} + +func runStatus(ctx context.Context, ctl controller.Controller, network model.Network, autoInstall bool, args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(string(network)+" status", flag.ContinueOnError) + fs.SetOutput(stderr) + asJSON := fs.Bool("json", false, "print state.json and exit") + if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + return exitOK + } + fprintln(stderr, err) + return exitUsage + } + + resolvedLNCLI, err := ensureLNDTools(ctx, ctl.Workspace, ctl.LNCLI.Path, autoInstall, false) + if err != nil { + fprintln(stderr, err) + return exitError + } + ctl.LNCLI.Path = resolvedLNCLI + + st, err := ctl.Status(ctx, network) + if err != nil { + fprintln(stderr, err) + return exitError + } + + if *asJSON { + b, err := json.MarshalIndent(st, "", " ") + if err != nil { + fprintln(stderr, err) + return exitError + } + fprintln(stdout, string(b)) + return exitOK + } + + walletState := lncli.WalletUnknown + if !st.Readiness.LNDReady { + walletState = ctl.LNCLI.DetectWalletState(ctx, network) + } + printState(stdout, ctl.Workspace, st) + printNextSteps(stdout, network, st, walletState) + return exitOK +} + +func runLNCLI(ctx context.Context, ctl controller.Controller, network model.Network, autoInstall bool, args []string, stdout, stderr io.Writer) int { + _ = stdout + _ = stderr + + resolvedLNCLI, err := ensureLNDTools(ctx, ctl.Workspace, ctl.LNCLI.Path, autoInstall, false) + if err != nil { + fprintln(os.Stderr, err) + return exitError + } + + cmdArgs := make([]string, 0, len(args)+1) + if network == model.NetworkTestnet && !hasLNCLINetworkFlag(args) { + cmdArgs = append(cmdArgs, "--network=testnet") + } + cmdArgs = append(cmdArgs, args...) + + cmd := exec.CommandContext(ctx, resolvedLNCLI, cmdArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Run(); err != nil { + return exitError + } + return exitOK +} + +func hasLNCLINetworkFlag(args []string) bool { + for i, arg := range args { + arg = strings.TrimSpace(arg) + if strings.HasPrefix(arg, "--network=") { + return true + } + if arg == "--network" && i+1 < len(args) { + return true + } + } + return false +} + +func runLogs(ctx context.Context, ws workspace.Workspace, network model.Network, args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(string(network)+" logs", flag.ContinueOnError) + fs.SetOutput(stderr) + if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + return exitOK + } + fprintln(stderr, err) + return exitUsage + } + if fs.NArg() != 1 { + fprintln(stderr, "usage: lcp-quickstart logs ") + fprintln(stderr, "components: lnd, lcpd-grpcd, openai-serve") + return exitUsage + } + componentRaw := strings.TrimSpace(fs.Arg(0)) + component, ok := normalizeComponent(componentRaw) + if !ok { + fprintf(stderr, "unknown component: %q\n", componentRaw) + fprintln(stderr, "components: lnd, lcpd-grpcd, openai-serve") + return exitUsage + } + logPath := ws.LogPath(component) + if _, err := os.Stat(logPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + fprintf(stderr, "log file not found: %s\n", logPath) + fprintf(stderr, "run `lcp-quickstart %s up` first\n", network) + return exitError + } + fprintln(stderr, err) + return exitError + } + fprintf(stdout, "tailing %s\n", logPath) + cmd := exec.CommandContext(ctx, "tail", "-n", "200", "-f", logPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Run(); err != nil { + fprintln(stderr, err) + return exitError + } + return exitOK +} + +func runReset(ctx context.Context, ctl controller.Controller, network model.Network, args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet(string(network)+" reset", flag.ContinueOnError) + fs.SetOutput(stderr) + force := fs.Bool("force", false, "required") + if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + return exitOK + } + fprintln(stderr, err) + return exitUsage + } + if err := ctl.Reset(ctx, network, *force); err != nil { + fprintln(stderr, err) + return exitError + } + fprintf(stdout, "ok: %s reset\n", network) + return exitOK +} + +const ( + defaultLCPDGRPCAddr = "127.0.0.1:50051" + defaultLNDRPCAddr = "localhost:10009" + defaultOpenAIHTTPAddr = "127.0.0.1:8080" + defaultOpenAIAPIKeys = "lcp-dev" +) + +func printUsage(w io.Writer) { + fprintln(w, "usage: lcp-quickstart [--home ] [--repo-root ] [--auto-install] [args]") + fprintln(w, "") + fprintln(w, "examples:") + fprintln(w, " lcp-quickstart testnet up --provider @") + fprintln(w, " lcp-quickstart mainnet up --i-understand-mainnet") + fprintln(w, " lcp-quickstart testnet status") + fprintln(w, " lcp-quickstart testnet logs openai-serve") + fprintln(w, " lcp-quickstart testnet lncli getinfo") + fprintln(w, " lcp-quickstart testnet down") + fprintln(w, " lcp-quickstart testnet reset --force") +} + +func printState(w io.Writer, ws workspace.Workspace, st model.State) { + fprintf(w, "network: %s\n", st.Network) + if strings.TrimSpace(string(st.ProviderNode)) != "" { + fprintf(w, "provider: %s\n", st.ProviderNode) + } + fprintf(w, "workspace: %s\n", ws.DataDir) + fprintln(w, "") + + fprintf(w, "readiness: lnd=%t provider_connected=%t channel_ready=%t lcpd=%t openai=%t\n", + st.Readiness.LNDReady, + st.Readiness.ProviderConnected, + st.Readiness.ChannelReady, + st.Readiness.LCPDReady, + st.Readiness.OpenAIReady, + ) + + for _, component := range []string{controller.ComponentLND, controller.ComponentLCPDGRPCD, controller.ComponentOpenAIServe} { + cs, ok := st.Components[component] + if !ok { + continue + } + status := "stopped" + if cs.Running { + status = "running" + } + fprintf(w, "- %s: %s", component, status) + if cs.PID != 0 { + fprintf(w, " pid=%d", cs.PID) + } + if strings.TrimSpace(cs.Addr) != "" { + fprintf(w, " addr=%s", cs.Addr) + } + if cs.Managed { + fprint(w, " managed") + } + fprintln(w, "") + if strings.TrimSpace(cs.LastError) != "" { + fprintf(w, " last_error: %s\n", cs.LastError) + } + fprintf(w, " log: %s\n", ws.LogPath(component)) + } +} + +func printNextSteps(w io.Writer, network model.Network, st model.State, walletState lncli.WalletState) { + next := statusNextSteps(network, st, walletState) + if len(next) == 0 { + return + } + fprintln(w, "") + fprintln(w, "next:") + for _, line := range next { + fprintf(w, " - %s\n", line) + } +} + +func statusNextSteps(network model.Network, st model.State, walletState lncli.WalletState) []string { + var next []string + + upCmd := upCommand(network, st) + + if !st.Readiness.LNDReady { + switch walletState { + case lncli.WalletUninitialized: + next = append(next, fmt.Sprintf("initialize lnd wallet: %s create", lncliCommandPrefix(network))) + case lncli.WalletLocked: + next = append(next, fmt.Sprintf("unlock lnd wallet: %s unlock", lncliCommandPrefix(network))) + default: + next = append(next, fmt.Sprintf("start lnd (or re-run: %s --start-lnd)", upCmd)) + } + return next + } + + if strings.TrimSpace(string(st.ProviderNode)) == "" { + if network == model.NetworkTestnet { + next = append(next, "set a Provider and run: "+upCmd) + return next + } + next = append(next, "run: "+upCmd) + return next + } + + switch { + case !st.Readiness.ProviderConnected: + next = append(next, "connect Provider / start services: "+upCmd) + case !st.Readiness.ChannelReady: + next = append(next, "need outbound liquidity; open a channel: "+upCmd+" --open-channel --channel-sats ") + case !st.Readiness.LCPDReady || !st.Readiness.OpenAIReady: + next = append(next, "start services: "+upCmd) + } + + if openAIAddr := strings.TrimSpace(st.Components[controller.ComponentOpenAIServe].Addr); openAIAddr != "" { + next = append(next, fmt.Sprintf("healthz: curl -sS http://%s/healthz", openAIAddr)) + } + + return next +} + +func upCommand(network model.Network, st model.State) string { + parts := []string{"lcp-quickstart", string(network), "up"} + if network == model.NetworkMainnet { + parts = append(parts, "--i-understand-mainnet") + } + + node := strings.TrimSpace(string(st.ProviderNode)) + switch { + case node != "": + parts = append(parts, "--provider", node) + case network == model.NetworkTestnet: + parts = append(parts, "--provider", "@") + } + + return strings.Join(parts, " ") +} + +func lncliCommandPrefix(network model.Network) string { + return "lcp-quickstart " + string(network) + " lncli" +} + +func normalizeComponent(component string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(component)) { + case controller.ComponentLND: + return controller.ComponentLND, true + case controller.ComponentLCPDGRPCD, "lcpd", "grpcd": + return controller.ComponentLCPDGRPCD, true + case controller.ComponentOpenAIServe, "openai", "serve": + return controller.ComponentOpenAIServe, true + default: + return "", false + } +} + +func fprintln(w io.Writer, a ...any) { + _, _ = fmt.Fprintln(w, a...) +} + +func ensureLNDTools(ctx context.Context, ws workspace.Workspace, lncliPath string, autoInstall bool, needLND bool) (string, error) { + lncliPath = strings.TrimSpace(lncliPath) + if lncliPath == "" { + lncliPath = "lncli" + } + + if lncliPath != "lncli" { + if _, err := exec.LookPath(lncliPath); err != nil { + return "", fmt.Errorf("lncli not found: %s", lncliPath) + } + return lncliPath, nil + } + + _, lncliErr := exec.LookPath("lncli") + _, lndErr := exec.LookPath("lnd") + + if lncliErr == nil && (!needLND || lndErr == nil) { + return "lncli", nil + } + + // If we already downloaded LND in the workspace, use that first. + downloadedLNCLI := filepath.Join(ws.BinDir, "lncli") + downloadedLND := filepath.Join(ws.BinDir, "lnd") + if fileExists(downloadedLNCLI) && (!needLND || fileExists(downloadedLND)) { + return downloadedLNCLI, nil + } + + if !autoInstall { + if lncliErr != nil { + return "", errors.New("lncli not found; install lnd/lncli or pass --auto-install") + } + return "", errors.New("lnd not found; install lnd or pass --auto-install") + } + + bundle, err := deps.EnsureLND(ctx, ws, deps.DefaultLNDVersion) + if err != nil { + return "", err + } + return bundle.LNCLIPath, nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func fprint(w io.Writer, a ...any) { + _, _ = fmt.Fprint(w, a...) +} + +func fprintf(w io.Writer, format string, a ...any) { + _, _ = fmt.Fprintf(w, format, a...) +} diff --git a/apps/lcp-quickstart/internal/cli/main_test.go b/apps/lcp-quickstart/internal/cli/main_test.go new file mode 100644 index 0000000..c6eaa91 --- /dev/null +++ b/apps/lcp-quickstart/internal/cli/main_test.go @@ -0,0 +1,129 @@ +package cli + +import ( + "testing" + + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/controller" + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/lncli" + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/model" +) + +func TestUpCommand_TestnetMissingProvider(t *testing.T) { + t.Parallel() + + got := upCommand(model.NetworkTestnet, model.NewState()) + want := "lcp-quickstart testnet up --provider @" + if got != want { + t.Fatalf("upCommand() = %q, want %q", got, want) + } +} + +func TestUpCommand_Mainnet(t *testing.T) { + t.Parallel() + + got := upCommand(model.NetworkMainnet, model.NewState()) + want := "lcp-quickstart mainnet up --i-understand-mainnet" + if got != want { + t.Fatalf("upCommand() = %q, want %q", got, want) + } +} + +func TestStatusNextSteps_LndUninitialized(t *testing.T) { + t.Parallel() + + st := model.NewState() + st.Readiness.LNDReady = false + + next := statusNextSteps(model.NetworkTestnet, st, lncli.WalletUninitialized) + if len(next) == 0 { + t.Fatalf("statusNextSteps() returned empty next steps") + } + want := "initialize lnd wallet: lcp-quickstart testnet lncli create" + if next[0] != want { + t.Fatalf("next[0] = %q, want %q", next[0], want) + } +} + +func TestStatusNextSteps_LndLocked(t *testing.T) { + t.Parallel() + + st := model.NewState() + st.Readiness.LNDReady = false + + next := statusNextSteps(model.NetworkMainnet, st, lncli.WalletLocked) + if len(next) == 0 { + t.Fatalf("statusNextSteps() returned empty next steps") + } + want := "unlock lnd wallet: lcp-quickstart mainnet lncli unlock" + if next[0] != want { + t.Fatalf("next[0] = %q, want %q", next[0], want) + } +} + +func TestStatusNextSteps_NeedProvider(t *testing.T) { + t.Parallel() + + st := model.NewState() + st.Readiness.LNDReady = true + + next := statusNextSteps(model.NetworkTestnet, st, lncli.WalletUnknown) + if len(next) == 0 { + t.Fatalf("statusNextSteps() returned empty next steps") + } + want := "set a Provider and run: lcp-quickstart testnet up --provider @" + if next[0] != want { + t.Fatalf("next[0] = %q, want %q", next[0], want) + } +} + +func TestStatusNextSteps_NeedChannel(t *testing.T) { + t.Parallel() + + st := model.NewState() + st.ProviderNode = model.ProviderNode("02deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefde@127.0.0.1:9735") + st.Readiness = model.Readiness{ + LNDReady: true, + ProviderConnected: true, + ChannelReady: false, + LCPDReady: false, + OpenAIReady: false, + } + + next := statusNextSteps(model.NetworkTestnet, st, lncli.WalletUnknown) + if len(next) == 0 { + t.Fatalf("statusNextSteps() returned empty next steps") + } + wantPrefix := "need outbound liquidity; open a channel: lcp-quickstart testnet up --provider " + if len(next[0]) < len(wantPrefix) || next[0][:len(wantPrefix)] != wantPrefix { + t.Fatalf("next[0] = %q, want prefix %q", next[0], wantPrefix) + } + wantSuffix := " --open-channel --channel-sats " + if len(next[0]) < len(wantSuffix) || next[0][len(next[0])-len(wantSuffix):] != wantSuffix { + t.Fatalf("next[0] = %q, want suffix %q", next[0], wantSuffix) + } +} + +func TestNormalizeComponent(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + in string + want string + wantOK bool + }{ + {in: "lnd", want: controller.ComponentLND, wantOK: true}, + {in: "grpcd", want: controller.ComponentLCPDGRPCD, wantOK: true}, + {in: "lcpd-grpcd", want: controller.ComponentLCPDGRPCD, wantOK: true}, + {in: "openai", want: controller.ComponentOpenAIServe, wantOK: true}, + {in: "openai-serve", want: controller.ComponentOpenAIServe, wantOK: true}, + {in: "unknown", want: "", wantOK: false}, + } { + got, ok := normalizeComponent(tc.in) + if ok != tc.wantOK { + t.Fatalf("normalizeComponent(%q) ok=%v, want %v", tc.in, ok, tc.wantOK) + } + if got != tc.want { + t.Fatalf("normalizeComponent(%q)=%q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/apps/lcp-quickstart/internal/controller/controller.go b/apps/lcp-quickstart/internal/controller/controller.go new file mode 100644 index 0000000..0b72999 --- /dev/null +++ b/apps/lcp-quickstart/internal/controller/controller.go @@ -0,0 +1,646 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" + + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/build" + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/lncli" + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/model" + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/openai" + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/proc" + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/state" + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/workspace" +) + +const ( + ComponentLND = "lnd" + ComponentLCPDGRPCD = "lcpd-grpcd" + ComponentOpenAIServe = "openai-serve" +) + +const ( + defaultLNDGRPCAddr = "localhost:10009" + defaultLCPDGRPCAddr = "127.0.0.1:50051" + defaultOpenAIHTTPAddr = "127.0.0.1:8080" + defaultOpenAIAPIKeys = "lcp-dev" + defaultMainnetMaxPrice = uint64(100_000) +) + +const ( + lncliTimeout = 10 * time.Second + openChannelTimeout = 2 * time.Minute + buildTimeout = 2 * time.Minute + healthzTimeout = 3 * time.Second +) + +type Controller struct { + Workspace workspace.Workspace + RepoRoot string + LNCLI lncli.Client +} + +type UpOptions struct { + Network model.Network + + Provider string + + StartLND bool + OpenChannel bool + ChannelSats int64 + + IUnderstandMainnet bool + IUnderstandExposingOpenAI bool + + LCPDGRPCAddr string + + LNDRPCAddr string + LNDTLSCertPath string + LNDAdminMacaroonPath string + + OpenAIHTTPAddr string + OpenAIAPIKeysCSV string + OpenAIMaxPriceMsat uint64 + OpenAIMaxPriceSet bool +} + +func (c Controller) Up(ctx context.Context, opts UpOptions) error { + if opts.Network == model.NetworkMainnet && !opts.IUnderstandMainnet { + return errors.New("mainnet requires --i-understand-mainnet") + } + if strings.TrimSpace(c.RepoRoot) == "" { + return errors.New("repo root not set; run from within the repo or pass --repo-root") + } + if err := c.Workspace.Ensure(); err != nil { + return err + } + + providerNode, providerPeerID, err := c.resolveProvider(opts.Network, opts.Provider) + if err != nil { + return err + } + + _, _ = state.Update(c.Workspace.StatePath, func(st model.State) model.State { + st.EnsureDefaults() + st.Network = opts.Network + st.ProviderNode = providerNode + st.ProviderPeerID = providerPeerID + st.Readiness = model.Readiness{} + for k, v := range st.Components { + v.LastError = "" + st.Components[k] = v + } + return st + }) + + infoJSON, err := c.ensureLNDReady(ctx, opts.Network, opts.StartLND) + if err != nil { + c.recordComponentError(ComponentLND, err.Error()) + walletState := c.LNCLI.DetectWalletState(ctx, opts.Network) + switch walletState { + case lncli.WalletUninitialized: + return fmt.Errorf("lnd wallet is uninitialized; run: %s create", lncliCommandPrefix(opts.Network)) + case lncli.WalletLocked: + return fmt.Errorf("lnd wallet is locked; run: %s unlock", lncliCommandPrefix(opts.Network)) + default: + if strings.Contains(strings.ToLower(err.Error()), "lncli not found") { + return err + } + if !opts.StartLND { + return fmt.Errorf("lnd not ready; start lnd or re-run with --start-lnd: %w", err) + } + return err + } + } + if err := c.LNCLI.ValidateNetwork(opts.Network, infoJSON); err != nil { + return err + } + c.recordReadiness(func(r *model.Readiness) { r.LNDReady = true }) + + ctxConnect, cancelConnect := context.WithTimeout(ctx, lncliTimeout) + defer cancelConnect() + if err := c.LNCLI.ConnectPeer(ctxConnect, opts.Network, providerNode); err != nil { + c.recordComponentError(ComponentLND, err.Error()) + return err + } + c.recordReadiness(func(r *model.Readiness) { r.ProviderConnected = true }) + + ctxLiquidity, cancelLiquidity := context.WithTimeout(ctx, lncliTimeout) + defer cancelLiquidity() + hasLiquidity, err := c.LNCLI.HasOutboundLiquidity(ctxLiquidity, opts.Network, providerPeerID, 1) + if err != nil { + c.recordComponentError(ComponentLND, err.Error()) + return err + } + if !hasLiquidity { + if !opts.OpenChannel { + return errors.New("channel/route required; rerun with --open-channel --channel-sats ") + } + if opts.ChannelSats <= 0 { + return errors.New("--channel-sats is required with --open-channel") + } + ctxChan, cancelChan := context.WithTimeout(ctx, openChannelTimeout) + defer cancelChan() + if err := c.LNCLI.OpenChannel(ctxChan, opts.Network, providerPeerID, opts.ChannelSats); err != nil { + c.recordComponentError(ComponentLND, err.Error()) + return err + } + return errors.New("channel opening initiated; wait for confirmations then rerun `up`") + } + c.recordReadiness(func(r *model.Readiness) { r.ChannelReady = true }) + + lcpdGRPCAddr := defaultIfBlank(opts.LCPDGRPCAddr, defaultLCPDGRPCAddr) + openAIHTTPAddr := defaultIfBlank(opts.OpenAIHTTPAddr, defaultOpenAIHTTPAddr) + openAIAPIKeys := strings.TrimSpace(opts.OpenAIAPIKeysCSV) + + if err := requireLoopbackListenAddr("lcpd-grpc-addr", lcpdGRPCAddr); err != nil { + return err + } + if err := validateOpenAIServeHTTPAddr(openAIHTTPAddr, openAIAPIKeys, opts.IUnderstandExposingOpenAI); err != nil { + return err + } + + openAIMaxPrice := opts.OpenAIMaxPriceMsat + if !opts.OpenAIMaxPriceSet { + if opts.Network == model.NetworkMainnet { + openAIMaxPrice = defaultMainnetMaxPrice + } else { + openAIMaxPrice = 0 + } + } + + lndRPCAddr := defaultIfBlank(opts.LNDRPCAddr, defaultLNDGRPCAddr) + lndTLSCert := expandTilde(defaultIfBlank(opts.LNDTLSCertPath, defaultLndTLSCertPath())) + lndMacaroon := expandTilde(defaultIfBlank(opts.LNDAdminMacaroonPath, defaultLndAdminMacaroonPath(opts.Network))) + + if err := c.ensureLCPDGRPCD(ctx, lcpdGRPCAddr, lndRPCAddr, lndTLSCert, lndMacaroon); err != nil { + c.recordComponentError(ComponentLCPDGRPCD, err.Error()) + return err + } + c.recordReadiness(func(r *model.Readiness) { r.LCPDReady = true }) + + if err := c.ensureOpenAIServe(ctx, openAIHTTPAddr, lcpdGRPCAddr, openAIAPIKeys, providerPeerID, openAIMaxPrice); err != nil { + c.recordComponentError(ComponentOpenAIServe, err.Error()) + return err + } + + ctxHealth, cancelHealth := context.WithTimeout(ctx, healthzTimeout) + defer cancelHealth() + if err := openai.Healthz(ctxHealth, openAIHTTPAddr); err != nil { + c.recordComponentError(ComponentOpenAIServe, err.Error()) + return fmt.Errorf("openai-serve healthz failed: %w", err) + } + c.recordReadiness(func(r *model.Readiness) { r.OpenAIReady = true }) + + return nil +} + +func (c Controller) Down(ctx context.Context, network model.Network) error { + _ = ctx + if err := c.Workspace.Ensure(); err != nil { + return err + } + + var errs []error + + if err := proc.Stop(c.Workspace.PIDPath(ComponentOpenAIServe)); err != nil { + errs = append(errs, err) + } + if err := proc.Stop(c.Workspace.PIDPath(ComponentLCPDGRPCD)); err != nil { + errs = append(errs, err) + } + + st, err := state.Read(c.Workspace.StatePath) + if err == nil && st.Components != nil { + if lnd, ok := st.Components[ComponentLND]; ok && lnd.Managed { + if err := proc.Stop(c.Workspace.PIDPath(ComponentLND)); err != nil { + errs = append(errs, err) + } + } + } + + _, _ = state.Update(c.Workspace.StatePath, func(st model.State) model.State { + st.EnsureDefaults() + st.Network = network + for _, component := range []string{ComponentOpenAIServe, ComponentLCPDGRPCD, ComponentLND} { + cs := st.Components[component] + cs.Running = false + st.Components[component] = cs + } + st.Readiness.OpenAIReady = false + st.Readiness.LCPDReady = false + return st + }) + + return errors.Join(errs...) +} + +func (c Controller) Status(ctx context.Context, network model.Network) (model.State, error) { + if err := c.Workspace.Ensure(); err != nil { + return model.State{}, err + } + + st, err := state.Read(c.Workspace.StatePath) + if err != nil { + return model.State{}, err + } + st.EnsureDefaults() + st.Network = network + + var lndErr error + ctxInfo, cancelInfo := context.WithTimeout(ctx, lncliTimeout) + defer cancelInfo() + infoJSON, err := c.LNCLI.GetInfo(ctxInfo, network) + if err != nil { + lndErr = err + st.Readiness.LNDReady = false + } else if err := c.LNCLI.ValidateNetwork(network, infoJSON); err != nil { + lndErr = err + st.Readiness.LNDReady = false + } else { + st.Readiness.LNDReady = true + } + + if st.Readiness.LNDReady { + if strings.TrimSpace(string(st.ProviderPeerID)) != "" { + ctxPeers, cancelPeers := context.WithTimeout(ctx, lncliTimeout) + connected, err := c.LNCLI.IsPeerConnected(ctxPeers, network, st.ProviderPeerID) + cancelPeers() + if err == nil { + st.Readiness.ProviderConnected = connected + } + + ctxLiquidity, cancelLiquidity := context.WithTimeout(ctx, lncliTimeout) + hasLiquidity, err := c.LNCLI.HasOutboundLiquidity(ctxLiquidity, network, st.ProviderPeerID, 1) + cancelLiquidity() + if err == nil { + st.Readiness.ChannelReady = hasLiquidity + } + } + } + + openAIRunning, openAIPid := proc.IsRunning(c.Workspace.PIDPath(ComponentOpenAIServe)) + lcpdRunning, lcpdPid := proc.IsRunning(c.Workspace.PIDPath(ComponentLCPDGRPCD)) + lndManagedRunning, lndPid := proc.IsRunning(c.Workspace.PIDPath(ComponentLND)) + + st.Components[ComponentOpenAIServe] = model.ComponentState{ + Running: openAIRunning, + PID: openAIPid, + Addr: st.Components[ComponentOpenAIServe].Addr, + Managed: true, + } + st.Components[ComponentLCPDGRPCD] = model.ComponentState{ + Running: lcpdRunning, + PID: lcpdPid, + Addr: st.Components[ComponentLCPDGRPCD].Addr, + Managed: true, + } + + lndState := st.Components[ComponentLND] + switch { + case lndManagedRunning: + lndState.Running = true + lndState.PID = lndPid + lndState.Managed = true + case st.Readiness.LNDReady: + lndState.Running = true + lndState.PID = 0 + lndState.Managed = false + default: + lndState.Running = false + lndState.PID = 0 + lndState.Managed = false + } + st.Components[ComponentLND] = lndState + + if openAIRunning && strings.TrimSpace(st.Components[ComponentOpenAIServe].Addr) != "" { + ctxHealth, cancelHealth := context.WithTimeout(ctx, healthzTimeout) + defer cancelHealth() + if err := openai.Healthz(ctxHealth, st.Components[ComponentOpenAIServe].Addr); err == nil { + st.Readiness.OpenAIReady = true + } + } + st.Readiness.LCPDReady = lcpdRunning + + if lndErr != nil { + st.Components[ComponentLND] = model.ComponentState{ + Running: lndState.Running, + PID: lndState.PID, + Managed: lndState.Managed, + LastError: func() string { + return lndErr.Error() + }(), + } + } + + _, _ = state.Update(c.Workspace.StatePath, func(_ model.State) model.State { return st }) + return st, nil +} + +func (c Controller) Reset(ctx context.Context, network model.Network, force bool) error { + _ = ctx + if !force { + return errors.New("reset is destructive; re-run with --force") + } + _ = c.Down(context.Background(), network) + return removeAllGuarded(c.Workspace.DataDir) +} + +func (c Controller) ensureLNDReady(ctx context.Context, network model.Network, startLND bool) ([]byte, error) { + ctxInfo, cancelInfo := context.WithTimeout(ctx, lncliTimeout) + defer cancelInfo() + info, err := c.LNCLI.GetInfo(ctxInfo, network) + if err == nil { + return info, nil + } + if !startLND { + return nil, err + } + + // Best-effort "managed lnd": start it only if our pid file isn't already running. + if running, pid := proc.IsRunning(c.Workspace.PIDPath(ComponentLND)); running { + _, _ = state.Update(c.Workspace.StatePath, func(st model.State) model.State { + st.EnsureDefaults() + st.Components[ComponentLND] = model.ComponentState{Running: true, PID: pid, Managed: true} + return st + }) + return nil, err + } + + args := []string{"--bitcoin.active"} + if network == model.NetworkTestnet { + args = append(args, "--bitcoin.testnet") + } + + lndCmd := "lnd" + if candidate := filepath.Join(c.Workspace.BinDir, "lnd"); fileExists(candidate) { + lndCmd = candidate + } + + pid, startErr := proc.Start(proc.Spec{ + Name: ComponentLND, + Command: lndCmd, + Args: args, + LogPath: c.Workspace.LogPath(ComponentLND), + PIDPath: c.Workspace.PIDPath(ComponentLND), + }) + if startErr != nil { + return nil, fmt.Errorf("failed to start lnd: %w", startErr) + } + + _, _ = state.Update(c.Workspace.StatePath, func(st model.State) model.State { + st.EnsureDefaults() + st.Components[ComponentLND] = model.ComponentState{Running: true, PID: pid, Managed: true} + return st + }) + + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + ctxTry, cancelTry := context.WithTimeout(ctx, 3*time.Second) + info, err := c.LNCLI.GetInfo(ctxTry, network) + cancelTry() + if err == nil { + return info, nil + } + time.Sleep(250 * time.Millisecond) + } + return nil, fmt.Errorf("lnd did not become ready (see %s)", c.Workspace.LogPath(ComponentLND)) +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func (c Controller) ensureLCPDGRPCD(ctx context.Context, grpcAddr, lndRPCAddr, lndTLSCertPath, lndAdminMacaroonPath string) error { + pidPath := c.Workspace.PIDPath(ComponentLCPDGRPCD) + if running, pid := proc.IsRunning(pidPath); running { + _, _ = state.Update(c.Workspace.StatePath, func(st model.State) model.State { + st.EnsureDefaults() + st.Components[ComponentLCPDGRPCD] = model.ComponentState{ + Running: true, + PID: pid, + Addr: grpcAddr, + Managed: true, + } + return st + }) + return nil + } + + binPath := filepath.Join(c.Workspace.BinDir, ComponentLCPDGRPCD) + ctxBuild, cancelBuild := context.WithTimeout(ctx, buildTimeout) + defer cancelBuild() + if err := build.GoBinary(ctxBuild, filepath.Join(c.RepoRoot, "go-lcpd"), "./tools/lcpd-grpcd", binPath); err != nil { + return err + } + + pid, err := proc.Start(proc.Spec{ + Name: ComponentLCPDGRPCD, + Command: binPath, + Args: []string{ + "-grpc_addr=" + grpcAddr, + "-lnd_rpc_addr=" + lndRPCAddr, + "-lnd_tls_cert_path=" + lndTLSCertPath, + "-lnd_admin_macaroon_path=" + lndAdminMacaroonPath, + }, + LogPath: c.Workspace.LogPath(ComponentLCPDGRPCD), + PIDPath: pidPath, + }) + if err != nil { + return fmt.Errorf("failed to start lcpd-grpcd (see %s): %w", c.Workspace.LogPath(ComponentLCPDGRPCD), err) + } + + _, _ = state.Update(c.Workspace.StatePath, func(st model.State) model.State { + st.EnsureDefaults() + st.Components[ComponentLCPDGRPCD] = model.ComponentState{ + Running: true, + PID: pid, + Addr: grpcAddr, + Managed: true, + } + return st + }) + return nil +} + +func (c Controller) ensureOpenAIServe(ctx context.Context, httpAddr, lcpdGRPCAddr, apiKeys string, peerID model.ProviderPeerID, maxPriceMsat uint64) error { + pidPath := c.Workspace.PIDPath(ComponentOpenAIServe) + if running, pid := proc.IsRunning(pidPath); running { + _, _ = state.Update(c.Workspace.StatePath, func(st model.State) model.State { + st.EnsureDefaults() + st.Components[ComponentOpenAIServe] = model.ComponentState{ + Running: true, + PID: pid, + Addr: httpAddr, + Managed: true, + } + return st + }) + return nil + } + + binPath := filepath.Join(c.Workspace.BinDir, ComponentOpenAIServe) + ctxBuild, cancelBuild := context.WithTimeout(ctx, buildTimeout) + defer cancelBuild() + if err := build.GoBinary(ctxBuild, filepath.Join(c.RepoRoot, "apps", "openai-serve"), "./cmd/openai-serve", binPath); err != nil { + return err + } + + pid, err := proc.Start(proc.Spec{ + Name: ComponentOpenAIServe, + Command: binPath, + Env: []string{ + "OPENAI_SERVE_HTTP_ADDR=" + httpAddr, + "OPENAI_SERVE_LCPD_GRPC_ADDR=" + lcpdGRPCAddr, + "OPENAI_SERVE_API_KEYS=" + apiKeys, + "OPENAI_SERVE_DEFAULT_PEER_ID=" + string(peerID), + "OPENAI_SERVE_MAX_PRICE_MSAT=" + fmt.Sprintf("%d", maxPriceMsat), + }, + LogPath: c.Workspace.LogPath(ComponentOpenAIServe), + PIDPath: pidPath, + }) + if err != nil { + return fmt.Errorf("failed to start openai-serve (see %s): %w", c.Workspace.LogPath(ComponentOpenAIServe), err) + } + + _, _ = state.Update(c.Workspace.StatePath, func(st model.State) model.State { + st.EnsureDefaults() + st.Components[ComponentOpenAIServe] = model.ComponentState{ + Running: true, + PID: pid, + Addr: httpAddr, + Managed: true, + } + return st + }) + return nil +} + +func (c Controller) resolveProvider(network model.Network, raw string) (model.ProviderNode, model.ProviderPeerID, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + if network == model.NetworkMainnet { + raw = string(model.DefaultMainnetProviderNode) + } else { + return "", "", errors.New("testnet requires --provider @") + } + } + return model.ParseProviderNode(raw) +} + +func (c Controller) recordReadiness(mutate func(*model.Readiness)) { + _, _ = state.Update(c.Workspace.StatePath, func(st model.State) model.State { + st.EnsureDefaults() + mutate(&st.Readiness) + return st + }) +} + +func (c Controller) recordComponentError(component, msg string) { + _, _ = state.Update(c.Workspace.StatePath, func(st model.State) model.State { + st.EnsureDefaults() + cs := st.Components[component] + cs.LastError = strings.TrimSpace(msg) + st.Components[component] = cs + return st + }) +} + +func defaultLndTLSCertPath() string { + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + return "~/.lnd/tls.cert" + } + return filepath.Join(home, ".lnd", "tls.cert") +} + +func defaultLndAdminMacaroonPath(network model.Network) string { + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + return "~/.lnd/data/chain/bitcoin/" + string(network) + "/admin.macaroon" + } + return filepath.Join(home, ".lnd", "data", "chain", "bitcoin", string(network), "admin.macaroon") +} + +func defaultIfBlank(s, fallback string) string { + if strings.TrimSpace(s) == "" { + return fallback + } + return strings.TrimSpace(s) +} + +func lncliCommandPrefix(network model.Network) string { + return "lcp-quickstart " + string(network) + " lncli" +} + +func requireLoopbackListenAddr(flagName, addr string) error { + host, _, err := net.SplitHostPort(strings.TrimSpace(addr)) + if err != nil { + return fmt.Errorf("--%s must be host:port (got %q): %w", flagName, addr, err) + } + if !isLoopbackHost(host) { + return fmt.Errorf("--%s must bind to loopback (got %q)", flagName, addr) + } + return nil +} + +func validateOpenAIServeHTTPAddr(addr, apiKeys string, iUnderstandExposingOpenAI bool) error { + host, _, err := net.SplitHostPort(strings.TrimSpace(addr)) + if err != nil { + return fmt.Errorf("--openai-http-addr must be host:port (got %q): %w", addr, err) + } + if isLoopbackHost(host) { + return nil + } + if !iUnderstandExposingOpenAI { + return fmt.Errorf("refusing to bind openai-serve to non-loopback addr %q; pass --i-understand-exposing-openai to override", addr) + } + if strings.TrimSpace(apiKeys) == "" { + return errors.New("refusing to expose openai-serve without API keys; set --openai-api-keys or bind to 127.0.0.1") + } + return nil +} + +func isLoopbackHost(host string) bool { + host = strings.TrimSpace(host) + switch { + case host == "": + return false + case strings.EqualFold(host, "localhost"): + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func removeAllGuarded(path string) error { + cleaned := filepath.Clean(expandTilde(strings.TrimSpace(path))) + if cleaned == "" || cleaned == "." || cleaned == string(filepath.Separator) { + return fmt.Errorf("refusing to remove unsafe path %q", path) + } + return os.RemoveAll(cleaned) +} + +func expandTilde(path string) string { + if path == "" || path[0] != '~' { + return path + } + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + return path + } + if path == "~" { + return home + } + if strings.HasPrefix(path, "~/") { + return filepath.Join(home, strings.TrimPrefix(path, "~/")) + } + return path +} diff --git a/apps/lcp-quickstart/internal/controller/controller_test.go b/apps/lcp-quickstart/internal/controller/controller_test.go new file mode 100644 index 0000000..d1310c1 --- /dev/null +++ b/apps/lcp-quickstart/internal/controller/controller_test.go @@ -0,0 +1,69 @@ +package controller + +import "testing" + +func TestRequireLoopbackListenAddr(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + addr string + wantErr bool + }{ + {name: "ipv4 loopback", addr: "127.0.0.1:1234", wantErr: false}, + {name: "localhost", addr: "localhost:1234", wantErr: false}, + {name: "ipv6 loopback", addr: "[::1]:1234", wantErr: false}, + {name: "unspecified ipv4", addr: "0.0.0.0:1234", wantErr: true}, + {name: "missing host", addr: ":1234", wantErr: true}, + {name: "bad", addr: "not-an-addr", wantErr: true}, + } { + err := requireLoopbackListenAddr("x", tc.addr) + if (err != nil) != tc.wantErr { + t.Fatalf("%s: err=%v, wantErr=%v", tc.name, err, tc.wantErr) + } + } +} + +func TestValidateOpenAIServeHTTPAddr(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + addr string + apiKeys string + iUnderstandExposingOpenAI bool + wantErr bool + }{ + { + name: "loopback no keys ok", + addr: "127.0.0.1:8080", + apiKeys: "", + wantErr: false, + }, + { + name: "non-loopback without consent", + addr: "0.0.0.0:8080", + apiKeys: "k", + wantErr: true, + }, + { + name: "non-loopback with consent but no keys", + addr: "0.0.0.0:8080", + apiKeys: "", + iUnderstandExposingOpenAI: true, + wantErr: true, + }, + { + name: "non-loopback with consent and keys", + addr: "0.0.0.0:8080", + apiKeys: "k", + iUnderstandExposingOpenAI: true, + wantErr: false, + }, + } { + err := validateOpenAIServeHTTPAddr(tc.addr, tc.apiKeys, tc.iUnderstandExposingOpenAI) + if (err != nil) != tc.wantErr { + t.Fatalf("%s: err=%v, wantErr=%v", tc.name, err, tc.wantErr) + } + } +} diff --git a/apps/lcp-quickstart/internal/deps/lnd.go b/apps/lcp-quickstart/internal/deps/lnd.go new file mode 100644 index 0000000..6872b74 --- /dev/null +++ b/apps/lcp-quickstart/internal/deps/lnd.go @@ -0,0 +1,307 @@ +package deps + +import ( + "archive/tar" + "bufio" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/workspace" +) + +const DefaultLNDVersion = "v0.19.3-beta" + +type LND struct { + Version string + LNDPath string + LNCLIPath string +} + +func EnsureLND(ctx context.Context, ws workspace.Workspace, version string) (LND, error) { + version = strings.TrimSpace(version) + if version == "" { + version = DefaultLNDVersion + } + if !strings.HasPrefix(version, "v") { + return LND{}, fmt.Errorf("invalid lnd version %q (expected leading 'v')", version) + } + if err := ws.Ensure(); err != nil { + return LND{}, err + } + + lndPath := filepath.Join(ws.BinDir, "lnd") + lncliPath := filepath.Join(ws.BinDir, "lncli") + if ok, err := binariesMatchVersion(ws.BinDir, version, lndPath, lncliPath); err != nil { + return LND{}, err + } else if ok { + return LND{Version: version, LNDPath: lndPath, LNCLIPath: lncliPath}, nil + } + + assetName, err := lndAssetName(runtime.GOOS, runtime.GOARCH, version) + if err != nil { + return LND{}, err + } + + manifestURL := fmt.Sprintf("https://github.com/lightningnetwork/lnd/releases/download/%s/manifest-%s.txt", version, version) + expectedSHA, err := fetchManifestSHA256(ctx, manifestURL, assetName) + if err != nil { + return LND{}, err + } + + tarballURL := fmt.Sprintf("https://github.com/lightningnetwork/lnd/releases/download/%s/%s", version, assetName) + tarPath, gotSHA, err := downloadToTemp(ctx, tarballURL, ws.DataDir) + if err != nil { + return LND{}, err + } + defer func() { _ = os.Remove(tarPath) }() + + if expectedSHA != "" && !strings.EqualFold(expectedSHA, gotSHA) { + return LND{}, fmt.Errorf("lnd download sha256 mismatch: expected %s, got %s", expectedSHA, gotSHA) + } + + tmpLND, err := extractTarballBinary(tarPath, ws.BinDir, "lnd") + if err != nil { + return LND{}, err + } + defer func() { _ = os.Remove(tmpLND) }() + tmpLNCLI, err := extractTarballBinary(tarPath, ws.BinDir, "lncli") + if err != nil { + return LND{}, err + } + defer func() { _ = os.Remove(tmpLNCLI) }() + + if err := os.Rename(tmpLND, lndPath); err != nil { + return LND{}, err + } + if err := os.Rename(tmpLNCLI, lncliPath); err != nil { + return LND{}, err + } + + if err := os.WriteFile(versionMarkerPath(ws.BinDir), []byte(version+"\n"), 0o600); err != nil { + return LND{}, err + } + + return LND{Version: version, LNDPath: lndPath, LNCLIPath: lncliPath}, nil +} + +func binariesMatchVersion(binDir, version, lndPath, lncliPath string) (bool, error) { + vb, err := os.ReadFile(versionMarkerPath(binDir)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, err + } + if strings.TrimSpace(string(vb)) != version { + return false, nil + } + if !fileExists(lndPath) || !fileExists(lncliPath) { + return false, nil + } + return true, nil +} + +func versionMarkerPath(binDir string) string { + return filepath.Join(binDir, "lnd.version") +} + +func lndAssetName(goos, goarch, version string) (string, error) { + goos = strings.TrimSpace(goos) + goarch = strings.TrimSpace(goarch) + + var arch string + switch goarch { + case "amd64", "arm64", "386": + arch = goarch + default: + return "", fmt.Errorf("unsupported arch %q (expected amd64/arm64/386)", goarch) + } + + switch goos { + case "darwin", "linux", "freebsd", "netbsd", "openbsd": + return fmt.Sprintf("lnd-%s-%s-%s.tar.gz", goos, arch, version), nil + case "windows": + return fmt.Sprintf("lnd-windows-%s-%s.zip", arch, version), nil + default: + return "", fmt.Errorf("unsupported os %q (expected darwin/linux)", goos) + } +} + +func fetchManifestSHA256(ctx context.Context, manifestURL, assetName string) (string, error) { + b, err := httpGet(ctx, manifestURL, 2*time.Minute, 1<<20) + if err != nil { + return "", err + } + entries, err := parseManifest(b) + if err != nil { + return "", err + } + expected, ok := entries[assetName] + if !ok { + return "", fmt.Errorf("lnd manifest missing entry for %s", assetName) + } + if _, err := hex.DecodeString(expected); err != nil || len(expected) != 64 { + return "", fmt.Errorf("invalid sha256 in manifest for %s: %q", assetName, expected) + } + return expected, nil +} + +func parseManifest(contents []byte) (map[string]string, error) { + out := make(map[string]string) + scanner := bufio.NewScanner(bytes.NewReader(contents)) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) != 2 { + continue + } + sha := strings.TrimSpace(fields[0]) + name := strings.TrimSpace(fields[1]) + if sha == "" || name == "" { + continue + } + out[name] = sha + } + if err := scanner.Err(); err != nil { + return nil, err + } + return out, nil +} + +func httpGet(ctx context.Context, url string, timeout time.Duration, maxBytes int64) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + client := &http.Client{Timeout: timeout} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http get %s: unexpected status %s", url, resp.Status) + } + return io.ReadAll(io.LimitReader(resp.Body, maxBytes)) +} + +func downloadToTemp(ctx context.Context, url, dir string) (path string, shaHex string, err error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", "", err + } + tmp, err := os.CreateTemp(dir, ".download.*") + if err != nil { + return "", "", err + } + tmpPath := tmp.Name() + defer func() { + _ = tmp.Close() + if err != nil { + _ = os.Remove(tmpPath) + } + }() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", "", err + } + client := &http.Client{Timeout: 15 * time.Minute} + resp, err := client.Do(req) + if err != nil { + return "", "", err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("http get %s: unexpected status %s", url, resp.Status) + } + + h := sha256.New() + w := io.MultiWriter(tmp, h) + if _, err := io.Copy(w, resp.Body); err != nil { + return "", "", err + } + if err := tmp.Sync(); err != nil { + return "", "", err + } + if err := tmp.Close(); err != nil { + return "", "", err + } + return tmpPath, hex.EncodeToString(h.Sum(nil)), nil +} + +func extractTarballBinary(tarGzPath, outDir, wantBase string) (string, error) { + f, err := os.Open(tarGzPath) + if err != nil { + return "", err + } + defer func() { _ = f.Close() }() + + zr, err := gzip.NewReader(f) + if err != nil { + return "", err + } + defer func() { _ = zr.Close() }() + + tr := tar.NewReader(zr) + for { + h, err := tr.Next() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return "", err + } + if h.Typeflag != tar.TypeReg { + continue + } + if filepath.Base(h.Name) != wantBase { + continue + } + if err := os.MkdirAll(outDir, 0o700); err != nil { + return "", err + } + out, err := os.CreateTemp(outDir, "."+wantBase+".tmp.*") + if err != nil { + return "", err + } + outPath := out.Name() + if err := out.Chmod(0o700); err != nil { + _ = out.Close() + _ = os.Remove(outPath) + return "", err + } + if _, err := io.Copy(out, tr); err != nil { + _ = out.Close() + _ = os.Remove(outPath) + return "", err + } + if err := out.Sync(); err != nil { + _ = out.Close() + _ = os.Remove(outPath) + return "", err + } + if err := out.Close(); err != nil { + _ = os.Remove(outPath) + return "", err + } + return outPath, nil + } + return "", fmt.Errorf("binary %q not found in %s", wantBase, tarGzPath) +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/apps/lcp-quickstart/internal/deps/lnd_test.go b/apps/lcp-quickstart/internal/deps/lnd_test.go new file mode 100644 index 0000000..edf1090 --- /dev/null +++ b/apps/lcp-quickstart/internal/deps/lnd_test.go @@ -0,0 +1,77 @@ +package deps + +import "testing" + +func TestLndAssetName(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + goos string + goarch string + version string + want string + wantErr bool + }{ + { + name: "darwin arm64", + goos: "darwin", + goarch: "arm64", + version: "v0.19.3-beta", + want: "lnd-darwin-arm64-v0.19.3-beta.tar.gz", + }, + { + name: "linux amd64", + goos: "linux", + goarch: "amd64", + version: "v0.19.3-beta", + want: "lnd-linux-amd64-v0.19.3-beta.tar.gz", + }, + { + name: "unsupported arch", + goos: "linux", + goarch: "riscv64", + version: "v0.19.3-beta", + wantErr: true, + }, + { + name: "unsupported os", + goos: "plan9", + goarch: "amd64", + version: "v0.19.3-beta", + wantErr: true, + }, + } { + got, err := lndAssetName(tc.goos, tc.goarch, tc.version) + if (err != nil) != tc.wantErr { + t.Fatalf("%s: err=%v, wantErr=%v", tc.name, err, tc.wantErr) + } + if got != tc.want { + t.Fatalf("%s: asset=%q, want %q", tc.name, got, tc.want) + } + } +} + +func TestParseManifest(t *testing.T) { + t.Parallel() + + contents := []byte("" + + "abc file1\n" + + "def file2\n" + + "bad line\n" + + "\n", + ) + got, err := parseManifest(contents) + if err != nil { + t.Fatalf("parseManifest() error = %v", err) + } + if got["file1"] != "abc" { + t.Fatalf("file1=%q, want %q", got["file1"], "abc") + } + if got["file2"] != "def" { + t.Fatalf("file2=%q, want %q", got["file2"], "def") + } + if _, ok := got["bad"]; ok { + t.Fatalf("unexpected entry for bad") + } +} diff --git a/apps/lcp-quickstart/internal/lncli/lncli.go b/apps/lcp-quickstart/internal/lncli/lncli.go new file mode 100644 index 0000000..c4a2c76 --- /dev/null +++ b/apps/lcp-quickstart/internal/lncli/lncli.go @@ -0,0 +1,198 @@ +package lncli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/model" +) + +type WalletState string + +const ( + WalletUninitialized WalletState = "uninitialized" + WalletLocked WalletState = "locked" + WalletUnlocked WalletState = "unlocked" + WalletUnknown WalletState = "unknown" +) + +type Client struct { + Path string +} + +func DefaultClient() Client { + return Client{Path: "lncli"} +} + +func (c Client) GetInfo(ctx context.Context, network model.Network) ([]byte, error) { + return c.run(ctx, network, "getinfo") +} + +func (c Client) IsPeerConnected(ctx context.Context, network model.Network, peerID model.ProviderPeerID) (bool, error) { + if strings.TrimSpace(string(peerID)) == "" { + return false, errors.New("peer_id is empty") + } + out, err := c.run(ctx, network, "listpeers") + if err != nil { + return false, err + } + type peer struct { + PubKey string `json:"pub_key"` + } + type list struct { + Peers []peer `json:"peers"` + } + var lp list + if err := json.Unmarshal(out, &lp); err != nil { + return false, fmt.Errorf("parse listpeers json: %w", err) + } + for _, p := range lp.Peers { + if strings.TrimSpace(p.PubKey) == string(peerID) { + return true, nil + } + } + return false, nil +} + +func (c Client) ValidateNetwork(network model.Network, getInfoJSON []byte) error { + type chain struct { + Network string `json:"network"` + } + type getInfo struct { + Chains []chain `json:"chains"` + } + var gi getInfo + if err := json.Unmarshal(getInfoJSON, &gi); err != nil { + return fmt.Errorf("parse getinfo json: %w", err) + } + for _, ch := range gi.Chains { + if strings.EqualFold(strings.TrimSpace(ch.Network), string(network)) { + return nil + } + } + return fmt.Errorf("lncli is not targeting %s (getinfo.chains[].network=%v)", network, gi.Chains) +} + +func (c Client) DetectWalletState(ctx context.Context, network model.Network) WalletState { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + _, err := c.GetInfo(ctx, network) + if err == nil { + return WalletUnlocked + } + + msg := strings.ToLower(err.Error()) + switch { + case strings.Contains(msg, "wallet locked"): + return WalletLocked + case strings.Contains(msg, "unlock"): + return WalletLocked + case strings.Contains(msg, "wallet not found"): + return WalletUninitialized + case strings.Contains(msg, "uninitialized"): + return WalletUninitialized + default: + return WalletUnknown + } +} + +func (c Client) ConnectPeer(ctx context.Context, network model.Network, node model.ProviderNode) error { + _, err := c.run(ctx, network, "connect", string(node)) + if err == nil { + return nil + } + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "already connected") { + return nil + } + return err +} + +func (c Client) HasOutboundLiquidity(ctx context.Context, network model.Network, peerID model.ProviderPeerID, minSats int64) (bool, error) { + out, err := c.run(ctx, network, "listchannels") + if err != nil { + return false, err + } + type channel struct { + Active bool `json:"active"` + RemotePubkey string `json:"remote_pubkey"` + LocalBalance string `json:"local_balance"` + } + type list struct { + Channels []channel `json:"channels"` + } + var lc list + if err := json.Unmarshal(out, &lc); err != nil { + return false, fmt.Errorf("parse listchannels json: %w", err) + } + for _, ch := range lc.Channels { + if !ch.Active { + continue + } + if strings.TrimSpace(ch.RemotePubkey) != string(peerID) { + continue + } + localBal, err := strconv.ParseInt(strings.TrimSpace(ch.LocalBalance), 10, 64) + if err != nil { + continue + } + if localBal >= minSats { + return true, nil + } + } + return false, nil +} + +func (c Client) OpenChannel(ctx context.Context, network model.Network, peerID model.ProviderPeerID, localAmtSats int64) error { + if localAmtSats <= 0 { + return fmt.Errorf("local_amt must be > 0, got %d", localAmtSats) + } + _, err := c.run( + ctx, + network, + "openchannel", + "--node_key", string(peerID), + "--local_amt", strconv.FormatInt(localAmtSats, 10), + ) + return err +} + +func (c Client) run(ctx context.Context, network model.Network, args ...string) ([]byte, error) { + bin := strings.TrimSpace(c.Path) + if bin == "" { + bin = "lncli" + } + cmdArgs := make([]string, 0, len(args)+1) + if network == model.NetworkTestnet { + cmdArgs = append(cmdArgs, "--network=testnet") + } + cmdArgs = append(cmdArgs, args...) + + cmd := exec.CommandContext(ctx, bin, cmdArgs...) + var combined bytes.Buffer + cmd.Stdout = &combined + cmd.Stderr = &combined + if err := cmd.Run(); err != nil { + out := strings.TrimSpace(combined.String()) + if out != "" { + return nil, fmt.Errorf("lncli %s failed: %w\n%s", strings.Join(args, " "), err, out) + } + if errors.Is(err, exec.ErrNotFound) { + if strings.Contains(bin, string(filepath.Separator)) { + return nil, fmt.Errorf("lncli not found: %s", bin) + } + return nil, fmt.Errorf("lncli not found in PATH") + } + return nil, fmt.Errorf("lncli %s failed: %w", strings.Join(args, " "), err) + } + return bytes.TrimSpace(combined.Bytes()), nil +} diff --git a/apps/lcp-quickstart/internal/model/network.go b/apps/lcp-quickstart/internal/model/network.go new file mode 100644 index 0000000..ebba79f --- /dev/null +++ b/apps/lcp-quickstart/internal/model/network.go @@ -0,0 +1,25 @@ +package model + +import ( + "fmt" + "strings" +) + +type Network string + +const ( + NetworkTestnet Network = "testnet" + NetworkMainnet Network = "mainnet" +) + +func ParseNetwork(s string) (Network, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case string(NetworkTestnet): + return NetworkTestnet, nil + case string(NetworkMainnet): + return NetworkMainnet, nil + default: + return "", fmt.Errorf("unsupported network %q (expected %q or %q)", s, NetworkTestnet, NetworkMainnet) + } +} + diff --git a/apps/lcp-quickstart/internal/model/provider.go b/apps/lcp-quickstart/internal/model/provider.go new file mode 100644 index 0000000..5224e17 --- /dev/null +++ b/apps/lcp-quickstart/internal/model/provider.go @@ -0,0 +1,58 @@ +package model + +import ( + "encoding/hex" + "errors" + "fmt" + "net" + "strings" +) + +type ProviderNode string +type ProviderPeerID string + +const ( + peerIDHexLen = 66 + peerIDByteLen = peerIDHexLen / 2 +) + +const DefaultMainnetProviderNode ProviderNode = "03737b4a2e44b45f786a18e43c3cf462ab97891e9f8992a0d493394691ac0db983@54.214.32.132:20309" + +func ParseProviderNode(s string) (ProviderNode, ProviderPeerID, error) { + raw := strings.TrimSpace(s) + if raw == "" { + return "", "", errors.New("provider is required") + } + peerID, hostport, ok := strings.Cut(raw, "@") + if !ok { + return "", "", fmt.Errorf("provider must be @, got %q", raw) + } + peerID = strings.TrimSpace(peerID) + hostport = strings.TrimSpace(hostport) + if err := ValidatePeerID(peerID); err != nil { + return "", "", fmt.Errorf("provider pubkey: %w", err) + } + if _, _, err := net.SplitHostPort(hostport); err != nil { + return "", "", fmt.Errorf("provider host:port: %w", err) + } + return ProviderNode(raw), ProviderPeerID(peerID), nil +} + +func ValidatePeerID(peerID string) error { + peerID = strings.TrimSpace(peerID) + if peerID == "" { + return errors.New("peer_id is empty") + } + if len(peerID) != peerIDHexLen { + return fmt.Errorf("peer_id must be %d hex chars (got %d)", peerIDHexLen, len(peerID)) + } + decoded, err := hex.DecodeString(peerID) + if err != nil { + return errors.New("peer_id must be hex") + } + if len(decoded) != peerIDByteLen { + return fmt.Errorf("peer_id must be %d bytes (got %d)", peerIDByteLen, len(decoded)) + } + return nil +} + diff --git a/apps/lcp-quickstart/internal/model/state.go b/apps/lcp-quickstart/internal/model/state.go new file mode 100644 index 0000000..462d41a --- /dev/null +++ b/apps/lcp-quickstart/internal/model/state.go @@ -0,0 +1,56 @@ +package model + +import "time" + +const StateVersion = 1 + +type ComponentState struct { + Running bool `json:"running"` + PID int `json:"pid,omitempty"` + Addr string `json:"addr,omitempty"` + LastError string `json:"last_error,omitempty"` + Managed bool `json:"managed,omitempty"` +} + +type Readiness struct { + LNDReady bool `json:"lnd_ready"` + ProviderConnected bool `json:"provider_connected"` + ChannelReady bool `json:"channel_ready"` + LCPDReady bool `json:"lcpd_ready"` + OpenAIReady bool `json:"openai_ready"` +} + +type State struct { + Version int `json:"version"` + Network Network `json:"network,omitempty"` + + ProviderNode ProviderNode `json:"provider_node,omitempty"` + ProviderPeerID ProviderPeerID `json:"provider_peer_id,omitempty"` + + Readiness Readiness `json:"readiness"` + Components map[string]ComponentState `json:"components"` + + UpdatedAtRFC3339 string `json:"updated_at_rfc3339,omitempty"` +} + +func NewState() State { + return State{ + Version: StateVersion, + Components: make(map[string]ComponentState), + } +} + +func (s *State) EnsureDefaults() { + if s.Version == 0 { + s.Version = StateVersion + } + if s.Components == nil { + s.Components = make(map[string]ComponentState) + } +} + +func (s State) WithUpdatedAt(t time.Time) State { + s.UpdatedAtRFC3339 = t.UTC().Format(time.RFC3339) + return s +} + diff --git a/apps/lcp-quickstart/internal/openai/healthz.go b/apps/lcp-quickstart/internal/openai/healthz.go new file mode 100644 index 0000000..9e26419 --- /dev/null +++ b/apps/lcp-quickstart/internal/openai/healthz.go @@ -0,0 +1,35 @@ +package openai + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" +) + +const maxHealthzBytes = 1024 + +func Healthz(ctx context.Context, httpAddr string) error { + url := "http://" + httpAddr + "/healthz" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("healthz: unexpected status %s", resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxHealthzBytes)) + if err != nil { + return err + } + if strings.TrimSpace(string(body)) != "ok" { + return fmt.Errorf("healthz: unexpected body %q", strings.TrimSpace(string(body))) + } + return nil +} diff --git a/apps/lcp-quickstart/internal/proc/proc.go b/apps/lcp-quickstart/internal/proc/proc.go new file mode 100644 index 0000000..9b9ad47 --- /dev/null +++ b/apps/lcp-quickstart/internal/proc/proc.go @@ -0,0 +1,161 @@ +package proc + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +const ( + defaultStopTimeout = 5 * time.Second + stopPollInterval = 100 * time.Millisecond +) + +type Spec struct { + Name string + + Command string + Args []string + Env []string + Dir string + + LogPath string + PIDPath string +} + +func Start(spec Spec) (int, error) { + if running, pid := IsRunning(spec.PIDPath); running { + return pid, fmt.Errorf("%s is already running (pid %d)", spec.Name, pid) + } + if err := os.MkdirAll(filepath.Dir(spec.LogPath), 0o700); err != nil { + return 0, err + } + if err := os.MkdirAll(filepath.Dir(spec.PIDPath), 0o700); err != nil { + return 0, err + } + + logFile, err := os.OpenFile(spec.LogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return 0, err + } + defer func() { _ = logFile.Close() }() + + devNull, err := os.OpenFile(os.DevNull, os.O_RDONLY, 0o600) + if err != nil { + return 0, err + } + defer func() { _ = devNull.Close() }() + + cmd := exec.Command(spec.Command, spec.Args...) + cmd.Dir = spec.Dir + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.Stdin = devNull + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + if len(spec.Env) > 0 { + cmd.Env = append(os.Environ(), spec.Env...) + } + + if err := cmd.Start(); err != nil { + return 0, err + } + + pid := cmd.Process.Pid + if err := os.WriteFile(spec.PIDPath, []byte(strconv.Itoa(pid)+"\n"), 0o600); err != nil { + _ = cmd.Process.Signal(syscall.SIGTERM) + return 0, err + } + return pid, nil +} + +func Stop(pidPath string) error { + return StopWithTimeout(pidPath, defaultStopTimeout) +} + +func StopWithTimeout(pidPath string, timeout time.Duration) error { + pid, err := ReadPID(pidPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + + if !pidRunning(pid) { + _ = os.Remove(pidPath) + return nil + } + + p, err := os.FindProcess(pid) + if err != nil { + return err + } + + _ = p.Signal(syscall.SIGTERM) + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if !pidRunning(pid) { + _ = os.Remove(pidPath) + return nil + } + time.Sleep(stopPollInterval) + } + + _ = p.Signal(syscall.SIGKILL) + time.Sleep(200 * time.Millisecond) + _ = os.Remove(pidPath) + return nil +} + +func IsRunning(pidPath string) (bool, int) { + pid, err := ReadPID(pidPath) + if err != nil { + return false, 0 + } + if !pidRunning(pid) { + return false, pid + } + return true, pid +} + +func ReadPID(path string) (int, error) { + b, err := os.ReadFile(path) + if err != nil { + return 0, err + } + raw := strings.TrimSpace(string(b)) + if raw == "" { + return 0, fmt.Errorf("pid file %s is empty", path) + } + pid, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("parse pid in %s: %w", path, err) + } + if pid <= 0 { + return 0, fmt.Errorf("invalid pid %d in %s", pid, path) + } + return pid, nil +} + +func pidRunning(pid int) bool { + err := syscall.Kill(pid, 0) + switch { + case err == nil: + return true + case errors.Is(err, syscall.EPERM): + return true + case errors.Is(err, syscall.ESRCH): + return false + default: + return false + } +} + diff --git a/apps/lcp-quickstart/internal/proc/proc_test.go b/apps/lcp-quickstart/internal/proc/proc_test.go new file mode 100644 index 0000000..5271343 --- /dev/null +++ b/apps/lcp-quickstart/internal/proc/proc_test.go @@ -0,0 +1,26 @@ +package proc + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadPID(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "p.pid") + if err := os.WriteFile(path, []byte("123\n"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + pid, err := ReadPID(path) + if err != nil { + t.Fatalf("ReadPID() error = %v", err) + } + if pid != 123 { + t.Fatalf("pid = %d, want %d", pid, 123) + } +} + diff --git a/apps/lcp-quickstart/internal/reporoot/reporoot.go b/apps/lcp-quickstart/internal/reporoot/reporoot.go new file mode 100644 index 0000000..a110b7b --- /dev/null +++ b/apps/lcp-quickstart/internal/reporoot/reporoot.go @@ -0,0 +1,55 @@ +package reporoot + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +func Detect(startDir string) (string, error) { + startDir = filepath.Clean(startDir) + dir := startDir + for { + if isRepoRoot(dir) { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", fmt.Errorf("failed to detect repo root from %s; set --repo-root", startDir) +} + +func isRepoRoot(dir string) bool { + if _, err := os.Stat(filepath.Join(dir, "go-lcpd", "go.mod")); err != nil { + return false + } + if _, err := os.Stat(filepath.Join(dir, "apps", "openai-serve", "go.mod")); err != nil { + return false + } + if _, err := os.Stat(filepath.Join(dir, "apps", "lcp-quickstart", "spec.md")); err != nil { + return false + } + return true +} + +func MustDetectFromCWD() string { + cwd, err := os.Getwd() + if err != nil { + panic(err) + } + root, err := Detect(cwd) + if err != nil { + panic(err) + } + return root +} + +func Exists(path string) bool { + _, err := os.Stat(path) + return err == nil || !errors.Is(err, os.ErrNotExist) +} + diff --git a/apps/lcp-quickstart/internal/state/store.go b/apps/lcp-quickstart/internal/state/store.go new file mode 100644 index 0000000..3caab11 --- /dev/null +++ b/apps/lcp-quickstart/internal/state/store.go @@ -0,0 +1,128 @@ +package state + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/model" +) + +const ( + lockWaitTimeout = 5 * time.Second + lockStaleAfter = 30 * time.Second + lockPollInterval = 50 * time.Millisecond +) + +func Read(path string) (model.State, error) { + b, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return model.NewState(), nil + } + return model.State{}, err + } + var st model.State + if err := json.Unmarshal(b, &st); err != nil { + return model.State{}, fmt.Errorf("parse state json: %w", err) + } + st.EnsureDefaults() + return st, nil +} + +func Write(path string, st model.State) error { + st.EnsureDefaults() + st = st.WithUpdatedAt(time.Now()) + b, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + return atomicWriteFile(path, b, 0o600) +} + +func Update(path string, mutate func(model.State) model.State) (model.State, error) { + lockPath := path + ".lock" + var updated model.State + err := withLock(lockPath, func() error { + st, err := Read(path) + if err != nil { + return err + } + updated = mutate(st) + return Write(path, updated) + }) + if err != nil { + return model.State{}, err + } + return updated, nil +} + +func withLock(lockPath string, fn func() error) error { + deadline := time.Now().Add(lockWaitTimeout) + for { + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err == nil { + _, _ = fmt.Fprintf(f, "pid=%d time=%s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339)) + _ = f.Close() + defer func() { _ = os.Remove(lockPath) }() + return fn() + } + if !errors.Is(err, os.ErrExist) { + return err + } + + info, statErr := os.Stat(lockPath) + if statErr == nil && time.Since(info.ModTime()) > lockStaleAfter { + _ = os.Remove(lockPath) + continue + } + if time.Now().After(deadline) { + return fmt.Errorf("timed out waiting for state lock: %s", lockPath) + } + time.Sleep(lockPollInterval) + } +} + +func atomicWriteFile(path string, contents []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp.*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(contents); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return err + } + + // Best-effort durability: fsync the containing directory. + if dirFile, err := os.Open(dir); err == nil { + _ = dirFile.Sync() + _ = dirFile.Close() + } + return nil +} + diff --git a/apps/lcp-quickstart/internal/state/store_test.go b/apps/lcp-quickstart/internal/state/store_test.go new file mode 100644 index 0000000..23c6100 --- /dev/null +++ b/apps/lcp-quickstart/internal/state/store_test.go @@ -0,0 +1,83 @@ +package state + +import ( + "path/filepath" + "testing" + + "github.com/bruwbird/lcp/apps/lcp-quickstart/internal/model" +) + +func TestRead_MissingReturnsEmptyState(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + + st, err := Read(path) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + if st.Version != model.StateVersion { + t.Fatalf("st.Version = %d, want %d", st.Version, model.StateVersion) + } + if st.Components == nil { + t.Fatal("st.Components is nil") + } +} + +func TestWriteThenRead_RoundTrip(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + + want := model.NewState() + want.Network = model.NetworkTestnet + want.ProviderNode = "aa" + want.Components["openai-serve"] = model.ComponentState{Running: true, PID: 123} + if err := Write(path, want); err != nil { + t.Fatalf("Write() error = %v", err) + } + + got, err := Read(path) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + if got.Network != want.Network { + t.Fatalf("Network = %q, want %q", got.Network, want.Network) + } + if got.Components["openai-serve"].PID != 123 { + t.Fatalf("components[openai-serve].PID = %d, want %d", got.Components["openai-serve"].PID, 123) + } + if got.UpdatedAtRFC3339 == "" { + t.Fatal("UpdatedAtRFC3339 is empty") + } +} + +func TestUpdate_MutatesAndPersists(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + + _, err := Update(path, func(st model.State) model.State { + st.Network = model.NetworkMainnet + st.Components["lcpd-grpcd"] = model.ComponentState{Running: true, PID: 456} + return st + }) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + + got, err := Read(path) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + if got.Network != model.NetworkMainnet { + t.Fatalf("Network = %q, want %q", got.Network, model.NetworkMainnet) + } + if got.Components["lcpd-grpcd"].PID != 456 { + t.Fatalf("components[lcpd-grpcd].PID = %d, want %d", got.Components["lcpd-grpcd"].PID, 456) + } +} + diff --git a/apps/lcp-quickstart/internal/workspace/workspace.go b/apps/lcp-quickstart/internal/workspace/workspace.go new file mode 100644 index 0000000..16d2a53 --- /dev/null +++ b/apps/lcp-quickstart/internal/workspace/workspace.go @@ -0,0 +1,75 @@ +package workspace + +import ( + "os" + "path/filepath" + "strings" +) + +type Workspace struct { + DataDir string + StatePath string + + LogsDir string + PIDsDir string + BinDir string +} + +func Resolve(homeOverride string) Workspace { + homeOverride = strings.TrimSpace(homeOverride) + if homeOverride != "" { + return workspaceAt(expandTilde(homeOverride)) + } + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + return workspaceAt(".lcp-quickstart") + } + return workspaceAt(filepath.Join(home, ".lcp-quickstart")) +} + +func workspaceAt(dir string) Workspace { + return Workspace{ + DataDir: dir, + StatePath: filepath.Join(dir, "state.json"), + LogsDir: filepath.Join(dir, "logs"), + PIDsDir: filepath.Join(dir, "pids"), + BinDir: filepath.Join(dir, "bin"), + } +} + +func (w Workspace) Ensure() error { + if err := os.MkdirAll(w.DataDir, 0o700); err != nil { + return err + } + for _, dir := range []string{w.LogsDir, w.PIDsDir, w.BinDir} { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + } + return nil +} + +func (w Workspace) LogPath(component string) string { + return filepath.Join(w.LogsDir, component+".log") +} + +func (w Workspace) PIDPath(component string) string { + return filepath.Join(w.PIDsDir, component+".pid") +} + +func expandTilde(path string) string { + if path == "" || path[0] != '~' { + return path + } + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + return path + } + if path == "~" { + return home + } + if strings.HasPrefix(path, "~/") { + return filepath.Join(home, strings.TrimPrefix(path, "~/")) + } + return path +} diff --git a/apps/lcp-quickstart/spec.md b/apps/lcp-quickstart/spec.md new file mode 100644 index 0000000..77f4fb9 --- /dev/null +++ b/apps/lcp-quickstart/spec.md @@ -0,0 +1,279 @@ +# lcp-quickstart spec (WYSIWID) — testnet/mainnet + +This document follows the “What You See Is What It Does (WYSIWID)” pattern: +Concepts describe independent capabilities; Synchronizations connect them into end-to-end user-visible flows. + +## Security & Architectural Constraints + +The keywords **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are to be interpreted as described in RFC 2119. + +- The app MUST support only `testnet` and `mainnet` networks. + Rationale: keep the UX focused; `regtest` is intentionally out of scope. +- The app MUST treat mainnet as dangerous by default. + Rationale: mainnet actions can move real funds and cause irreversible loss. +- The app MUST require explicit user consent for mainnet “funds-moving” operations. + Rationale: avoid accidental channel opens or unintended spend enablement. +- The app MUST NOT automatically create or unlock a wallet (testnet or mainnet). + Rationale: unlocking/creation involves secret handling (password/seed) and requires a dedicated UX. +- The app MUST NOT print or persist seed mnemonics. + Rationale: seed phrases are total-funds secrets. +- The app MUST NOT log OpenAI prompts/outputs, API keys, macaroons, or BOLT11 invoices. + Rationale: logs are commonly shared; leaks are catastrophic. +- The app MUST bind local services (`lcpd-grpcd`, `openai-serve`, optional local API for UI) to `127.0.0.1` by default. + Rationale: these processes can spend funds or expose sensitive metadata if reachable remotely. +- The app MUST refuse to bind `lcpd-grpcd` to non-loopback addresses. + Rationale: `lcpd-grpcd` has no built-in auth and can spend sats via the user’s `lnd`. +- The app MUST refuse to bind `openai-serve` to non-loopback addresses unless the user passes `--i-understand-exposing-openai` and configures API keys (`--openai-api-keys` non-empty). + Rationale: exposing the OpenAI-compatible HTTP surface without explicit consent/auth is unsafe. +- The app MAY auto-install `lnd` and `lncli` by downloading official release binaries into the workspace `bin/` directory. + Rationale: reduce onboarding friction and avoid OS-specific package managers. +- If auto-installing `lnd`/`lncli`, the app SHOULD verify the tarball SHA256 using the release `manifest-.txt`. + Rationale: detect corruption and avoid partially downloaded/cached artifacts. +- The app MUST pin `openai-serve` routing to a single Provider peer id (`OPENAI_SERVE_DEFAULT_PEER_ID`) for both testnet and mainnet. + Rationale: prevent accidental routing to an arbitrary connected peer. +- The app SHOULD set a conservative `OPENAI_SERVE_MAX_PRICE_MSAT` default for mainnet. + Rationale: reduce overspend risk from misconfiguration or unexpected quotes. +- The app MUST validate that `lncli` is targeting the requested network (`testnet` or `mainnet`). + Rationale: mixing networks is a common source of confusing failures and accidental mainnet use. +- The app MUST be restartable and idempotent for the “up” flows. + Rationale: users will rerun commands during setup; partial progress must not require manual cleanup. +- The app MUST support a destructive reset that requires `--force`. + Rationale: avoid accidental deletion of app state/logs (and any managed lnd directories). + +## Concepts + +### QuickstartWorkspace + +- Purpose: Provide a stable on-disk “home” for state/logs/pids that both CLI and future UI can consume. +- Domain Model: + - `Workspace: data_dir, state_path, logs_dir, pids_dir` +- Actions: + - `ResolveWorkspace(home_override: string) -> Workspace` + - Side effects: MAY create directories. + - Errors: none (falls back to `~/.lcp-quickstart` on failure to resolve override). + - `LogPath(workspace: Workspace, component: string) -> string` + - `PIDPath(workspace: Workspace, component: string) -> string` + +### QuickstartStateStore + +- Purpose: Persist a machine-readable snapshot of “what’s running” and “what’s ready” for CLI status and future UI. +- Domain Model: + - `Network: "testnet" | "mainnet"` + - `ComponentState: running: bool, pid: int, addr: string, last_error: string` + - `Readiness: lnd_ready: bool, provider_connected: bool, channel_ready: bool, lcpd_ready: bool, openai_ready: bool` + - `State: version, network, provider_node, provider_peer_id, readiness, components: map[string]ComponentState, updated_at_rfc3339` +- Actions: + - `Read(path: string) -> State | (empty State when file missing)` + - Errors: returns an error only on unreadable/invalid JSON. + - `Write(path: string, state: State) -> ok | error` + - Side effects: atomic write (write temp + rename). + - `Update(path: string, mutate: func(State) State) -> State | error` + - Side effects: MUST be serialized (file lock or best-effort lock) to avoid concurrent writes. + +### LNCLI + +- Purpose: Interact with the user’s lnd via `lncli` for the target network (testnet/mainnet). +- Domain Model: + - `Network: "testnet" | "mainnet"` + - `ProviderNode: "@"` + - `ProviderPeerID: ""` + - `WalletState: "uninitialized" | "locked" | "unlocked" | "unknown"` +- Actions: + - `GetInfo(network: Network) -> stdout_json_bytes | error` + - Side effects: runs `lncli` (for testnet, MUST select testnet). + - `ValidateNetwork(network: Network, getinfo_json: bytes) -> ok | error` + - Errors: returns error when `getinfo.chains[].network` is not the requested network. + - `DetectWalletState(network: Network) -> WalletState` + - Behavior: best-effort classification based on `lncli getinfo` success/failure output. + - Side effects: MUST NOT print mnemonics (this action MUST NOT call `lncli create`). + - `ConnectPeer(network: Network, provider: ProviderNode) -> ok | error` + - `HasOutboundLiquidity(network: Network, provider_peer_id: ProviderPeerID, min_sats: int64) -> bool | error` + - Side effects: runs `lncli listchannels` / `lncli channelbalance` and inspects results. + - `OpenChannel(network: Network, provider_peer_id: ProviderPeerID, local_amt_sats: int64) -> ok | error` + - Side effects: runs `lncli openchannel ...`. + - Errors: policy rejection, insufficient on-chain funds, fees, timeouts. + +### ManagedLND + +- Purpose: Optionally start `lnd` as a managed child process (without creating/unlocking wallets). +- Domain Model: + - `LNDRun: network, lnddir, extra_args` +- Actions: + - `Start(run: LNDRun, log_path: string, pid_path: string) -> pid | error` + - Side effects: starts `lnd`, writes pid file, redirects stdout/stderr to log. + - `Stop(pid_path: string) -> ok | error` + - `IsRunning(pid_path: string) -> bool` + +### LCPDGRPCD + +- Purpose: Build and run `lcpd-grpcd` as a background process (Requester mode). +- Domain Model: + - `LCPDRun: grpc_addr, lnd_rpc_addr, lnd_tls_cert_path, lnd_admin_macaroon_path` +- Actions: + - `Build(repo_root: string, out_bin_path: string) -> ok | error` + - Side effects: `go build` or `go install` for `go-lcpd/tools/lcpd-grpcd`. + - `Start(workdir: string, run: LCPDRun, log_path: string, pid_path: string) -> pid | error` + - Side effects: starts process, writes pid file, redirects stdout/stderr to log. + - `Stop(pid_path: string) -> ok | error` + - `IsRunning(pid_path: string) -> bool` + +### OpenAIServeGateway + +- Purpose: Build and run `openai-serve` to provide an OpenAI-compatible HTTP endpoint backed by LCP. +- Domain Model: + - `OpenAIServeRun: http_addr, lcpd_grpc_addr, api_keys_csv, default_peer_id, max_price_msat` +- Actions: + - `Build(repo_root: string, out_bin_path: string) -> ok | error` + - Side effects: `go build` for `apps/openai-serve/cmd/openai-serve`. + - `Start(workdir: string, run: OpenAIServeRun, log_path: string, pid_path: string) -> pid | error` + - Side effects: starts process, writes pid file, redirects stdout/stderr to log. + - `Stop(pid_path: string) -> ok | error` + - `Healthz(http_addr: string) -> ok | error` + - Side effects: HTTP GET `http:///healthz` with timeout. + +## Synchronizations + +### sync cli_testnet_up + +- Summary: Connect to a testnet Provider (`--provider` required), ensure prerequisites, and start `lcpd-grpcd` + `openai-serve`. +- Flow: + 1. When: `lcp-quickstart testnet up --provider @ [--start-lnd] [--open-channel --channel-sats ...]` + 2. Then: + - Resolve `workspace`. + - If `--provider` is missing: exit non-zero with “testnet requires --provider”. + - If `--start-lnd` is set and `LNCLI.GetInfo(testnet)` fails: + - start `lnd` via `ManagedLND.Start(...)` and retry `LNCLI.GetInfo(testnet)`. + - If `LNCLI.DetectWalletState(testnet)` is `uninitialized` or `locked`: + - print the exact next command(s) to run (interactive) and exit non-zero: + - `lcp-quickstart testnet lncli create` (first time) + - `lcp-quickstart testnet lncli unlock` (when locked) + - Validate `LNCLI.ValidateNetwork(testnet, getinfo_json)` succeeds. + - Parse `provider_peer_id` from the `--provider` string. + - `LNCLI.ConnectPeer(testnet, provider)` + - If outbound liquidity is insufficient: + - if `--open-channel` is not set: print “channel/route required” and exit non-zero + - if `--channel-sats` missing: exit non-zero + - else: `LNCLI.OpenChannel(testnet, provider_peer_id, channel_sats)` and instruct the user to wait for confirmations + - Start `lcpd-grpcd` (Requester mode) using default `LCPD_LND_*` paths unless overridden: + - RPC addr default: `localhost:10009` + - TLS cert default: `~/.lnd/tls.cert` + - Admin macaroon default: `~/.lnd/data/chain/bitcoin/testnet/admin.macaroon` + - Start `openai-serve`: + - `default_peer_id := provider_peer_id` + - `api_keys_csv := "lcp-dev"` + - `http_addr := "127.0.0.1:8080"` + - set `max_price_msat` to `0` by default (no cap) unless overridden + - Probe readiness: + - `OpenAIServeGateway.Healthz(http_addr)` + - Persist state via `QuickstartStateStore.Update(...)`. + +### sync cli_testnet_down + +- Summary: Stop only quickstart-managed processes (and managed `lnd` if it was started by `--start-lnd`). +- Flow: + 1. When: `lcp-quickstart testnet down` + 2. Then: + - Stop `openai-serve` (if running) + - Stop `lcpd-grpcd` (if running) + - If state indicates `lnd` is managed: stop it + - Update state to mark components stopped (keep historical info). + +### sync cli_testnet_status + +- Summary: Show testnet status and actionable next steps (unlock wallet, open channel, etc). +- Flow: + 1. When: `lcp-quickstart testnet status` + 2. Then: + - Best-effort `LNCLI.GetInfo(testnet)` (show “locked/uninitialized/not running” hints on failure). + - Read state and show managed process status. + - If `openai-serve` configured: `Healthz`. + +### sync cli_testnet_logs + +- Summary: Tail logs for a chosen component. +- Flow: + 1. When: `lcp-quickstart testnet logs ` + 2. Then: + - Resolve `LogPath(workspace, component)` and `tail -f` it (or print a clear error if missing). + +### sync cli_testnet_reset + +- Summary: Destructively remove the app workspace so `testnet up` starts fresh. +- Flow: + 1. When: `lcp-quickstart testnet reset --force` + 2. Then: + - Run `cli_testnet_down` behavior first (best-effort). + - Delete `workspace.data_dir`. + 3. Error branches: + - If `--force` is missing: exit non-zero with a warning. + +### sync cli_mainnet_up + +- Summary: Connect to a mainnet Provider (default or `--provider`), ensure prerequisites, and start `lcpd-grpcd` + `openai-serve`. +- Flow: + 1. When: `lcp-quickstart mainnet up --i-understand-mainnet [--provider ...] [--start-lnd] [--open-channel --channel-sats ...]` + 2. Then: + - If `--i-understand-mainnet` is missing: exit non-zero with a safety message. + - Resolve `workspace`. + - Ensure `LNCLI.GetInfo(mainnet)` works; if not: + - if `--start-lnd`: start `lnd` via `ManagedLND.Start(...)` and retry + - else: instruct the user to start/unlock `lnd` + - If `LNCLI.DetectWalletState(mainnet)` is `uninitialized` or `locked`: + - print “run `lcp-quickstart mainnet lncli create` / `lcp-quickstart mainnet lncli unlock`” and exit non-zero. + - Validate `LNCLI.ValidateNetwork(mainnet, getinfo_json)` succeeds. + - Determine Provider: + - default: `03737b4a2e44b45f786a18e43c3cf462ab97891e9f8992a0d493394691ac0db983@54.214.32.132:20309` + - override via `--provider` + - parse `provider_peer_id` from the node string + - `LNCLI.ConnectPeer(mainnet, provider)` + - If outbound liquidity is insufficient: + - if `--open-channel` is not set: print “channel/route required” and exit non-zero + - if `--channel-sats` missing: exit non-zero + - else: `LNCLI.OpenChannel(mainnet, provider_peer_id, channel_sats)` and instruct the user to wait for confirmations + - Start `lcpd-grpcd` in Requester mode using default `LCPD_LND_*` paths unless overridden: + - RPC addr default: `localhost:10009` + - TLS cert default: `~/.lnd/tls.cert` + - Admin macaroon default: `~/.lnd/data/chain/bitcoin/mainnet/admin.macaroon` + - Start `openai-serve`: + - set `default_peer_id := provider_peer_id` + - set a conservative `max_price_msat` default (unless overridden) + - Persist state. + +### sync cli_mainnet_down + +- Summary: Stop only quickstart-managed processes (do not stop the user’s mainnet `lnd` by default). +- Flow: + 1. When: `lcp-quickstart mainnet down` + 2. Then: + - Stop `openai-serve` (if running) + - Stop `lcpd-grpcd` (if running) + - Leave `lnd` untouched unless it was started by `--start-lnd` and is tracked as managed. + +### sync cli_mainnet_status + +- Summary: Show mainnet status and actionable next steps (unlock wallet, open channel, etc). +- Flow: + 1. When: `lcp-quickstart mainnet status` + 2. Then: + - Best-effort `LNCLI.GetInfo(mainnet)` (show “locked/uninitialized/not running” hints on failure). + - Read state and show managed process status. + - If `openai-serve` configured: `Healthz`. + +### sync cli_mainnet_logs + +- Summary: Tail logs for a chosen component. +- Flow: + 1. When: `lcp-quickstart mainnet logs ` + 2. Then: + - Resolve `LogPath(workspace, component)` and `tail -f` it (or print a clear error if missing). + +### sync cli_mainnet_reset + +- Summary: Destructively remove the app workspace so `mainnet up` starts fresh. +- Flow: + 1. When: `lcp-quickstart mainnet reset --force` + 2. Then: + - Run `cli_mainnet_down` behavior first (best-effort). + - Delete `workspace.data_dir`. + 3. Error branches: + - If `--force` is missing: exit non-zero with a warning. diff --git a/docs/docs.json b/docs/docs.json index fd4d6e1..78ce1db 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -41,6 +41,7 @@ "group": "Getting Started", "pages": [ "index", + "lcp-quickstart/overview", "go-lcpd/docs/regtest", "go-lcpd/docs/quickstart-mainnet" ] @@ -84,6 +85,7 @@ "group": "はじめに", "pages": [ "index-ja", + "lcp-quickstart/overview-ja", "go-lcpd/docs/regtest-ja", "go-lcpd/docs/quickstart-mainnet-ja" ] diff --git a/docs/go-lcpd/docs/quickstart-mainnet-ja.md b/docs/go-lcpd/docs/quickstart-mainnet-ja.md index e95d84c..dea3669 100644 --- a/docs/go-lcpd/docs/quickstart-mainnet-ja.md +++ b/docs/go-lcpd/docs/quickstart-mainnet-ja.md @@ -1,5 +1,8 @@ # クイックスタート(mainnet) +`openai-serve`(OpenAI互換HTTP)まで含めて “ガイド付きで一式起動” したい場合は、 +[lcp-quickstart](/lcp-quickstart/overview-ja) を参照してください。このページは手動の `go-lcpd` 手順です。 + ## 安全性と運用上の注意(mainnet) - このプロジェクトは未監査です。mainnet で送金 / チャネル開設 / 請求書支払いを行うと、資金を失う可能性があります。 diff --git a/docs/go-lcpd/docs/quickstart-mainnet.md b/docs/go-lcpd/docs/quickstart-mainnet.md index 3f88dc5..65c19bd 100644 --- a/docs/go-lcpd/docs/quickstart-mainnet.md +++ b/docs/go-lcpd/docs/quickstart-mainnet.md @@ -1,5 +1,8 @@ # Quickstart (mainnet) +If you want a guided CLI that also brings up `openai-serve` (OpenAI-compatible HTTP), see +[lcp-quickstart](/lcp-quickstart/overview). This page documents the manual `go-lcpd` workflow. + ## Safety & operational constraints (mainnet) - This project is unaudited. Sending funds / opening channels / paying invoices on mainnet can lead to loss of funds. diff --git a/docs/index-ja.mdx b/docs/index-ja.mdx index f429876..a637968 100644 --- a/docs/index-ja.mdx +++ b/docs/index-ja.mdx @@ -8,9 +8,10 @@ LCP (Lightning Compute Protocol) は、Lightning 上で「小さな計算ジョ ## まずはここから -- メインネット: [Quickstart (mainnet)](go-lcpd/docs/quickstart-mainnet-ja) -- regtest(最初におすすめ): [regtest](go-lcpd/docs/regtest-ja) -- 設定: [configuration](go-lcpd/docs/configuration-ja) -- CLI リファレンス: [cli](go-lcpd/docs/cli-ja) -- OpenAI互換ゲートウェイ: [openai-serve](openai-serve/overview-ja) -- プロトコル仕様: [protocol](protocol/protocol-ja) +- Quickstart CLI(testnet/mainnet): [lcp-quickstart](/lcp-quickstart/overview-ja) +- regtest(最初におすすめ): [regtest](/go-lcpd/docs/regtest-ja) +- 手動 mainnet 手順: [go-lcpd quickstart (mainnet)](/go-lcpd/docs/quickstart-mainnet-ja) +- 設定: [configuration](/go-lcpd/docs/configuration-ja) +- CLI リファレンス: [cli](/go-lcpd/docs/cli-ja) +- OpenAI互換ゲートウェイ: [openai-serve](/openai-serve/overview-ja) +- プロトコル仕様: [protocol](/protocol/protocol-ja) diff --git a/docs/index.mdx b/docs/index.mdx index f18c18e..890a27e 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -8,9 +8,10 @@ It uses BOLT #1 custom messages on a direct Lightning peer connection. ## Start here -- Mainnet quickstart: [Quickstart (mainnet)](go-lcpd/docs/quickstart-mainnet) -- regtest walkthrough (recommended first): [regtest](go-lcpd/docs/regtest) -- Configuration: [configuration](go-lcpd/docs/configuration) -- CLI reference: [cli](go-lcpd/docs/cli) -- OpenAI-compatible gateway: [openai-serve](openai-serve/overview) -- Protocol spec: [protocol](protocol/protocol) +- Quickstart CLI (testnet/mainnet): [lcp-quickstart](/lcp-quickstart/overview) +- regtest walkthrough (recommended first): [regtest](/go-lcpd/docs/regtest) +- Manual mainnet walkthrough: [go-lcpd quickstart (mainnet)](/go-lcpd/docs/quickstart-mainnet) +- Configuration: [configuration](/go-lcpd/docs/configuration) +- CLI reference: [cli](/go-lcpd/docs/cli) +- OpenAI-compatible gateway: [openai-serve](/openai-serve/overview) +- Protocol spec: [protocol](/protocol/protocol) diff --git a/docs/lcp-quickstart/overview-ja.mdx b/docs/lcp-quickstart/overview-ja.mdx new file mode 100644 index 0000000..af7dc26 --- /dev/null +++ b/docs/lcp-quickstart/overview-ja.mdx @@ -0,0 +1,128 @@ +--- +title: lcp-quickstart (CLI) +description: testnet/mainnet 向けに、lnd + lcpd-grpcd + openai-serve を段階的に起動するガイド付き CLI。 +--- + +`lcp-quickstart` は、ローカルで “Requester スタック” を立ち上げるための小さな CLI です: + +- `lnd`(ユーザー管理。必要ならツールが起動を試みる) +- `lcpd-grpcd`(Requester) +- `openai-serve`(OpenAI 互換 HTTP ゲートウェイ) + +観測できるゴールは `curl http://127.0.0.1:8080/healthz` が `ok` を返すことです。 + +## 安全上の注意 + +- mainnet は実資金が動きます。mainnet の `up` は明示同意が必須です: `--i-understand-mainnet` +- wallet の作成/アンロックは自動化しません(seed 安全のため)。必要なら次を実行します: + - `lcp-quickstart lncli create` + - `lcp-quickstart lncli unlock` +- `lcpd-grpcd` は loopback(`127.0.0.1` / `localhost`)への bind のみ許可します。 +- `openai-serve` は既定で loopback bind です。非 loopback に bind する場合は + `--i-understand-exposing-openai` と `--openai-api-keys`(非空)が必須です。 + +## インストール + +repo root から: + +```sh +cd apps/lcp-quickstart +go install ./cmd/lcp-quickstart +``` + +`lcp-quickstart` は必要に応じて、このリポジトリ内の `lcpd-grpcd` と `openai-serve` をビルドします。 + +また、`lnd` / `lncli` が未導入の場合は `~/.lcp-quickstart/bin/` へ自動インストール(ダウンロード)できます。 + +repo root の自動検出に失敗する場合は、repo 内から実行するか `--repo-root ` を指定してください。 + +## 起動(testnet) + +testnet は Provider 指定が必須です: + +```sh +lcp-quickstart testnet up --provider "@" +``` + +`lnd` が起動していない場合は、best-effort で起動を試せます: + +```sh +lcp-quickstart testnet up --provider "@" --start-lnd +``` + +“channel/route required” が出た場合は outbound liquidity が不足しています。明示的に opt-in した場合のみ、 +`lncli openchannel` を試みます: + +```sh +lcp-quickstart testnet up \ + --provider "@" \ + --open-channel --channel-sats +``` + +## 起動(mainnet) + +mainnet は明示的な opt-in が必須です: + +```sh +lcp-quickstart mainnet up --i-understand-mainnet +``` + +Provider は `--provider "@"` で上書きできます。未指定の場合は次を使用します: + +```text +03737b4a2e44b45f786a18e43c3cf462ab97891e9f8992a0d493394691ac0db983@54.214.32.132:20309 +``` + +mainnet では `openai-serve` に保守的な価格上限(`OPENAI_SERVE_MAX_PRICE_MSAT`)を既定で設定します。 +必要なら `--openai-max-price-msat` で上書きしてください。 + +## 動作確認(HTTP) + +ヘルスチェック: + +```sh +curl -sS http://127.0.0.1:8080/healthz +``` + +期待する出力: + +```text +ok +``` + +Chat completions(既定 API key は `lcp-dev`): + +```sh +curl -sS http://127.0.0.1:8080/v1/chat/completions \ + -H 'content-type: application/json' \ + -H 'authorization: Bearer lcp-dev' \ + -d '{"model":"gpt-5.2","messages":[{"role":"user","content":"Say hello."}]}' +``` + +## 運用コマンド + +```sh +lcp-quickstart status +lcp-quickstart lncli +lcp-quickstart logs +lcp-quickstart down +lcp-quickstart reset --force +``` + +`status` は “次にすべきこと”(wallet unlock、Provider 接続、チャネル開設など)を表示します。 + +## Workspace レイアウト + +既定では `~/.lcp-quickstart/` に出力します: + +- `state.json`: 状態スナップショット(機械可読) +- `logs/.log`: ログ +- `pids/.pid`: PID 管理(ツールが管理するプロセス用) +- `bin/`: ビルド済みバイナリ(`lcpd-grpcd`, `openai-serve`) + +`--home ` で上書きできます。 + +## 関連ドキュメント + +- 手動 mainnet 手順: [go-lcpd quickstart (mainnet)](/go-lcpd/docs/quickstart-mainnet-ja) +- OpenAI ゲートウェイ: [openai-serve overview](/openai-serve/overview-ja) / [configuration](/openai-serve/configuration-ja) diff --git a/docs/lcp-quickstart/overview.mdx b/docs/lcp-quickstart/overview.mdx new file mode 100644 index 0000000..dbd0b63 --- /dev/null +++ b/docs/lcp-quickstart/overview.mdx @@ -0,0 +1,127 @@ +--- +title: lcp-quickstart (CLI) +description: Guided setup for testnet/mainnet to run lcpd-grpcd + openai-serve against your lnd. +--- + +`lcp-quickstart` is a small CLI that helps you bring up a local “Requester stack”: + +- `lnd` (yours; optionally started by the tool) +- `lcpd-grpcd` (Requester) +- `openai-serve` (OpenAI-compatible HTTP gateway) + +The goal is an observable outcome: `curl http://127.0.0.1:8080/healthz` returns `ok`. + +## Safety notes + +- Mainnet moves real funds. Mainnet “up” requires explicit consent: `--i-understand-mainnet`. +- Wallet creation/unlock is **not** automated. If needed, run: + - `lcp-quickstart lncli create` + - `lcp-quickstart lncli unlock` +- `lcpd-grpcd` is forced to bind loopback only (`127.0.0.1` / `localhost`). +- `openai-serve` binds loopback by default. If you bind it to a non-loopback address, you must pass + `--i-understand-exposing-openai` and set `--openai-api-keys` (non-empty). + +## Install + +From the repository root: + +```sh +cd apps/lcp-quickstart +go install ./cmd/lcp-quickstart +``` + +`lcp-quickstart` builds `lcpd-grpcd` and `openai-serve` from this repository on demand. + +It can also auto-install `lnd` and `lncli` (downloaded into `~/.lcp-quickstart/bin/`) if they are missing. + +If repo-root auto-detection fails, run it from within the repo or pass `--repo-root `. + +## Run (testnet) + +Testnet requires an explicit Provider: + +```sh +lcp-quickstart testnet up --provider "@" +``` + +If `lnd` is not running, you can try a best-effort start: + +```sh +lcp-quickstart testnet up --provider "@" --start-lnd +``` + +If you see a “channel/route required” error, you need outbound liquidity. You can opt into an `lncli openchannel` attempt: + +```sh +lcp-quickstart testnet up \ + --provider "@" \ + --open-channel --channel-sats +``` + +## Run (mainnet) + +Mainnet requires explicit opt-in: + +```sh +lcp-quickstart mainnet up --i-understand-mainnet +``` + +You can override the Provider with `--provider "@"`. If omitted, it uses: + +```text +03737b4a2e44b45f786a18e43c3cf462ab97891e9f8992a0d493394691ac0db983@54.214.32.132:20309 +``` + +Mainnet also sets a conservative default price cap for `openai-serve` (`OPENAI_SERVE_MAX_PRICE_MSAT`). Override via +`--openai-max-price-msat`. + +## Verify (HTTP) + +Health check: + +```sh +curl -sS http://127.0.0.1:8080/healthz +``` + +Expected output: + +```text +ok +``` + +Chat completions (default API key is `lcp-dev`): + +```sh +curl -sS http://127.0.0.1:8080/v1/chat/completions \ + -H 'content-type: application/json' \ + -H 'authorization: Bearer lcp-dev' \ + -d '{"model":"gpt-5.2","messages":[{"role":"user","content":"Say hello."}]}' +``` + +## Operations + +```sh +lcp-quickstart status +lcp-quickstart lncli +lcp-quickstart logs +lcp-quickstart down +lcp-quickstart reset --force +``` + +`status` prints “next steps” guidance (unlock wallet, connect Provider, open a channel, etc). + +## Workspace layout + +By default, the tool writes to `~/.lcp-quickstart/`: + +- `state.json`: machine-readable state snapshot +- `logs/.log`: logs +- `pids/.pid`: PID tracking (for managed processes) +- `bin/`: built binaries (`lcpd-grpcd`, `openai-serve`) + +Override with `--home `. + +## Related docs + +- Manual mainnet walkthrough: [go-lcpd quickstart (mainnet)](/go-lcpd/docs/quickstart-mainnet) +- OpenAI gateway behavior/config: [openai-serve overview](/openai-serve/overview) / [configuration](/openai-serve/configuration) diff --git a/plans/lcp-quickstart-cli.md b/plans/lcp-quickstart-cli.md new file mode 100644 index 0000000..a420874 --- /dev/null +++ b/plans/lcp-quickstart-cli.md @@ -0,0 +1,278 @@ +# lcp-quickstart(app): lnd起動 → チャネル開設 → openai-serve起動(CLIから開始、将来UI対応、testnet/mainnet) + +このExecPlanは生きたドキュメントです。実装が進むたびに、`Progress` / `Surprises & Discoveries` / `Decision Log` / `Outcomes & Retrospective` を更新し続けてください。 + +本リポジトリのExecPlan標準は `/.codex/skills/lcp-execplan/references/PLANS.md` にあります。本書はそれに従って維持します。 + +## Purpose / Big Picture + +開発者が「手元のMac/Linuxで、Lightning(lnd)+ LCP + OpenAI互換HTTPを最短で動かす」ための“ワンコマンド体験”を提供します。最初はCLIツールとして実装し、将来は同じ“中核ロジック”を使ってGUI(デスクトップ/ローカルWeb UI)を載せられる構造にします。 + +この変更の後、ユーザーは以下を同じアプリで行えます: + +- `testnet`: `lcp-quickstart testnet up --provider @` で、lndの状態確認 → Provider接続 →(必要なら)チャネル開設案内 → `lcpd-grpcd` 起動 → `openai-serve` 起動までを“一連の体験”として案内/自動化する。 +- `mainnet`: `lcp-quickstart mainnet up --i-understand-mainnet` で同様にセットアップし、OpenAI互換HTTPエンドポイントをローカルに立ち上げる(危険操作は明示的な許可が必要)。 + +動作確認は `curl http://127.0.0.1:8080/healthz` と `curl http://127.0.0.1:8080/v1/chat/completions ...` で行い、「OpenAI互換HTTPが返る」ことを観測できます。 + +## Progress + +- [x] (2026-01-06 05:14Z) 既存の起動手順(openai-serve / quickstart mainnet)を調査し、初期案を作成した。 +- [x] (2026-01-06 06:34Z) 独立app化(`apps/lcp-quickstart/`)と将来UI前提(状態管理/イベント)を設計に反映した。 +- [x] (2026-01-06 07:21Z) WYSIWID-style spec(Concepts + Synchronizations)を追加した(`apps/lcp-quickstart/spec.md`)。 +- [x] (2026-01-06 07:30Z) スコープ変更: regtestを廃止し、testnet/mainnetのみに再設計した(本ExecPlanとspecを更新)。 +- [x] (2026-01-07 03:28Z) `apps/lcp-quickstart/`(新規Go module)にCLI + 中核ロジック + 状態管理の骨格を実装した(workspace/state/proc/lncli/controller、`up/down/status/logs/reset` の基本動線)。 +- [x] (2026-01-07 04:40Z) `testnet up/status/down/logs/reset` を実装し、最小のセットアップ体験を成立させた(statusの次アクション提示、provider/channelの再判定、logsのコンポーネント検証と未生成ログの明確なエラーなど)。※ 実環境での手順検証は継続。 +- [x] (2026-01-07 04:55Z) `mainnet up/status/down/logs/reset` を実装し、安全優先で段階的にセットアップできるようにした(`--i-understand-mainnet` 同意ゲート、mainnet Provider既定、`OPENAI_SERVE_MAX_PRICE_MSAT` の保守的デフォルト、非loopback bindの安全ガードなど)。※ 実環境での手順検証は継続。 +- [x] (2026-01-07 04:55Z) 最小ドキュメント(README)を追加し、初心者が同じ結果を再現できるようにした(`apps/lcp-quickstart/README.md`)。 +- [x] (2026-01-07 05:30Z) `lnd` / `lncli` の自動インストール(workspace `bin/` へのダウンロード)と `lcp-quickstart lncli ...` ラッパを追加し、セットアップの前提依存を削減した。 + +## Surprises & Discoveries + +- Observation: `openai-serve` は Lightning に直接接続せず、`lcpd-grpcd` gRPC にフォワードするだけのステートレスHTTPゲートウェイである。 + Evidence: `apps/openai-serve/README.md` +- Observation: `go-lcpd/internal/...`(例: `go-lcpd/internal/lnd/lnrpc`)はGoの `internal/` 制約で、`apps/` の別モジュールから直接importできない。 + Evidence: Go `internal` directory rules(構造上の制約) +- Observation: `lncli create` / `lncli unlock` は対話的になりやすく、`create` は seed mnemonic を表示する。 + Evidence: `docs/go-lcpd/docs/quickstart-mainnet.md` +- Observation: このrepo内には “testnet Provider node” の既定値が見当たらない。 + Evidence: `docs/go-lcpd/docs/quickstart-mainnet.md` はmainnetのみ +- Observation: `lcpd-grpcd` の計算バックエンドは既定で `disabled` になっており(`LCPD_BACKEND` 未設定でもOK)、Requester用途では追加のAPIキー設定が不要。 + Evidence: `go-lcpd/tools/lcpd-grpcd/main.go` の `computeBackendFromEnvConfig` + +## Decision Log + +- Decision: 実装は「独立したapp」として `apps/lcp-quickstart/` に置く(go-lcpdのtools配下には置かない)。 + Rationale: “ユーザーが使うアプリ”として責務を分離し、将来UI(別entrypoint/別プロセス)を足しやすくするため。 + Date/Author: 2026-01-06 / Codex +- Decision: 将来UIのために、CLIは薄くし、“手順の状態機械”と“状態ファイル(JSON)”を中核にする。 + Rationale: UIは「進捗」「次に必要な操作」を表示したい。print中心設計だと再利用しづらい。 + Date/Author: 2026-01-06 / Codex +- Decision: lnd操作は当面 `lncli` ベースにする。 + Rationale: `apps/` から `go-lcpd/internal/...` を直接importできない。`lncli` はユーザー環境の認証/設定に追従しやすい。 + Date/Author: 2026-01-06 / Codex +- Decision: mainnetの危険操作(チャネル開設など)は明示フラグ(`--i-understand-mainnet` 等)必須にする。 + Rationale: 誤操作による資金損失を防ぐ。 + Date/Author: 2026-01-06 / Codex +- Decision: testnetは `--provider` を必須にする(既定Providerは持たない)。 + Rationale: repo内に既定値がないため。曖昧な推測で接続先を決めない。 + Date/Author: 2026-01-06 / Codex +- Decision: `openai-serve` は常に `OPENAI_SERVE_DEFAULT_PEER_ID` を固定する。 + Rationale: 接続済みの別ピアに誤ルーティングして課金/情報漏えいする事故を防ぐ。 + Date/Author: 2026-01-06 / Codex +- Decision: `lcp-quickstart` は repo root を cwd から上方向探索で自動検出し、必要なら `--repo-root` で上書きできるようにする。 + Rationale: `go install` 後にどのディレクトリから実行しても、`go-lcpd/` や `apps/openai-serve/` をビルドできるようにするため。 + Date/Author: 2026-01-07 / Codex +- Decision: `lcpd-grpcd` は loopback にのみ bind し、`openai-serve` の非loopback bind は明示フラグ + API key 設定がある場合のみ許可する。 + Rationale: `lcpd-grpcd` は認証がなく資金操作に直結しうる。`openai-serve` も露出時は誤操作/漏えいの危険が高いため、明示同意と最低限の認証を必須にする。 + Date/Author: 2026-01-07 / Codex +- Decision: `apps/lcp-quickstart/README.md` を “最小の再現手順” として整備する。 + Rationale: `go install` 後に初心者が迷わず(testnet/mainnetの安全差分を理解しつつ)同じ結果を再現できるようにするため。 + Date/Author: 2026-01-07 / Codex +- Decision: `lnd` / `lncli` は OS パッケージマネージャに依存せず、workspace の `bin/` へ公式リリースをダウンロードして使用できるようにする(`manifest-.txt` の SHA256 で検証する)。 + Rationale: 環境差分(brew/apt 等)と “未導入” によるつまずきを減らし、最小のセットアップ体験を成立させるため。 + Date/Author: 2026-01-07 / Codex + +## Outcomes & Retrospective + +(実装後に記入) + +## Context and Orientation + +### 用語(このExecPlan内の意味) + +- `testnet`: Bitcoin/Lightningのテストネットです。資産価値はありませんが、wallet/seed/運用は本番同様に扱う必要があります。 +- `mainnet`: 実在のBTC/Lightningを使うネットワークです。オンチェーン/Lightningで実際に資金が動きます。 +- `lnd` / `lncli`: Lightning Network Daemon とそのCLIです。このリポジトリの Lightning 連携は `lnd` に依存します。 +- `チャネル(channel)`: Lightningの支払いに必要な“入出力容量”を作るためのオンチェーン取引です。testnet/mainnetではブロック確認が必要です。 +- `Provider` / `Requester`: + - Provider: 計算(LLM等)を提供し、見積(quote)と請求書(invoice)を返し、支払い後に結果を返す側。 + - Requester: Providerへ依頼し、invoiceを支払い、結果を受け取る側。 +- `lcpd-grpcd`: LCPのgRPCデーモン(go-lcpdのツール)です。Requesterとして動かし、Lightning peer接続の上でLCPメッセージを交換し、支払いを行います。 +- `openai-serve`: OpenAI互換HTTPサーバです。HTTPリクエストを `lcpd-grpcd` に転送し、LCP Providerから得たレスポンスをそのまま返します。 + +### 既存の関連ファイル/コンポーネント + +- `apps/openai-serve/cmd/openai-serve`: OpenAI互換HTTPサーバ本体(Go)。 +- `docs/go-lcpd/docs/quickstart-mainnet.md`: mainnet向けの手動quickstart(既定Provider nodeがここにある)。 + +### UXゴール(このツールが“隠す/短縮する”べき複雑さ) + +現状の手動手順では、以下が初心者にとって重い: + +- `lnd` の起動と待機 +- walletの作成/アンロック(対話的) +- Providerへの接続 +- チャネル開設と確認待ち +- `lcpd-grpcd` / `openai-serve` の起動と環境変数設定 + +本ExecPlanでは「柔軟性よりも簡潔さ」を優先し、まずは `testnet` と `mainnet` の“成功しやすい最短パス”を提供します。 + +## Plan of Work + +### 1) 新app `apps/lcp-quickstart` を追加する(CLIが最初のフロントエンド) + +新規ディレクトリ(MVP): + +- `apps/lcp-quickstart/go.mod`(独立Go module) +- `apps/lcp-quickstart/cmd/lcp-quickstart/main.go`(CLI entrypoint) +- `apps/lcp-quickstart/spec.md`(WYSIWID spec: Concepts + Synchronizations。既に追加済み) + +将来UIを見据えた内部構造(MVPから用意): + +- `apps/lcp-quickstart/internal/controller/...`(“手順の状態機械”。CLI/UIから再利用) +- `apps/lcp-quickstart/internal/state/...`(状態ファイル `~/.lcp-quickstart/state.json`) +- `apps/lcp-quickstart/internal/proc/...`(外部プロセス起動/停止、ログ/ pid管理) + +### 2) コマンド設計(MVP) + +最低限のコマンドセット: + +- `lcp-quickstart testnet up --provider @` +- `lcp-quickstart testnet status` +- `lcp-quickstart testnet logs ` +- `lcp-quickstart testnet down` +- `lcp-quickstart testnet reset --force` + +- `lcp-quickstart mainnet up --i-understand-mainnet` +- `lcp-quickstart mainnet status` +- `lcp-quickstart mainnet logs ` +- `lcp-quickstart mainnet down` +- `lcp-quickstart mainnet reset --force` + +共通オプション(MVP): + +- `--provider`: 接続先Provider(`@`)。mainnetは未指定なら既定値を使う。 +- `--start-lnd`: `lncli getinfo` が通らない時に限り、`lnd` を子プロセスとして起動する(任意)。 +- `--open-channel`: outbound liquidity が足りない場合に、`lncli openchannel` まで行う(任意)。 +- `--channel-sats`: `--open-channel` で開設するチャネル金額(sats)。未指定ならエラー(誤操作防止)。 + +mainnet専用の安全フラグ: + +- `--i-understand-mainnet`: mainnetで資金が動くセットアップを許可する(必須)。 + +### 3) 状態管理(UI前提の“最小”) + +このappは「今どこまで終わったか」を次で表現できるようにします: + +- data dir: `~/.lcp-quickstart/` +- 状態ファイル: `~/.lcp-quickstart/state.json` +- ログ: `~/.lcp-quickstart/logs/.log` +- PID: `~/.lcp-quickstart/pids/.pid` + +### 4) `testnet up` の処理フロー(段階的) + +`testnet up` は次の順で処理します。途中で失敗した場合は「次に何をすべきか」を明確に表示し、再実行で復旧できるようにします。 + +1. `lncli --network=testnet getinfo` を試す。 + - 失敗し、かつ `--start-lnd` が指定されていれば `lnd` を起動して再試行する。 +2. walletが `uninitialized/locked` の場合は、次を案内して終了(自動化しない): + - 初回: `lncli --network=testnet create` + - ロック時: `lncli --network=testnet unlock` +3. Providerへの接続: + - `--provider` が必須。 + - `lncli --network=testnet connect @` +4. 支払い可能性(チャネル/ルート)確認: + - 足りない場合は、チャネル開設が必要であることを表示。 + - `--open-channel --channel-sats ` が指定されている場合のみ `lncli openchannel` を実行。 +5. `lcpd-grpcd`(Requester)を起動: + - `LCPD_LND_RPC_ADDR` / `LCPD_LND_TLS_CERT_PATH` / `LCPD_LND_ADMIN_MACAROON_PATH` は “標準のデフォルト” を採用しつつ、必要ならフラグで上書きできるようにする。 + - rpc addr: `localhost:10009` + - tls cert: `~/.lnd/tls.cert` + - admin macaroon: `~/.lnd/data/chain/bitcoin/testnet/admin.macaroon` +6. `openai-serve` を起動: + - `OPENAI_SERVE_DEFAULT_PEER_ID` を `--provider` の pubkey に固定する。 + - `OPENAI_SERVE_API_KEYS=lcp-dev` をデフォルトにする。 +7. 使い方(base_url / api key / curl例 / 停止コマンド)を表示する。 + +### 5) `mainnet up` の処理フロー(安全優先・段階的) + +mainnetは資金が動くため、必ず `--i-understand-mainnet` を要求します。フローは testnet と同様ですが、以下を追加します: + +- Providerは未指定なら既定値を使う(`docs/go-lcpd/docs/quickstart-mainnet.md` の node)。 +- `openai-serve` に `OPENAI_SERVE_MAX_PRICE_MSAT` の保守的デフォルトを設定する(ユーザーが上げられる)。 + +### 6) `status/down/logs/reset` の設計(両network共通) + +- `status`: “起動済み/未起動” と “次にやるべきこと” を表示する。`curl /healthz` も成功/失敗を示す。 +- `logs`: 対象コンポーネントのログを `tail -f` 相当で追跡する。 +- `down`: 起動した順と逆順で停止する(lndは「自分で起動した場合のみ」止める)。 +- `reset --force`: appのworkspaceのみ削除する(ユーザーの `~/.lnd` は削除しない)。 + +## Concrete Steps + +### 1) ビルド(1回) + + cd apps/lcp-quickstart + go install ./cmd/lcp-quickstart + +### 2) 起動(testnet) + +(注)`lcp-quickstart` は repo root を自動検出します。検出に失敗する場合は、repo内から実行するか `--repo-root ` を指定してください。 + + lcp-quickstart testnet up --provider "@" + +### 3) 起動(mainnet) + + lcp-quickstart mainnet up --i-understand-mainnet + +### 4) 動作確認(HTTP) + + curl -sS http://127.0.0.1:8080/healthz + +期待する出力: + + ok + +### 5) 停止 + + lcp-quickstart mainnet down + +## Validation and Acceptance + +受け入れ条件(“観測できる挙動”として定義): + +1. `lcp-quickstart testnet up --provider ...` が、前提が足りない場合に “次にすべきこと” を明確に表示できる(wallet unlock、チャネル開設、など)。 +2. `lcp-quickstart mainnet up --i-understand-mainnet` が、同様に段階的に案内できる(危険操作は明示的許可が必要)。 +3. `curl http://127.0.0.1:8080/healthz` が `ok\n` を返す。 +4. `POST /v1/chat/completions` がHTTP 200を返し、レスポンスボディが空でない(Provider/モデルが有効な場合)。 +5. `down` が再実行可能な状態でプロセスを停止する(`status` が stopped を示す)。 + +## Idempotence and Recovery + +- `up` は冪等にする: + - 既に `lcpd-grpcd` / `openai-serve` が起動済みなら再起動せず `status` を表示して終了してよい。 +- `reset --force` は破壊的: + - appのworkspace(`~/.lcp-quickstart`)のみ削除する。 + - ユーザーの `~/.lnd` は削除しない(絶対に触らない)。 + +## Artifacts and Notes + +(実装後に、実際の `status` 出力例やログの場所を追記) + +## Interfaces and Dependencies + +### 呼び出す外部プロセス(固定) + +- `lncli`(testnet/mainnetのlnd操作) +- `lnd`(`--start-lnd` の場合のみ子プロセスとして起動) +- `go-lcpd/tools/lcpd-grpcd`(ビルドして起動) +- `apps/openai-serve/cmd/openai-serve`(ビルドして起動) + +--- + +Plan revision note (2026-01-06 07:30Z): + +ユーザー要望により regtest をスコープから外し、testnet/mainnet のみに絞りました。これに伴い、採掘による自動資金付与やローカルdevnet管理(bitcoind + 2x lnd)は削除し、`lncli` ベースで「段階的に案内しながら進める」設計に切り替えました。 + +Plan revision note (2026-01-07 03:28Z): + +`apps/lcp-quickstart/` の新規Go module実装を開始し、CLI + 状態ファイル(JSON) + 外部プロセス起動(pid/log) + `lncli` ラッパの骨格を追加しました。あわせて Progress/Decision Log/Concrete Steps を更新しました。 + +Plan revision note (2026-01-07 04:55Z): + +`mainnet up/status/down/logs/reset` を実装し、mainnetの安全ガード(同意ゲート/保守的既定値/非loopback bindの制限)を追加しました。あわせて `apps/lcp-quickstart/README.md` を追加し、初心者が同じ結果を再現できる “最小ドキュメント” を用意しました。 + +Plan revision note (2026-01-07 05:30Z): + +`lnd` / `lncli` の “未導入” を吸収するため、workspace `bin/` への自動ダウンロード(公式リリース + manifest SHA256 検証)を追加しました。あわせて `lcp-quickstart lncli ...` のラッパを提供し、wallet create/unlock を “自動化せずに” 実行できる導線を用意しました。