Skip to content
Open
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
8 changes: 7 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,10 @@ jobs:
run: make install

- name: Compile
run: make compile
run: make compile

- name: Test
run: make test

- name: Race-test execution paths
run: go test -race ./pkg/runtime/... ./pkg/run/... ./pkg/repl/... ./cmd/... ./internal/diagnostics/...
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ test:

fmt:
go fmt ./... && \
goimports -w -local github.com/MontFerret ./internal ./pkg/browser ./cmd ./pkg/debugger ./pkg/config ./ferret ./pkg/logger ./pkg/module ./pkg/repl ./pkg/runtime
goimports -w -local github.com/MontFerret ./internal ./pkg/browser ./cmd ./pkg/debugger ./pkg/config ./ferret ./pkg/logger ./pkg/module ./pkg/repl ./pkg/runtime ./examples

lint:
staticcheck ./... && \
Expand Down
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,84 @@ and an exact open pull request or publication branch is reused. Ferret never
overwrites an immutable Registry record or a divergent branch; delete a stale
publication branch from your fork before retrying if its contents differ.

## Wire source execution

This CLI integrates Wire `v1.0.0-alpha.2`, Universal API `v1.0.0-alpha.20`, and
Ferret `v2.0.0-alpha.57`. The host must support the alpha.2 handshake, including
hosted runtime version metadata; alpha.1 hosts are incompatible.

Builtin execution remains the default. Select `--runtime wire` explicitly to run
source against an application-configured Universal API runtime. Legacy HTTP URLs
in `--runtime` keep their existing Worker behavior.

Start the development host in terminal 1:

```bash
go run ./examples/wire-host
```

It prints an ephemeral endpoint such as `tcp://127.0.0.1:54321`. In terminal 2,
substitute that endpoint in these commands:

```bash
ferret run --runtime wire --runtime-endpoint tcp://127.0.0.1:54321 \
--eval 'RETURN DEMO::IDENTITY(@value)' --param value=42
ferret exec --runtime wire --runtime-endpoint tcp://127.0.0.1:54321 script.fql
ferret run --runtime wire --runtime-endpoint tcp://127.0.0.1:54321 \
--eval 'let arr = []

return'
ferret repl --runtime wire --runtime-endpoint tcp://127.0.0.1:54321
ferret version --runtime wire --runtime-endpoint tcp://127.0.0.1:54321
```

`DEMO::IDENTITY(value)` exists only in the example host. Try it in the REPL, submit
`RETURN )`, then correct the query. Syntax errors retain source snippets, carets,
and hints. Use `exit` or Ctrl-D to exit; Ctrl-C stops the shell. Stop the example
host with Ctrl-C when finished.

| Flag and config key | Environment variable | Default |
| --- | --- | --- |
| `runtime-endpoint` | `FERRET_RUNTIME_ENDPOINT` | Required for Wire; no default port |
| `runtime-connect-timeout` | `FERRET_RUNTIME_CONNECT_TIMEOUT` | `5s`, must be positive |

Only `tcp://127.0.0.1:<port>` is supported (ports 1–65535, no other URL components).
Both connection establishment and the Wire handshake respect the timeout and
command cancellation. Connection failure never falls back or reconnects the
logical runtime automatically. Endpoint and connection-timeout settings require
Wire mode; builtin and legacy HTTP behavior otherwise remain unchanged.

This plaintext transport is for **trusted local development**. Loopback access
does not authenticate either the client or runtime. There are no TLS or credential
settings in this integration. HTTP headers and policy flags are not Wire credentials.

The CLI reads script files locally and transmits their semantic name and contents.
Compilation happens on the host, allowing application-only functions and modules.
FQL filesystem operations use the runtime host's configured filesystem defaults
(the example host uses its working directory as the root);
the CLI does not send its current directory as a filesystem root. Explicit
filesystem/HTTP policies and browser configuration, including settings in config
or environment variables, are rejected in Wire mode. Untouched flag defaults do
not count as explicit configuration.

Each run owns one logical runtime and transport; the REPL reuses one pair. Active
calls settle before runtime and transport cleanup. Wire performs detached cleanup
bounded by its 30-second cleanup timeout after cancellation. The host owns its
engine independently; closing a CLI connection does not close it.

Run output is printed as encoded bytes, including available output accompanying
an error. Errors go to stderr and fail the command; successfully produced empty
output prints nothing. Artifacts and debugging remain builtin-only.

`ferret version --runtime wire` reports the hosted runtime version alongside the
CLI version. The Wire REPL banner shows the endpoint and hosted version. Version
values come directly from the Universal API and are preserved as opaque strings,
including empty values. Wire captures this metadata during the initial handshake;
reading it adds no RPC. Hosted metadata failure fails construction within the
connection timeout, before the REPL requests input. The example derives its
Ferret version from Go build information; builtin execution retains CLI-injected
version metadata (or `unknown` in a build without version injection).

## Browser usage

Ferret can use Chrome or Chromium through the Chrome DevTools Protocol.
Expand Down
16 changes: 16 additions & 0 deletions cmd/internal/debug/debug_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/MontFerret/cli/v2/cmd/internal/execution"
"github.com/MontFerret/cli/v2/cmd/internal/testutil"
"github.com/MontFerret/cli/v2/internal/testutil/wirehost"
"github.com/MontFerret/ferret/v2/pkg/source"

"github.com/MontFerret/cli/v2/pkg/browser"
Expand All @@ -30,6 +31,21 @@ func TestDebugCommandRequiresExactlyOneScript(t *testing.T) {
}
}

func TestDebugRejectsWireBeforeConnecting(t *testing.T) {
host := wirehost.New(t)
path := filepath.Join(t.TempDir(), "script.fql")
testutil.WriteQuery(t, path, "RETURN DEMO::IDENTITY(42)")
store := testutil.NewStore(t)
cmd := New(store)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs([]string{"--runtime=wire", "--runtime-endpoint=" + host.Endpoint, path})
err := cmd.ExecuteContext(config.With(t.Context(), store))
if !errors.Is(err, cliruntime.ErrDebugRequiresBuiltinRuntime) || host.Connections() != 0 {
t.Fatalf("error=%v connections=%d", err, host.Connections())
}
}

func TestExecuteDebugRejectsRemoteRuntimeBeforeStarting(t *testing.T) {
path := filepath.Join(t.TempDir(), "query.fql")
testutil.WriteQuery(t, path, "RETURN 1")
Expand Down
11 changes: 8 additions & 3 deletions cmd/internal/diagnostics/diagnostics.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package diagnostics

import (
"fmt"
"io"
"os"

"github.com/MontFerret/ferret/v2/pkg/diagnostics"
"github.com/MontFerret/cli/v2/internal/diagnostics"
)

// PrintError preserves the CLI's direct stderr rendering for Ferret diagnostics.
func PrintError(err error) {
fmt.Fprintln(os.Stderr, diagnostics.Format(err))
diagnostics.Print(os.Stderr, err)
}

// PrintErrorTo renders execution diagnostics to the command's selected stderr.
func PrintErrorTo(out io.Writer, err error) {
diagnostics.Print(out, err)
}
9 changes: 8 additions & 1 deletion cmd/internal/execution/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,16 @@ import (
cliruntime "github.com/MontFerret/cli/v2/pkg/runtime"
)

// AddRuntimeSelectionFlags registers runtime selection without execution policies.
func AddRuntimeSelectionFlags(cmd *cobra.Command) {
cmd.Flags().StringP(config.ExecRuntime, "r", cliruntime.DefaultRuntime, "Ferret runtime (builtin, wire, or legacy HTTP URL)")
cmd.Flags().String(config.ExecRuntimeEndpoint, "", "Wire endpoint: tcp://127.0.0.1:<port> (trusted local development)")
cmd.Flags().Duration(config.ExecRuntimeConnectTimeout, cliruntime.DefaultConnectTimeout, "Wire connection and handshake timeout (must be positive)")
}

// AddRuntimeFlags keeps the execution-policy surface identical across run, debug, and repl.
func AddRuntimeFlags(cmd *cobra.Command) {
cmd.Flags().StringP(config.ExecRuntime, "r", cliruntime.DefaultRuntime, "Ferret runtime type (\"builtin\"|$url)")
AddRuntimeSelectionFlags(cmd)
cmd.Flags().String(config.ExecProxy, "x", "Proxy server address")
cmd.Flags().String(config.ExecUserAgent, "a", "User agent header")
cmd.Flags().StringP(config.ExecBrowserAddress, "d", cliruntime.DefaultBrowser, "Browser debugger address")
Expand Down
2 changes: 1 addition & 1 deletion cmd/internal/execution/runtime_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,5 @@ func OptionsFromCommand(cmd *cobra.Command, store *config.Store) (cliruntime.Opt
}
opts.FSPolicy = fsPolicy

return opts, nil
return opts, cliruntime.ValidateOptions(opts)
}
140 changes: 140 additions & 0 deletions cmd/internal/execution/wire_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package execution_test

import (
"strings"
"testing"
"time"

"github.com/spf13/cobra"

"github.com/MontFerret/cli/v2/cmd/internal/execution"
"github.com/MontFerret/cli/v2/cmd/internal/testutil"
"github.com/MontFerret/cli/v2/pkg/config"
)

func TestWireExplicitConfiguration(t *testing.T) {
for _, extra := range [][]string{
{}, {"--browser-open=false"}, {"--browser-headless=false"}, {"--browser-cookies=false"},
{"--browser-address=http://127.0.0.1:9222"}, {"--proxy="}, {"--user-agent="},
{"--policy-fs-read-only=false"}, {"--policy-http-follow-redirects=true"},
} {
t.Run(strings.Join(extra, " "), func(t *testing.T) {
store := testutil.NewStore(t)
cmd := &cobra.Command{}
execution.AddRuntimeFlags(cmd)
args := append([]string{"--runtime=wire", "--runtime-endpoint=tcp://127.0.0.1:123"}, extra...)
if err := cmd.ParseFlags(args); err != nil {
t.Fatal(err)
}

store.BindFlags(cmd)
opts, err := execution.OptionsFromCommand(cmd, store)
if (err != nil) != (len(extra) != 0) {
t.Fatalf("explicit options %v: %v", extra, err)
}

if len(extra) == 0 && opts.ConnectTimeout != 5*time.Second {
t.Fatalf("default timeout = %v", opts.ConnectTimeout)
}
})
}
}

func TestWireConfigPrecedenceAndDefaults(t *testing.T) {
store := testutil.NewStore(t)
if err := store.Set(config.ExecRuntime, "wire"); err != nil {
t.Fatal(err)
}

if err := store.Set(config.ExecRuntimeEndpoint, "tcp://127.0.0.1:111"); err != nil {
t.Fatal(err)
}

if err := store.Set(config.ExecRuntimeConnectTimeout, "9s"); err != nil {
t.Fatal(err)
}

store, err := config.NewStore("ferret", "test")
if err != nil {
t.Fatal(err)
}

t.Setenv("FERRET_RUNTIME_ENDPOINT", "tcp://127.0.0.1:222")
t.Setenv("FERRET_RUNTIME_CONNECT_TIMEOUT", "7s")
cmd := &cobra.Command{}
execution.AddRuntimeFlags(cmd)
store.BindFlags(cmd)
opts, err := execution.OptionsFromCommand(cmd, store)
if err != nil || opts.Endpoint != "tcp://127.0.0.1:222" || opts.ConnectTimeout != 7*time.Second {
t.Fatalf("environment precedence: %+v error=%v", opts, err)
}

cmd = &cobra.Command{}
execution.AddRuntimeFlags(cmd)
if err := cmd.ParseFlags([]string{"--runtime-endpoint=tcp://127.0.0.1:333", "--runtime-connect-timeout=3s"}); err != nil {
t.Fatal(err)
}

store.BindFlags(cmd)
opts, err = execution.OptionsFromCommand(cmd, store)
if err != nil || opts.Endpoint != "tcp://127.0.0.1:333" || opts.ConnectTimeout != 3*time.Second {
t.Fatalf("flag precedence: %+v error=%v", opts, err)
}
}

func TestWireConfigurationRejections(t *testing.T) {
for _, args := range [][]string{
{"--runtime=wire"},
{"--runtime=builtin", "--runtime-endpoint=tcp://127.0.0.1:1"},
{"--runtime=https://worker.example", "--runtime-connect-timeout=5s"},
{"--runtime=wire", "--runtime-endpoint=tcp://127.0.0.1:1", "--runtime-connect-timeout=0s"},
{"--runtime=wire", "--runtime-endpoint=tcp://127.0.0.1:1", "--runtime-connect-timeout=-1s"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
store := testutil.NewStore(t)
cmd := &cobra.Command{}
execution.AddRuntimeFlags(cmd)
if err := cmd.ParseFlags(args); err != nil {
t.Fatal(err)
}

store.BindFlags(cmd)
if _, err := execution.OptionsFromCommand(cmd, store); err == nil {
t.Fatal("invalid configuration accepted")
}
})
}
}

func TestWireRejectsConfiguredPolicyAndBrowserDefaults(t *testing.T) {
for _, test := range []struct{ key, value string }{
{config.ExecWithBrowser, "false"},
{config.ExecBrowserAddress, "http://127.0.0.1:9222"},
{config.PolicyFSReadOnly, "false"},
{config.PolicyHTTPFollowRedirects, "true"},
} {
for _, environment := range []bool{false, true} {
t.Run(test.key+"/"+map[bool]string{false: "config", true: "environment"}[environment], func(t *testing.T) {
store := testutil.NewStore(t)
if environment {
t.Setenv("FERRET_"+strings.ToUpper(strings.ReplaceAll(test.key, "-", "_")), test.value)
} else {
if err := store.Set(test.key, test.value); err != nil {
t.Fatal(err)
}
}

cmd := &cobra.Command{}
execution.AddRuntimeFlags(cmd)
if err := cmd.ParseFlags([]string{"--runtime=wire", "--runtime-endpoint=tcp://127.0.0.1:1"}); err != nil {
t.Fatal(err)
}

store.BindFlags(cmd)
if _, err := execution.OptionsFromCommand(cmd, store); err == nil {
t.Fatal("explicit builtin configuration accepted")
}
})
}
}
}
2 changes: 1 addition & 1 deletion cmd/internal/repl/repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func New(store *config.Store) *cobra.Command {

defer cleanup()

return clirepl.Start(cmd.Context(), rtOpts, params)
return clirepl.StartWithIO(cmd.Context(), rtOpts, params, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr())
},
}

Expand Down
48 changes: 48 additions & 0 deletions cmd/internal/repl/wire_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package repl

import (
"bytes"
"context"
"strings"
"testing"
"time"

"github.com/MontFerret/cli/v2/cmd/internal/testutil"
"github.com/MontFerret/cli/v2/internal/testutil/wirehost"
"github.com/MontFerret/cli/v2/pkg/config"
)

func TestWireReplReusesRuntimeAfterSyntaxError(t *testing.T) {
host := wirehost.New(t)
store := testutil.NewStore(t)
cmd := New(store)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs([]string{"--runtime", "wire", "--runtime-endpoint", host.Endpoint})
cmd.SetIn(strings.NewReader("RETURN DEMO::IDENTITY(41)\nRETURN )\nRETURN DEMO::IDENTITY(42)\nexit\n"))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()

if err := cmd.ExecuteContext(config.With(ctx, store)); err != nil {
t.Fatal(err)
}

for _, want := range []string{"Wire runtime: " + host.Endpoint + "; version: test-core-version", "41\n", "42\n"} {
if !strings.Contains(stdout.String(), want) {
t.Errorf("stdout missing %q: %q", want, stdout.String())
}
}

for _, want := range []string{"anonymous:1:8", "1 | RETURN )", " | ^"} {
if !strings.Contains(stderr.String(), want) {
t.Errorf("stderr missing %q: %q", want, stderr.String())
}
}

if host.Connections() != 1 {
t.Fatalf("connections=%d, want 1", host.Connections())
}
}
Loading
Loading