Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ jobs:
ext=""
if [ "$GOOS" = "windows" ]; then ext=".exe"; fi
out="flowgo-${TAG}-${GOOS}-${GOARCH}${ext}"
go build -trimpath -ldflags "-s -w -X main.version=${TAG}" -o "$out" .
go build -trimpath -ldflags "-s -w -X main.version=${TAG}" -o "$out" ./cmd/flowgo
echo "OUT=$out" >> "$GITHUB_ENV"
- name: Attach to release
env:
Expand Down
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
.claude-container
flowgo
.claude

# Compiled binary at repo root. Anchored with `/` so it doesn't also
# ignore pkg/flowgo (the library package directory).
/flowgo

# Local-only planning docs (not for public repo).
PLAN.md

# MemPalace per-project files (issue #185)
mempalace.yaml
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,17 @@ The path of least resistance, in order:

**1. In your browser, no install:** [flowgo-map.com](https://flowgo-map.com)

**2. On your machine, one command:**
**2. On macOS / Linux via Homebrew:**

```
brew install lassediercks/flowgo/flowgo
flowgo new
```

The tap lives at [lassediercks/homebrew-flowgo](https://github.com/lassediercks/homebrew-flowgo)
and tracks the latest release. `brew upgrade flowgo` pulls new versions.

**3. On your machine, one command:**

```
go install github.com/lassediercks/flowgo@latest
Expand Down
File renamed without changes.
88 changes: 36 additions & 52 deletions main.go → cmd/flowgo/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// Package main is the public flowgo CLI. It composes the
// pkg/flowgo library with a small flag parser, browser launcher, and
// version reporter; everything substantive lives in the library so
// downstream consumers can wire flowgo onto their own HTTP mux
// without copying code.
package main

import (
_ "embed"
"encoding/json"
"fmt"
"net"
Expand All @@ -13,39 +17,20 @@ import (
"strings"
"sync"

"github.com/lassediercks/flowgo/pkg/flowgo"
"github.com/lassediercks/flowgo/pkg/graph"
)

// Re-export the graph types and parser/serializer under their original
// unqualified names so the rest of this binary (mcp.go, serve.go,
// workspace.go, validate*.go) keeps compiling without churn. External
// consumers should import github.com/lassediercks/flowgo/pkg/graph
// directly instead of relying on these aliases.
type (
Box = graph.Box
Edge = graph.Edge
Text = graph.Text
Line = graph.Line
Stroke = graph.Stroke
NamedMap = graph.NamedMap
Graph = graph.Graph
)

var (
parse = graph.Parse
serialize = graph.Serialize
)

// version is overwritten at release-build time via:
//
// go build -ldflags "-X main.version=<tag>" ./cmd/flowgo
//
// `go install ...@<tag>` also surfaces the module version via
// runtime/debug, which resolveVersionString falls back on.
var version = "dev"

//go:embed dist/index.html
var indexHTML string

//go:embed .release-please-manifest.json
var releasePleaseManifest []byte

var (
mu sync.Mutex
fileMu sync.Mutex
filePath string
)

Expand Down Expand Up @@ -106,11 +91,11 @@ func main() {

createdFile := false
if _, err := os.Stat(filePath); os.IsNotExist(err) {
seed := serialize(Graph{
seed := graph.Serialize(graph.Graph{
Version: resolveVersionString(),
Maps: []NamedMap{{
Maps: []graph.NamedMap{{
Path: "/",
Boxes: []Box{{ID: "b1", Label: seedBoxLabel(filePath)}},
Boxes: []graph.Box{{ID: "b1", Label: seedBoxLabel(filePath)}},
}},
})
if err := os.WriteFile(filePath, []byte(seed), 0644); err != nil {
Expand All @@ -122,17 +107,24 @@ func main() {
fmt.Printf("initialised the flowgo interface on a new file %s\n", filePath)
}

flowgo.Configure(flowgo.Config{
ServeMode: false,
LocalFile: filePath,
LocalFileMu: &fileMu,
Version: resolveVersionString,
})

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(indexHTML))
w.Write([]byte(flowgo.IndexHTML))
})
http.HandleFunc("/state", handleState)
http.HandleFunc("/save", handleSave)
http.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintln(w, resolveVersionString())
})
http.HandleFunc("/mcp", handleMCP)
http.HandleFunc("/mcp", flowgo.MCPHandler)

// Walk forward from the canonical port so a second flowgo (or any
// process holding 54041) doesn't fail to start. Range is bounded to
Expand All @@ -144,9 +136,6 @@ func main() {
}
addr := ln.Addr().(*net.TCPAddr)
displayHost := bindHost
// When binding to all interfaces, surface the host's real LAN IP
// in the printed URL — `0.0.0.0` isn't a thing the user can paste
// into another browser tab.
if bindHost == "0.0.0.0" {
if lan := pickLanIP(); lan != "" {
displayHost = lan
Expand All @@ -169,8 +158,6 @@ func main() {
// RFC 1918 private ranges (10/8, 172.16/12, 192.168/16) over any other
// non-loopback IPv4. Empty string when nothing usable is reachable —
// callers should fall back to bindHost in that case.
//
// Pulled out of main so it can be unit-tested via a fake addr provider.
func pickLanIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
Expand Down Expand Up @@ -204,9 +191,7 @@ func pickLanIPFromAddrs(addrs []net.Addr) string {
}

// listenFirstFree tries each port in [start, end] on host and returns the
// first listener that binds successfully. The window is small on purpose:
// "next free port" should still produce a predictable URL, not vanish into
// the ephemeral range.
// first listener that binds successfully.
func listenFirstFree(host string, start, end int) (net.Listener, error) {
var lastErr error
for port := start; port <= end; port++ {
Expand All @@ -220,14 +205,14 @@ func listenFirstFree(host string, start, end int) (net.Listener, error) {
}

func handleState(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
fileMu.Lock()
defer fileMu.Unlock()
data, err := os.ReadFile(filePath)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
g, err := parse(string(data))
g, err := graph.Parse(string(data))
if err != nil {
http.Error(w, err.Error(), 500)
return
Expand All @@ -241,15 +226,15 @@ func handleSave(w http.ResponseWriter, r *http.Request) {
http.Error(w, "POST only", 405)
return
}
var g Graph
var g graph.Graph
if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
http.Error(w, err.Error(), 400)
return
}
mu.Lock()
defer mu.Unlock()
fileMu.Lock()
defer fileMu.Unlock()
g.Version = resolveVersionString()
if err := os.WriteFile(filePath, []byte(serialize(g)), 0644); err != nil {
if err := os.WriteFile(filePath, []byte(graph.Serialize(g)), 0644); err != nil {
http.Error(w, err.Error(), 500)
return
}
Expand Down Expand Up @@ -294,10 +279,9 @@ func resolveVersionString() string {
if version != "dev" {
return version
}
var m map[string]string
if err := json.Unmarshal(releasePleaseManifest, &m); err == nil {
if v := m["."]; v != "" {
return v
if info, ok := debug.ReadBuildInfo(); ok {
if v := info.Main.Version; v != "" && v != "(devel)" {
return strings.TrimPrefix(v, "v")
}
}
return "dev"
Expand Down
File renamed without changes.
45 changes: 18 additions & 27 deletions serve.go → cmd/flowgo/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,19 @@ import (
"os"
"strings"
"time"
)

// serveMode flips dispatchTool / mcpTools / route registration into multi-
// tenant workspace + snapshot-share territory. When false, flowgo is the
// single-file editor it's always been.
var serveMode bool
"github.com/lassediercks/flowgo/pkg/flowgo"
)

type ServeConfig struct {
type serveConfig struct {
BindAddr string
WebhookURL string
WebhookSecret string
WorkspaceTTL time.Duration
}

var (
serveCfg *ServeConfig
workspaces *WorkspaceManager
)

func runServe(args []string) {
cfg := &ServeConfig{
cfg := &serveConfig{
BindAddr: "127.0.0.1:8080",
WorkspaceTTL: time.Hour,
}
Expand Down Expand Up @@ -85,26 +77,26 @@ func runServe(args []string) {
}
}

// Allow secret via env so it doesn't appear in process listings.
if cfg.WebhookSecret == "" {
cfg.WebhookSecret = os.Getenv("FLOWGO_WEBHOOK_SECRET")
}

// share is the only feature that strictly requires the webhook. Allow
// running without it for local testing; agents calling share will get a
// clear error if it's not configured.
serveCfg = cfg
serveMode = true
workspaces = newWorkspaceManager(cfg.WorkspaceTTL)
flowgo.Configure(flowgo.Config{
ServeMode: true,
Workspaces: flowgo.NewWorkspaceManager(cfg.WorkspaceTTL),
ShareWebhookURL: cfg.WebhookURL,
ShareWebhookSecret: cfg.WebhookSecret,
Version: resolveVersionString,
})

mux := http.NewServeMux()
mux.HandleFunc("/mcp", handleMCP)
mux.HandleFunc("/mcp", flowgo.MCPHandler)
mux.HandleFunc("/m/", func(w http.ResponseWriter, r *http.Request) {
// Editor HTML for shared snapshots. The website is expected to
// reverse-proxy /m/* here. The HTML detects snapshot mode from the
// pathname and bootstraps from /api/snapshot/<id>.
// Editor HTML for shared snapshots. The website reverse-proxies
// /m/* here. The HTML detects snapshot mode from the pathname
// and bootstraps from /api/snapshot/<id>.
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(indexHTML))
w.Write([]byte(flowgo.IndexHTML))
})
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
Expand All @@ -117,7 +109,7 @@ func runServe(args []string) {
}
addr := ln.Addr().(*net.TCPAddr)
fmt.Printf("flowgo serve\n")
fmt.Printf(" bind: %s (port %d)\n", cfg.BindAddr, addr.Port)
fmt.Printf(" bind: %s (port %d)\n", cfg.BindAddr, addr.Port)
fmt.Printf(" workspace ttl: %s\n", cfg.WorkspaceTTL)
if cfg.WebhookURL != "" {
fmt.Printf(" share webhook: %s\n", cfg.WebhookURL)
Expand Down Expand Up @@ -155,7 +147,6 @@ Flags:
(default 1h)

The website reverse-proxies /api/mcp* and /m/* to this binary on loopback;
flowgo never serves the public surface directly. See
docs/website-integration-memo.md.
flowgo never serves the public surface directly.
`)
}
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion version_check.go → cmd/flowgo/version_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func notifyIfNewer(current, latest string) {
fmt.Fprintf(os.Stderr,
" update available: flowgo %s (you have %s)\n"+
" run `flowgo upgrade` to update in place (or `brew upgrade flowgo` if installed via brew,\n"+
" or `go install github.com/lassediercks/flowgo@latest` if installed via go)\n",
" or `go install github.com/lassediercks/flowgo/cmd/flowgo@latest` if installed via go)\n",
latest, current)
}

Expand Down
File renamed without changes.
Loading
Loading