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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Change Log

## 26.0.0-rc.2

* Added: `graphql query` and `graphql mutation` accept a raw GraphQL document at `--query`
* Added: `init skill` takes `--all`, `--skill`, `--agent`, and `--method` for headless installs
* Added: `--code` on `functions` and `sites create-deployment` accepts a source directory
* Fixed: Requests no longer fail at a ten second deadline, so large uploads complete
* Fixed: `--self-signed` reaches service commands instead of being dropped before the request
* Fixed: A timed out mutation reports an unknown outcome instead of an ordinary failure
* Fixed: `--documents`, `--rows`, `--columns`, `--attributes`, `--indexes`, and `--operations` parse each value as JSON
* Updated: `list-specifications` documents that `--type` defaults to `runtimes`

## 26.0.0-rc.1

* Breaking: Rewrote the CLI in Go, shipping a single binary with no runtime to install
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Once the installation is complete, you can verify the install using

```sh
$ appwrite -v
26.0.0-rc.1
26.0.0-rc.2
```

### Install using prebuilt binaries
Expand Down Expand Up @@ -83,7 +83,7 @@ $ scoop install https://raw.githubusercontent.com/appwrite/sdk-for-cli/master/sc
Once the installation completes, you can verify your install using
```
$ appwrite -v
26.0.0-rc.1
26.0.0-rc.2
```

## Getting Started
Expand Down
4 changes: 2 additions & 2 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
# You can use "View source" of this page to see the full script.

# REPO
$GITHUB_x64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/26.0.0-rc.1/appwrite-cli-win-x64.exe"
$GITHUB_arm64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/26.0.0-rc.1/appwrite-cli-win-arm64.exe"
$GITHUB_x64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/26.0.0-rc.2/appwrite-cli-win-x64.exe"
$GITHUB_arm64_URL = "https://github.com/appwrite/sdk-for-cli/releases/download/26.0.0-rc.2/appwrite-cli-win-arm64.exe"

$APPWRITE_BINARY_NAME = "appwrite.exe"

Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ verifyMacOSCodeSignature() {
downloadBinary() {
echo "[2/5] Downloading executable for $OS ($ARCH) ..."

GITHUB_LATEST_VERSION="26.0.0-rc.1"
GITHUB_LATEST_VERSION="26.0.0-rc.2"
GITHUB_FILE="appwrite-cli-${OS}-${ARCH}"
GITHUB_URL="https://github.com/$GITHUB_REPOSITORY_NAME/releases/download/$GITHUB_LATEST_VERSION/$GITHUB_FILE"

Expand Down
63 changes: 38 additions & 25 deletions internal/app/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,10 @@ import (

// Conversions between what a flag can hold and what an SDK parameter declares.
//
// A repeatable flag is []string but an untyped array parameter is
// []interface{}; an object parameter arrives as a JSON string. The generated
// call sites route through these so the conversion lives in one reviewable
// place rather than in 600 inlined expressions.

// ToAnySlice widens a repeatable string flag to an untyped slice.
//
// Returns nil for an empty input so an unset flag stays absent rather than
// becoming an empty array, which the API treats differently.
func ToAnySlice(values []string) []interface{} {
if len(values) == 0 {
return nil
}

widened := make([]interface{}, 0, len(values))
for _, value := range values {
widened = append(widened, value)
}

return widened
}
// A repeatable flag is []string but the SDK can declare a typed or untyped
// slice; an object parameter arrives as a JSON string. The generated call sites
// route through these so the conversion lives in one reviewable place rather
// than in 600 inlined expressions.

// JSONObject decodes a flag value that the SDK takes as an object.
//
Expand All @@ -52,6 +35,36 @@ func JSONObject(raw string) (interface{}, error) {
return value, nil
}

// GraphQLRequest accepts the document users naturally type at --query while
// preserving the SDK's existing JSON request-object contract. A request object
// carries variables and an operation name; an array carries a batch.
func GraphQLRequest(raw string) (interface{}, error) {
if strings.TrimSpace(raw) == "" {
return nil, fmt.Errorf("--query must be a GraphQL document or JSON request object or array")
}

if !json.Valid([]byte(raw)) {
return map[string]interface{}{"query": raw}, nil
}

decoder := json.NewDecoder(strings.NewReader(raw))
decoder.UseNumber()

var value interface{}
if err := decoder.Decode(&value); err != nil {
return nil, fmt.Errorf("invalid GraphQL JSON request: %w", err)
}

switch value := value.(type) {
case string:
return map[string]interface{}{"query": value}, nil
case map[string]interface{}, []interface{}:
return value, nil
default:
return nil, fmt.Errorf("--query must be a GraphQL document or JSON request object or array")
}
}

// WriteFile saves a downloaded file.
//
// `location` methods return the bytes rather than a URL, so there is nothing to
Expand All @@ -76,10 +89,10 @@ func WriteFile(destination string, content *[]byte) error {

// DecodeSlice parses each value of a repeatable flag into T.
//
// Needed where the SDK declares a typed slice such as []float64 or
// [][]interface{}. The TypeScript CLI hands the API raw strings and lets it
// coerce them; Go's static typing does not allow that, so the parse happens
// here instead. A malformed value therefore fails locally with a clear message
// Needed where the SDK declares a slice such as []interface{}, []float64, or
// [][]interface{}. Go's static typing does not allow the CLI to hand an API a
// string where it declares an object or number, so the parse happens here
// instead. A malformed value therefore fails locally with a clear message
// rather than as a server-side validation error.
func DecodeSlice[T any](raws []string) ([]T, error) {
if len(raws) == 0 {
Expand Down
7 changes: 4 additions & 3 deletions internal/app/globals.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ func RegisterGlobalFlags(root *cobra.Command) {
//
// Hidden at the root for the same reason it is hidden there in the
// TypeScript (`new Option('-a, --all', ...).hideHelp()`): it is parsed
// globally so `appwrite --all push` keeps working, but it acts on `push` and
// `pull`, which is where it is documented.
flags.BoolVar(&globals.All, "all", false, "Push or pull every resource")
// globally so `appwrite --all push` and `appwrite --all init skill` keep
// working, but it is documented only on commands where it selects resources
// or skills.
flags.BoolVar(&globals.All, "all", false, "Select every applicable resource or skill")
if flag := flags.Lookup("all"); flag != nil {
flag.Hidden = true
}
Expand Down
52 changes: 52 additions & 0 deletions internal/app/inputfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import (
"path/filepath"

sdkfile "github.com/appwrite/sdk-for-go/v6/file"

"github.com/appwrite/sdk-for-cli/internal/config"
"github.com/appwrite/sdk-for-cli/internal/deploy"
"github.com/appwrite/sdk-for-cli/internal/output"
)

// InputFile turns a path into the SDK's upload type.
Expand All @@ -27,3 +31,51 @@ func InputFile(path string) (sdkfile.InputFile, error) {

return sdkfile.NewInputFile(resolved, filepath.Base(resolved)), nil
}

// DeploymentInputFile turns a deployment archive or source directory into the
// SDK's upload type. Directories use the same packaging rules as `push`, and
// the returned cleanup must run after the SDK has finished reading the file.
func DeploymentInputFile(path string) (sdkfile.InputFile, func(), error) {
if path == "" {
return sdkfile.InputFile{}, func() {}, fmt.Errorf("a file or directory path is required")
}

resolved, err := filepath.Abs(path)
if err != nil {
return sdkfile.InputFile{}, func() {}, err
}
info, err := os.Stat(resolved)
if err != nil {
return sdkfile.InputFile{}, func() {}, fmt.Errorf("cannot read %q: %w", path, err)
}
if !info.IsDir() {
return sdkfile.NewInputFile(resolved, filepath.Base(resolved)), func() {}, nil
}

packaged, err := deploy.PackageDirectory(
resolved,
nil,
inputFileProjectRoot(),
func(message string) { output.Warn(os.Stderr, "%s", message) },
)
if err != nil {
return sdkfile.InputFile{}, func() {}, err
}

cleanup := func() { _ = packaged.Remove() }

return sdkfile.NewInputFile(packaged.Path, deploy.ArchiveName), cleanup, nil
}

// inputFileProjectRoot mirrors the TypeScript CLI's best-effort local config
// lookup. It bounds followed symlinks when the command runs inside a project,
// while still allowing a directory passed from outside any project.
func inputFileProjectRoot() string {
path := config.FindLocalPath()
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return ""
}

return filepath.Dir(path)
}
65 changes: 65 additions & 0 deletions internal/app/inputfile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package app

import (
"os"
"path/filepath"
"testing"
)

func TestDeploymentInputFilePassesThroughAnArchive(t *testing.T) {
archive := filepath.Join(t.TempDir(), "existing.tar.gz")
if err := os.WriteFile(archive, []byte("archive"), 0o644); err != nil {
t.Fatal(err)
}

input, cleanup, err := DeploymentInputFile(archive)
if err != nil {
t.Fatal(err)
}
cleanup()

if input.Path != archive {
t.Fatalf("input path %q, want %q", input.Path, archive)
}
if input.Name != "existing.tar.gz" {
t.Fatalf("input name %q, want existing.tar.gz", input.Name)
}
if _, err := os.Stat(archive); err != nil {
t.Fatalf("cleanup removed the caller's archive: %v", err)
}
}

func TestDeploymentInputFilePackagesAndCleansUpADirectory(t *testing.T) {
directory := t.TempDir()
if err := os.WriteFile(filepath.Join(directory, "main.js"), []byte("export default () => {};"), 0o644); err != nil {
t.Fatal(err)
}

input, cleanup, err := DeploymentInputFile(directory)
if err != nil {
t.Fatal(err)
}

if input.Path == directory {
t.Fatal("directory was passed through instead of packaged")
}
if input.Name != "code.tar.gz" {
t.Fatalf("input name %q, want code.tar.gz", input.Name)
}
if _, err := os.Stat(input.Path); err != nil {
t.Fatalf("packaged archive is unavailable before cleanup: %v", err)
}

cleanup()
if _, err := os.Stat(input.Path); !os.IsNotExist(err) {
t.Fatalf("packaged archive still exists after cleanup: %v", err)
}
}

func TestDeploymentInputFileRejectsAMissingPath(t *testing.T) {
missing := filepath.Join(t.TempDir(), "missing")

if _, _, err := DeploymentInputFile(missing); err == nil {
t.Fatal("missing path was accepted")
}
}
52 changes: 52 additions & 0 deletions internal/app/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,65 @@ package app

import (
"bytes"
"reflect"
"strings"
"testing"

"github.com/appwrite/sdk-for-cli/internal/output"
"github.com/appwrite/sdk-for-cli/internal/sdk"
)

func TestGraphQLRequestAcceptsDocumentsEnvelopesAndBatches(t *testing.T) {
tests := []struct {
name string
raw string
want interface{}
}{
{
name: "document",
raw: "query { __typename }",
want: map[string]interface{}{"query": "query { __typename }"},
},
{
name: "envelope",
raw: `{"query":"query Named($id: ID!) { node(id: $id) { id } }","variables":{"id":"one"},"operationName":"Named"}`,
want: map[string]interface{}{
"query": "query Named($id: ID!) { node(id: $id) { id } }",
"variables": map[string]interface{}{"id": "one"},
"operationName": "Named",
},
},
{
name: "batch",
raw: `[{"query":"query { __typename }"},{"query":"query { __typename }"}]`,
want: []interface{}{
map[string]interface{}{"query": "query { __typename }"},
map[string]interface{}{"query": "query { __typename }"},
},
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := GraphQLRequest(test.raw)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, test.want) {
t.Errorf("GraphQLRequest() = %#v, want %#v", got, test.want)
}
})
}
}

func TestGraphQLRequestRejectsEmptyAndScalarJSON(t *testing.T) {
for _, raw := range []string{"", "true", "42", "null"} {
if _, err := GraphQLRequest(raw); err == nil {
t.Errorf("GraphQLRequest(%q) succeeded", raw)
}
}
}

// The captured response, not the typed struct, is what --raw and --json show.
//
// internal/sdk tests the transport and internal/output tests the renderers;
Expand Down
19 changes: 18 additions & 1 deletion internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,27 @@ func baseTransport() *http.Transport {
return transport
}

// NewHTTPClient returns the HTTP policy shared by the CLI's direct requests
// and its generated SDK-backed service commands.
//
// A whole-request deadline makes a slow upload fail even while it is still
// making progress. The transport instead bounds the individual connection and
// response-header phases. Self-signed verification is configured before any
// caller wraps the transport, because the generated SDK cannot see through a
// recording RoundTripper to change it later.
func NewHTTPClient(selfSigned bool) *http.Client {
transport := baseTransport()
if selfSigned {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}

return &http.Client{Transport: transport}
}

func New(endpoint, sdkVersion string) *Client {
return &Client{
Endpoint: strings.TrimRight(endpoint, "/"),
HTTP: &http.Client{Transport: baseTransport()},
HTTP: NewHTTPClient(false),
SDKVersion: sdkVersion,
headers: map[string]string{
"content-type": "application/json",
Expand Down
Loading