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
189 changes: 189 additions & 0 deletions cmd/machine/machine.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Package machine is an additive parser for the existing command paths. The
// human command implementations are never invoked to perform RPC mutations.
package machine

import (
"bytes"
"context"
"encoding/json"
"io"
"os"
"strings"
"time"

"github.com/anyproto/anytype-cli/core/automation"
"github.com/anyproto/anytype-cli/core/localstate"
"github.com/spf13/cobra"
)

func Requested(args []string) bool {
for _, arg := range args {
if arg == "--" {
break
}
if arg == "--json" || strings.HasPrefix(arg, "--json=") {
return true
}
}
return false
}

type Runner interface {
Run(context.Context, automation.Request) automation.Envelope
}

func Execute(ctx context.Context, args []string, in io.Reader, out io.Writer, runner Runner) int {
var request automation.Request
var machineMode, noninteractive, noUpdate bool
var timeout time.Duration
var envelope automation.Envelope
written := false
root := &cobra.Command{Use: "anytype", Short: "Anytype automation (schema version 1)", SilenceErrors: true, SilenceUsage: true}
root.SetArgs(args)
root.SetIn(in)
var discard bytes.Buffer
root.SetOut(&discard)
root.SetErr(&discard)
root.PersistentFlags().BoolVar(&machineMode, "json", false, "Return a versioned JSON envelope; never prompt")
root.PersistentFlags().BoolVar(&noninteractive, "non-interactive", false, "Never prompt (implied by --json)")
root.PersistentFlags().BoolVar(&noUpdate, "no-update-check", true, "No update checks in machine mode")
root.PersistentFlags().StringVar(&request.Instance, "instance", "default", "Native instance (only default is supported)")
root.PersistentFlags().StringVar(&request.ExpectedAccount, "expect-account", "", "Full expected account ID; none guards an empty instance")
root.PersistentFlags().DurationVar(&timeout, "timeout", 45*time.Second, "Whole-command deadline, 1s to 5m")
root.SetHelpFunc(func(cmd *cobra.Command, _ []string) {
envelope = automation.Success(map[string]any{"help": cmd.UsageString()}, false)
written = true
})
add := func(parent *cobra.Command, use, path string, mutating bool) *cobra.Command {
cmd := &cobra.Command{Use: use, Short: path, Args: cobra.NoArgs}
if mutating {
cmd.Flags().StringVar(&request.OperationId, "operation-id", "", "Durable operation ID; reuse it on retry")
}
cmd.RunE = func(cmd *cobra.Command, args []string) error {
request.Command = path
if len(args) == 1 {
switch path {
case "auth create", "auth apikey create":
request.Name = args[0]
case "auth apikey revoke":
request.CredentialId = args[0]
case "space status":
request.SpaceId = args[0]
}
}
if timeout < time.Second || timeout > 5*time.Minute {
envelope = automation.Failure(&automation.Error{Code: "invalid_timeout", Message: "The timeout must be between 1s and 5m.", Exit: 2})
written = true
return nil
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if request.IncludeSecret && !privateChannel(out) {
envelope = automation.Failure(&automation.Error{Code: "private_output_required", Message: "Capture --include-secret output through a pipe or an owner-only regular file.", Exit: 2})
written = true
return nil
}
keyStdin, _ := cmd.Flags().GetBool("key-stdin")
inviteStdin, _ := cmd.Flags().GetBool("invite-stdin")
if keyStdin || inviteStdin {
if !privateChannel(in) {
envelope = automation.Failure(&automation.Error{Code: "private_input_required", Message: "Supply stdin through a pipe or an owner-only regular file; machine mode never prompts.", Exit: 2})
written = true
return nil
}
type inputResult struct {
data []byte
err error
}
ch := make(chan inputResult, 1)
go func() { data, err := io.ReadAll(io.LimitReader(in, 65537)); ch <- inputResult{data, err} }()
select {
case result := <-ch:
if result.err != nil || len(result.data) > 65536 {
envelope = automation.Failure(&automation.Error{Code: "invalid_private_input", Message: "Private input could not be read or exceeded 65536 bytes.", Exit: 2})
written = true
return nil
}
request.Secret = strings.TrimSpace(string(result.data))
case <-ctx.Done():
envelope = automation.Failure(&automation.Error{Code: "private_input_timeout", Message: "Private input timed out.", Exit: 4, Retryable: true})
written = true
return nil
}
}
envelope = runner.Run(ctx, request)
written = true
return nil
}
parent.AddCommand(cmd)
return cmd
}
add(root, "inspect", "inspect", false)
add(root, "capabilities", "capabilities", false)
service := &cobra.Command{Use: "service", Short: "Inspect and provision the default user service"}
root.AddCommand(service)
add(service, "status", "service status", false)
for _, name := range []string{"install", "start"} {
cmd := add(service, name, "service "+name, true)
cmd.Flags().StringVar(&request.ListenAddress, "listen-address", "", "Explicit API listen address; existing binding is preserved")
}
auth := &cobra.Command{Use: "auth", Short: "Inspect or provision an account"}
root.AddCommand(auth)
add(auth, "status", "auth status", false)
create := add(auth, "create <name>", "auth create", true)
create.Args = cobra.ExactArgs(1)
login := add(auth, "login", "auth login", true)
login.Flags().Bool("key-stdin", false, "Read the private account key from stdin")
for _, cmd := range []*cobra.Command{create, login} {
cmd.Flags().StringVar(&request.ListenAddress, "listen-address", "", "Explicit API listen address")
cmd.Flags().StringVar(&request.NetworkConfig, "network-config", "", "Network configuration file (preserved for existing accounts)")
cmd.Flags().BoolVar(&request.IncludeSecret, "include-secret", false, "Include recovery credential in private output")
}
rename := add(auth, "rename <name>", "auth rename", false)
rename.Args = cobra.ExactArgs(1)
keys := &cobra.Command{Use: "apikey", Short: "Manage integration credentials"}
auth.AddCommand(keys)
add(keys, "list", "auth apikey list", false)
keyCreate := add(keys, "create <application-name>", "auth apikey create", true)
keyCreate.Args = cobra.ExactArgs(1)
keyCreate.Flags().StringArrayVar(&request.Grant.Spaces, "space", nil, "Full space ID or exact unique name (repeatable)")
keyCreate.Flags().BoolVar(&request.Grant.AllSpaces, "all-spaces", false, "Grant access to all current and future spaces")
keyCreate.Flags().BoolVar(&request.Grant.ReadOnly, "read-only", false, "Read-only permission")
keyCreate.Flags().BoolVar(&request.Grant.ReadWrite, "read-write", false, "Read and write permission")
keyCreate.Flags().BoolVar(&request.IncludeSecret, "include-secret", false, "Return the verified credential secret to the private caller")
revoke := add(keys, "revoke <credential-id>", "auth apikey revoke", true)
revoke.Args = cobra.ExactArgs(1)
spaces := &cobra.Command{Use: "space", Short: "Native membership; HTTP usability is probed separately"}
root.AddCommand(spaces)
add(spaces, "list", "space list", false)
spaceStatus := add(spaces, "status <space-id>", "space status", false)
spaceStatus.Args = cobra.ExactArgs(1)
join := add(spaces, "join", "space join", true)
join.Flags().Bool("invite-stdin", false, "Read a private invitation URL from stdin")
join.Flags().StringVar(&request.SpaceId, "space-id", "", "Require this invitation target (optional)")
if err := root.ExecuteContext(ctx); err != nil {
envelope = automation.Failure(&automation.Error{Code: "invalid_arguments", Message: "Invalid machine command or arguments. Use --json --help for the supported interface; secrets must use stdin.", Exit: 2})
written = true
}
if !written {
envelope = automation.Success(map[string]any{"help": root.UsageString()}, false)
}
if err := json.NewEncoder(out).Encode(envelope); err != nil {
return 1
}
return envelope.ExitCode()
}
func privateChannel(v any) bool {
f, ok := v.(*os.File)
if !ok {
return true
} // injected streams are owned by the caller
info, err := f.Stat()
if err != nil {
return false
}
if info.Mode().IsRegular() {
return localstate.CheckPrivateFile(f) == nil
}
return info.Mode()&os.ModeNamedPipe != 0 || info.Mode()&os.ModeSocket != 0
}
39 changes: 39 additions & 0 deletions cmd/machine/machine_darwin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//go:build darwin && cgo

package machine

import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/anyproto/anytype-cli/core/automation"
)

func TestSecretOutputRejectsInheritedReadACL(t *testing.T) {
dir := t.TempDir()
if out, err := exec.Command("/bin/chmod", "+a", "everyone allow read,search,file_inherit,directory_inherit", dir).CombinedOutput(); err != nil {
t.Fatalf("add fixture ACL: %v: %s", err, out)
}
file, err := os.OpenFile(filepath.Join(dir, "output.json"), os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
t.Fatal(err)
}
defer file.Close()
r := new(recorder)
code := Execute(context.Background(), []string{"auth", "create", "Agent", "--json", "--include-secret"}, strings.NewReader(""), file, r)
if code != 2 || r.calls != 0 {
t.Fatal("shared ACL passed secret-output gate", code, r.calls)
}
if _, err = file.Seek(0, 0); err != nil {
t.Fatal(err)
}
var out automation.Envelope
if err = json.NewDecoder(file).Decode(&out); err != nil || out.Error == nil || out.Error.Code != "private_output_required" {
t.Fatal("missing actionable private-output failure", out, err)
}
}
100 changes: 100 additions & 0 deletions cmd/machine/machine_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package machine

import (
"bytes"
"context"
"encoding/json"
"os"
"strings"
"testing"

"github.com/anyproto/anytype-cli/core/automation"
)

type recorder struct {
calls int
request automation.Request
}

func (r *recorder) Run(_ context.Context, request automation.Request) automation.Envelope {
r.calls++
r.request = request
return automation.Success(map[string]string{"ok": "yes"}, false)
}
func TestMachineParserPrivateChannels(t *testing.T) {
r := new(recorder)
var out bytes.Buffer
code := Execute(context.Background(), []string{"auth", "login", "--json", "--key-stdin", "--expect-account", "none", "--operation-id", "login-0001"}, strings.NewReader("private-key\n"), &out, r)
if code != 0 || r.calls != 1 || r.request.Secret != "private-key" {
t.Fatal(code, r)
}
if strings.Contains(out.String(), "private-key") {
t.Fatal("private stdin leaked")
}
var envelope map[string]any
if err := json.Unmarshal(out.Bytes(), &envelope); err != nil {
t.Fatal("non-JSON stdout", err)
}
}
func TestParseErrorsNeverEchoArguments(t *testing.T) {
for _, args := range [][]string{{"auth", "login", "private-key", "--json"}, {"space", "join", "https://invite#private-key", "--json"}, {"--json", "--private-key=secret"}, {"unknown-private-key", "--json"}, {"auth", "create", "--json"}} {
r := new(recorder)
var out bytes.Buffer
code := Execute(context.Background(), args, strings.NewReader(""), &out, r)
if code != 2 || r.calls != 0 {
t.Fatalf("parse %v: %d", args, code)
}
if strings.Contains(out.String(), "private-key") {
t.Fatal("argument echoed")
}
var envelope automation.Envelope
if err := json.Unmarshal(out.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
}
}
func TestMachineHelpIsSingleEnvelope(t *testing.T) {
r := new(recorder)
var out bytes.Buffer
if code := Execute(context.Background(), []string{"auth", "apikey", "create", "--json", "--help"}, strings.NewReader(""), &out, r); code != 0 {
t.Fatal(code)
}
var envelope automation.Envelope
if err := json.Unmarshal(out.Bytes(), &envelope); err != nil {
t.Fatal(err, out.String())
}
if r.calls != 0 || envelope.State != "ready" {
t.Fatal(envelope)
}
}
func TestSecretInputLimit(t *testing.T) {
r := new(recorder)
var out bytes.Buffer
code := Execute(context.Background(), []string{"auth", "login", "--json", "--key-stdin"}, strings.NewReader(strings.Repeat("x", 65537)), &out, r)
if code != 2 || r.calls != 0 {
t.Fatal(code)
}
}
func TestRequestedStopsAtDelimiter(t *testing.T) {
if Requested([]string{"auth", "create", "--", "--json"}) {
t.Fatal("name selected machine parser")
}
if !Requested([]string{"--json=true", "inspect"}) {
t.Fatal("json not recognized")
}
}
func TestSecretOutputRejectsPublicFile(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "output")
if err != nil {
t.Fatal(err)
}
defer file.Close()
if err = os.Chmod(file.Name(), 0644); err != nil {
t.Fatal(err)
}
r := new(recorder)
code := Execute(context.Background(), []string{"auth", "create", "Agent", "--json", "--include-secret"}, strings.NewReader(""), file, r)
if code != 2 || r.calls != 0 {
t.Fatal("public secret output accepted")
}
}
33 changes: 33 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@ package cmd

import (
"context"
"encoding/json"
"fmt"
"os"
"strings"

"github.com/anyproto/anytype-cli/cmd/machine"
"github.com/anyproto/anytype-cli/core"
"github.com/anyproto/anytype-cli/core/automation"
"github.com/anyproto/anytype-cli/core/localstate"
"github.com/anyproto/anytype-cli/core/output"
"github.com/anyproto/anytype-cli/core/updatecheck"
"github.com/spf13/cobra"
"strconv"

"github.com/anyproto/anytype-cli/cmd/auth"
"github.com/anyproto/anytype-cli/cmd/config"
Expand Down Expand Up @@ -51,10 +56,37 @@ var (
)

func Execute() {
if machine.Requested(os.Args[1:]) {
engine, closeEngine, err := automation.New()
if err != nil {
_ = json.NewEncoder(os.Stdout).Encode(automation.Failure(err))
os.Exit(1)
}
code := machine.Execute(context.Background(), os.Args[1:], os.Stdin, os.Stdout, engine)
closeEngine()
if code != 0 {
os.Exit(code)
}
return
}
var unlock func()
if localstate.HumanMutation(os.Args[1:]) && os.Getenv("ANYTYPE_CLI_LOCK_PARENT_PID") != strconv.Itoa(os.Getppid()) {
var err error
unlock, err = localstate.Lock(localstate.Dir())
if err != nil {
fmt.Fprintln(os.Stderr, "Another CLI operation is in progress or instance coordination is unavailable.")
os.Exit(1)
}
defer unlock()
}

rootCmd.SilenceErrors = true
rootCmd.SilenceUsage = true

if err := rootCmd.Execute(); err != nil {
if unlock != nil {
unlock()
}
fmt.Fprintf(os.Stderr, "✗ %v\n", err)
os.Exit(1)
}
Expand All @@ -63,6 +95,7 @@ func Execute() {
func init() {
rootCmd.Flags().BoolVarP(&versionFlag, "version", "v", false, "Show version information")
rootCmd.Flags().BoolP("help", "h", false, "Show help for command")
rootCmd.PersistentFlags().Bool("json", false, "Use the versioned noninteractive JSON interface (see anytype --json --help)")
rootCmd.PersistentFlags().BoolVar(&noUpdateCheck, "no-update-check", false, "Disable the background check for new CLI releases")

rootCmd.AddCommand(
Expand Down
Loading
Loading