diff --git a/cmd/internal/flags/flags.go b/cmd/internal/flags/flags.go index 802e55c..0de3e3a 100644 --- a/cmd/internal/flags/flags.go +++ b/cmd/internal/flags/flags.go @@ -12,7 +12,7 @@ func CapabilityFlags() []cli.Flag { &cli.BoolFlag{ Name: "with-all", Category: "CAPABILITIES", - Usage: "grant all capabilities (console, IPFS, exec)", + Usage: "grant all capabilities (console, IPFS, exec, p2p)", EnvVars: []string{"WW_WITH_ALL"}, }, &cli.BoolFlag{ @@ -33,6 +33,12 @@ func CapabilityFlags() []cli.Flag { Usage: "grant process execution capability", EnvVars: []string{"WW_WITH_EXEC"}, }, + &cli.BoolFlag{ + Name: "with-p2p", + Category: "CAPABILITIES", + Usage: "grant P2P networking capability", + EnvVars: []string{"WW_WITH_P2P"}, + }, } } diff --git a/cmd/ww/shell/executor.go b/cmd/ww/shell/executor.go index cc578aa..54e0922 100644 --- a/cmd/ww/shell/executor.go +++ b/cmd/ww/shell/executor.go @@ -1,20 +1,15 @@ package shell import ( - "bytes" "context" "errors" "fmt" "io" - "strings" "time" "github.com/ipfs/boxo/files" "github.com/ipfs/boxo/path" - "github.com/libp2p/go-libp2p" - "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/core/protocol" - ma "github.com/multiformats/go-multiaddr" "github.com/spy16/slurp/builtin" "github.com/spy16/slurp/core" "github.com/wetware/go/system" @@ -106,74 +101,3 @@ func (e Exec) NewContext(opts map[builtin.Keyword]core.Any) (context.Context, co return context.WithTimeout(context.Background(), time.Second*15) } - -// SendToPeer sends data to a specific peer and process -func SendToPeer(peerAddr, procIdStr string, data interface{}) error { - ctx := context.TODO() - - // Create a new libp2p host for this connection - host, err := libp2p.New() - if err != nil { - return fmt.Errorf("failed to create libp2p host: %w", err) - } - defer host.Close() - - var peerInfo *peer.AddrInfo - - // Try to parse as peer ID first - peerId, err := peer.Decode(peerAddr) - if err == nil { - // Successfully parsed as peer ID - peerInfo = &peer.AddrInfo{ - ID: peerId, - // Note: In a real implementation, you'd need peer discovery - // or provide addresses as additional parameters - } - } else { - // Fall back to treating as multiaddr - addr, err := ma.NewMultiaddr(peerAddr) - if err != nil { - return fmt.Errorf("invalid peer address or ID: %w", err) - } - peerInfo, err = peer.AddrInfoFromP2pAddr(addr) - if err != nil { - return fmt.Errorf("failed to parse peer info from multiaddr: %w", err) - } - } - - // Connect to the peer - if err := host.Connect(ctx, *peerInfo); err != nil { - return fmt.Errorf("failed to connect to peer: %w", err) - } - - // Create protocol ID from process ID - protocolID := protocol.ID("/ww/0.1.0/" + procIdStr) - - // Open stream to the peer - stream, err := host.NewStream(ctx, peerInfo.ID, protocolID) - if err != nil { - return fmt.Errorf("failed to open stream: %w", err) - } - defer stream.Close() - - // Convert data to io.Reader based on type - var reader io.Reader - switch v := data.(type) { - case io.Reader: - reader = v - case []byte: - reader = bytes.NewReader(v) - case string: - reader = strings.NewReader(v) - default: - return fmt.Errorf("unsupported data type: %T, expected io.Reader, []byte, or string", data) - } - - // Send the data atomically - _, err = io.Copy(stream, reader) - if err != nil { - return fmt.Errorf("failed to send data: %w", err) - } - - return nil -} diff --git a/cmd/ww/shell/globals.go b/cmd/ww/shell/globals.go index c5051ca..76c7ecc 100644 --- a/cmd/ww/shell/globals.go +++ b/cmd/ww/shell/globals.go @@ -1,11 +1,14 @@ package shell import ( + "errors" "fmt" + "github.com/libp2p/go-libp2p" "github.com/spy16/slurp" "github.com/spy16/slurp/builtin" "github.com/spy16/slurp/core" + "github.com/urfave/cli/v2" ) const helpMessage = `Wetware Shell - Available commands: @@ -18,12 +21,17 @@ version - Show wetware version (< a b) - Less than (println expr) - Print expression with newline (print expr) - Print expression without newline -(send "peer-addr-or-id" "proc-id" data) - Send data to a peer process (data: string, []byte, or io.Reader) (import "module") - Import a module (stubbed) IPFS Path Syntax: /ipfs/QmHash/... - Direct IPFS path -/ipns/domain/... - IPNS path` +/ipns/domain/... - IPNS path + +P2P Commands (use --with-p2p): +(peer :send "peer-addr" "proc-id" data) - Send data to a peer process +(peer :connect "peer-addr") - Connect to a peer +(peer :is-self "peer-id") - Check if peer ID is our own +(peer :id) - Get our own peer ID` var globals = map[string]core.Any{ // Basic values @@ -72,7 +80,34 @@ var globals = map[string]core.Any{ fmt.Print(arg) } }), - "send": slurp.Func("send", func(peerAddr, procId string, data interface{}) error { - return SendToPeer(peerAddr, procId, data) - }), +} + +func NewGlobals(c *cli.Context) (map[string]core.Any, error) { + gs := make(map[string]core.Any, len(globals)) + for k, v := range globals { + gs[k] = v + } + + // Add IPFS support if --with-ipfs flag is set + if c.Bool("with-ipfs") || c.Bool("with-all") { + if env.IPFS == nil { + return nil, errors.New("uninitialized IPFS environment") + } + gs["ipfs"] = &IPFS{CoreAPI: env.IPFS} + } + + // Add P2P functionality if --with-p2p flag is set + if c.Bool("with-p2p") || c.Bool("with-all") { + // Create a new host for P2P functionality + host, err := libp2p.New() + if err != nil { + return nil, fmt.Errorf("failed to create libp2p host: %v", err) + } + gs["peer"] = &Peer{ + Ctx: c.Context, + Host: host, + } + } + + return gs, nil } diff --git a/cmd/ww/shell/globals_test.go b/cmd/ww/shell/globals_test.go index 3b0b5dc..e091183 100644 --- a/cmd/ww/shell/globals_test.go +++ b/cmd/ww/shell/globals_test.go @@ -24,7 +24,8 @@ func TestGetBaseGlobals(t *testing.T) { flagSet.Bool("with-all", false, "Enable all capabilities") c := cli.NewContext(app, flagSet, nil) c.Context = context.Background() - baseGlobals := getBaseGlobals(c) + baseGlobals, err := NewGlobals(c) + require.NoError(t, err) // Test that all expected base globals are present expectedGlobals := []string{ @@ -140,8 +141,10 @@ func TestGlobalsConsistency(t *testing.T) { flagSet.Bool("with-all", false, "Enable all capabilities") c := cli.NewContext(app, flagSet, nil) c.Context = context.Background() - baseGlobals1 := getBaseGlobals(c) - baseGlobals2 := getBaseGlobals(c) + baseGlobals1, err := NewGlobals(c) + require.NoError(t, err) + baseGlobals2, err := NewGlobals(c) + require.NoError(t, err) // They should have the same content initially assert.Equal(t, baseGlobals1, baseGlobals2) diff --git a/cmd/ww/shell/peer.go b/cmd/ww/shell/peer.go new file mode 100644 index 0000000..e9e7a38 --- /dev/null +++ b/cmd/ww/shell/peer.go @@ -0,0 +1,227 @@ +package shell + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + + "github.com/libp2p/go-libp2p/core/host" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/core/protocol" + ma "github.com/multiformats/go-multiaddr" + "github.com/spy16/slurp/builtin" + "github.com/spy16/slurp/core" +) + +var _ core.Invokable = (*Peer)(nil) + +type Peer struct { + Ctx context.Context + Host host.Host +} + +// Peer methods: (peer :send "peer-addr" "proc-id" data) or (peer :connect "peer-addr") +func (p Peer) Invoke(args ...core.Any) (core.Any, error) { + if len(args) == 0 { + return p.String(), nil + } + + if len(args) < 1 { + return nil, fmt.Errorf("peer requires at least 1 argument: (peer :method ...)") + } + + // First argument should be a keyword (:send, :connect, :is-self, :id) + method, ok := args[0].(builtin.Keyword) + if !ok { + return nil, fmt.Errorf("peer method must be a keyword, got %T", args[0]) + } + + switch method { + case "id": + return p.ID(), nil + case "send": + if len(args) < 4 { + return nil, fmt.Errorf("peer :send requires 3 arguments: (peer :send peer-addr proc-id data)") + } + + var peerAddr string + switch v := args[1].(type) { + case string: + peerAddr = v + case builtin.String: + peerAddr = string(v) + default: + return nil, fmt.Errorf("peer address must be a string or builtin.String, got %T", args[1]) + } + + var procID string + switch v := args[2].(type) { + case string: + procID = v + case builtin.String: + procID = string(v) + default: + return nil, fmt.Errorf("process ID must be a string or builtin.String, got %T", args[2]) + } + + return p.Send(p.Ctx, peerAddr, procID, args[3]) + + case "connect": + if len(args) < 2 { + return nil, fmt.Errorf("peer :connect requires 1 argument: (peer :connect peer-addr)") + } + + var peerAddr string + switch v := args[1].(type) { + case string: + peerAddr = v + case builtin.String: + peerAddr = string(v) + default: + return nil, fmt.Errorf("peer address must be a string or builtin.String, got %T", args[1]) + } + return p.Connect(peerAddr) + + case "is-self": + if len(args) < 2 { + return nil, fmt.Errorf("peer :is-self requires 1 argument: (peer :is-self peer-id)") + } + + var peerIDStr string + switch v := args[1].(type) { + case string: + peerIDStr = v + case builtin.String: + peerIDStr = string(v) + default: + return nil, fmt.Errorf("peer ID must be a string or builtin.String, got %T", args[1]) + } + return p.IsSelf(peerIDStr) + + default: + return nil, fmt.Errorf("unknown peer method: %s (supported: :send, :connect, :is-self, :id)", method) + } +} + +func (p Peer) String() string { + if p.Host == nil { + return "" + } + return fmt.Sprintf("", p.Host.ID()) +} + +// Send sends data to a specific peer and process +func (p *Peer) Send(ctx context.Context, peerAddr, procIDStr string, data interface{}) (core.Any, error) { + // Parse peer address + peerInfo, err := p.parsePeerAddr(peerAddr) + if err != nil { + return nil, fmt.Errorf("failed to parse peer address: %w", err) + } + + // Check if we're sending to ourselves + if peerInfo.ID == p.Host.ID() { + // TODO: Implement self-routing optimization + // For now, we'll still go through the network + } + + // Connect to the peer + if err := p.Host.Connect(ctx, *peerInfo); err != nil { + return nil, fmt.Errorf("failed to connect to peer: %w", err) + } + + // Create protocol ID from process ID + protocolID := protocol.ID("/ww/0.1.0/" + procIDStr) + + // Open stream to the peer + stream, err := p.Host.NewStream(ctx, peerInfo.ID, protocolID) + if err != nil { + return nil, fmt.Errorf("failed to open stream: %w", err) + } + defer stream.Close() + + // Convert data to io.Reader based on type + var reader io.Reader + switch v := data.(type) { + case io.Reader: + reader = v + case []byte: + reader = bytes.NewReader(v) + case string: + reader = strings.NewReader(v) + default: + return nil, fmt.Errorf("unsupported data type: %T, expected io.Reader, []byte, or string", data) + } + + // Send the data atomically + _, err = io.Copy(stream, reader) + if err != nil { + return nil, fmt.Errorf("failed to send data: %w", err) + } + + return builtin.String("sent"), nil +} + +// Connect establishes a connection to a peer +func (p *Peer) Connect(peerAddr string) (core.Any, error) { + ctx := context.TODO() + + // Parse peer address + peerInfo, err := p.parsePeerAddr(peerAddr) + if err != nil { + return nil, fmt.Errorf("failed to parse peer address: %w", err) + } + + // Connect to the peer + if err := p.Host.Connect(ctx, *peerInfo); err != nil { + return nil, fmt.Errorf("failed to connect to peer: %w", err) + } + + return builtin.String("connected"), nil +} + +// IsSelf checks if the given peer ID is our own +func (p *Peer) IsSelf(peerIDStr string) (core.Any, error) { + targetPeerID, err := peer.Decode(peerIDStr) + if err != nil { + return nil, fmt.Errorf("invalid peer ID: %w", err) + } + + return builtin.Bool(targetPeerID == p.Host.ID()), nil +} + +// ID returns our own peer ID as a string +func (p *Peer) ID() core.Any { + if p.Host == nil { + return builtin.String("") + } + return builtin.String(p.Host.ID().String()) +} + +// parsePeerAddr parses a peer address (either peer ID or multiaddr) into AddrInfo +func (p *Peer) parsePeerAddr(peerAddr string) (*peer.AddrInfo, error) { + // Try to parse as peer ID first + peerID, err := peer.Decode(peerAddr) + if err == nil { + // Successfully parsed as peer ID + return &peer.AddrInfo{ + ID: peerID, + // Note: In a real implementation, you'd need peer discovery + // or provide addresses as additional parameters + }, nil + } + + // Fall back to treating as multiaddr + addr, err := ma.NewMultiaddr(peerAddr) + if err != nil { + return nil, fmt.Errorf("invalid peer address or ID: %w", err) + } + + peerInfo, err := peer.AddrInfoFromP2pAddr(addr) + if err != nil { + return nil, fmt.Errorf("failed to parse peer info from multiaddr: %w", err) + } + + return peerInfo, nil +} diff --git a/cmd/ww/shell/peer_test.go b/cmd/ww/shell/peer_test.go new file mode 100644 index 0000000..9062636 --- /dev/null +++ b/cmd/ww/shell/peer_test.go @@ -0,0 +1,103 @@ +package shell_test + +import ( + "context" + "testing" + + "github.com/libp2p/go-libp2p" + "github.com/spy16/slurp/builtin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/wetware/go/cmd/ww/shell" +) + +func TestPeer(t *testing.T) { + t.Parallel() + + // Create a test host + host, err := libp2p.New() + require.NoError(t, err, "failed to create test host") + defer host.Close() + + // Create a test peer + peer := &shell.Peer{ + Ctx: context.Background(), + Host: host, + } + + t.Run("String", func(t *testing.T) { + str := peer.String() + assert.NotEqual(t, "", str, "expected peer string to show host ID") + assert.NotEmpty(t, str, "expected non-empty peer string") + }) + + t.Run("ID", func(t *testing.T) { + id := peer.ID() + require.NotNil(t, id, "expected non-nil peer ID") + + idStr, ok := id.(builtin.String) + require.True(t, ok, "expected builtin.String, got %T", id) + assert.NotEmpty(t, string(idStr), "expected non-empty peer ID string") + }) + + t.Run("IsSelf", func(t *testing.T) { + // Test with our own peer ID + ourID := host.ID().String() + result, err := peer.IsSelf(ourID) + require.NoError(t, err, "unexpected error") + + isSelf, ok := result.(builtin.Bool) + require.True(t, ok, "expected builtin.Bool, got %T", result) + assert.True(t, bool(isSelf), "expected peer to recognize itself") + + // Test with a different peer ID - create another host to get a valid peer ID + otherHost, err := libp2p.New() + require.NoError(t, err, "failed to create other host") + defer otherHost.Close() + + differentID := otherHost.ID().String() + result, err = peer.IsSelf(differentID) + require.NoError(t, err, "unexpected error") + + isSelf, ok = result.(builtin.Bool) + require.True(t, ok, "expected builtin.Bool, got %T", result) + assert.False(t, bool(isSelf), "expected peer to not recognize different ID as self") + }) + + t.Run("Invoke", func(t *testing.T) { + // Test :id method + result, err := peer.Invoke(builtin.Keyword("id")) + require.NoError(t, err, "unexpected error calling :id") + assert.NotNil(t, result, "expected non-nil result from :id") + + // Test :is-self method + ourID := host.ID().String() + result, err = peer.Invoke(builtin.Keyword("is-self"), ourID) + require.NoError(t, err, "unexpected error calling :is-self") + assert.NotNil(t, result, "expected non-nil result from :is-self") + + // Test invalid method + _, err = peer.Invoke(builtin.Keyword("invalid")) + assert.Error(t, err, "expected error for invalid method") + }) + + t.Run("BuiltinStringSupport", func(t *testing.T) { + // Test that builtin.String types are properly handled in Peer methods. + // This is important for composable expressions like: + // (peer :send (peer :id) "/ww/0.1.0/crU9ZDuzKWr" "hello, wetware!") + // where (peer :id) returns a builtin.String, not a regular Go string. + ourID := host.ID().String() + builtinID := builtin.String(ourID) + + // Test :is-self with builtin.String + result, err := peer.Invoke(builtin.Keyword("is-self"), builtinID) + require.NoError(t, err, "unexpected error calling :is-self with builtin.String") + assert.NotNil(t, result, "expected non-nil result from :is-self") + + // Test :connect with builtin.String (this will fail to connect, but should not error on type) + _, err = peer.Invoke(builtin.Keyword("connect"), builtinID) + // We expect a connection error, not a type error + assert.Error(t, err, "expected connection error") + assert.NotContains(t, err.Error(), "must be a string", "should not get type error for builtin.String") + }) +} diff --git a/cmd/ww/shell/shell.go b/cmd/ww/shell/shell.go index 5684d3f..1c7c841 100644 --- a/cmd/ww/shell/shell.go +++ b/cmd/ww/shell/shell.go @@ -103,6 +103,9 @@ func runHostMode(c *cli.Context) error { if c.Bool("with-console") { cmd.Args = append(cmd.Args, "--with-console") } + if c.Bool("with-p2p") { + cmd.Args = append(cmd.Args, "--with-p2p") + } if c.Bool("with-all") { cmd.Args = append(cmd.Args, "--with-all") } @@ -120,6 +123,9 @@ func runHostMode(c *cli.Context) error { if c.Bool("with-console") { cmd.Args = append(cmd.Args, "--with-console") } + if c.Bool("with-p2p") { + cmd.Args = append(cmd.Args, "--with-p2p") + } if c.Bool("with-all") { cmd.Args = append(cmd.Args, "--with-all") } @@ -181,10 +187,13 @@ func runGuestMode(c *cli.Context) error { return fmt.Errorf("failed to resolve terminal login: %w", err) } - // Create base environment with analyzer and globals to it. + gs, err := NewGlobals(c) + if err != nil { + return err + } + eval := slurp.New() - // Bind base globals (common to both modes) - if err := eval.Bind(getBaseGlobals(c)); err != nil { + if err := eval.Bind(gs); err != nil { return fmt.Errorf("failed to bind base globals: %w", err) } // Bind session-specific globals (interactive mode only) @@ -251,7 +260,11 @@ func executeCommand(c *cli.Context, command string, host *os.File) error { eval := slurp.New() // Bind base globals (common to both modes) - if err := eval.Bind(getBaseGlobals(c)); err != nil { + gs, err := NewGlobals(c) + if err != nil { + return fmt.Errorf("failed to bind base globals: %w", err) + } + if err := eval.Bind(gs); err != nil { return fmt.Errorf("failed to bind base globals: %w", err) } @@ -373,27 +386,6 @@ func getCompleter(c *cli.Context) readline.AutoCompleter { return readline.NewPrefixCompleter(completers...) } -// getBaseGlobals returns the base globals that are common to both interactive and command modes -func getBaseGlobals(c *cli.Context) map[string]core.Any { - gs := make(map[string]core.Any, len(globals)) - - // Copy the base globals from globals.go - for k, v := range globals { - gs[k] = v - } - - // Add IPFS support if --with-ipfs flag is set - if c.Bool("with-ipfs") || c.Bool("with-all") { - if env.IPFS != nil { - gs["ipfs"] = &IPFS{CoreAPI: env.IPFS} - } else { - panic("uninitialized IPFS environment") - } - } - - return gs -} - // NewSessionGlobals returns additional globals for interactive mode (requires terminal connection) func NewSessionGlobals(c *cli.Context, f *system.Terminal_login_Results) map[string]core.Any { session := make(map[string]core.Any) diff --git a/cmd/ww/shell/shell_test.go b/cmd/ww/shell/shell_test.go index 5735f50..10e1a53 100644 --- a/cmd/ww/shell/shell_test.go +++ b/cmd/ww/shell/shell_test.go @@ -12,6 +12,7 @@ import ( "github.com/spy16/slurp" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/urfave/cli/v2" ) @@ -37,7 +38,11 @@ func executeCommandForTesting(c *cli.Context, command string) error { eval := slurp.New() // Bind base globals (common to both modes) - if err := eval.Bind(getBaseGlobals(c)); err != nil { + gs, err := NewGlobals(c) + if err != nil { + return fmt.Errorf("failed to bind base globals: %w", err) + } + if err := eval.Bind(gs); err != nil { return fmt.Errorf("failed to bind base globals: %w", err) } @@ -269,7 +274,8 @@ func TestCommandStructure(t *testing.T) { func TestGlobalsIntegration(t *testing.T) { t.Parallel() - baseGlobals := getBaseGlobals(createMockCLIContext()) + baseGlobals, err := NewGlobals(createMockCLIContext()) + require.NoError(t, err) // Test that all expected functions are present and callable expectedFunctions := []string{"+", "*", ">", "<", "=", "/", "help", "println", "print"}