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
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,15 @@ make build

### Windows SmartScreen

The Windows binary is unsigned. On first run SmartScreen may block it with
"Windows protected your PC". Click **More info → Run anyway**. To avoid the
prompt altogether, unblock the executable before running:
Official release binaries are Authenticode-signed via Azure Trusted Signing
(see the `sign-windows` job in `.github/workflows/release.yml`), so
SmartScreen should not prompt when running an `ana.exe` downloaded from
GitHub Releases.

Binaries built from source (`go install` / `make build`) are **not** signed
and SmartScreen may still block them on first run with "Windows protected
your PC". Click **More info → Run anyway**, or unblock the file before
running:

```powershell
Unblock-File -Path .\ana.exe
Expand Down Expand Up @@ -74,6 +80,11 @@ ana chat send "show me last month's revenue"

Run `ana --help` or `ana <verb> --help` for command-specific flags.

Connector-create commands are structured as `dialect auth-mode` subcommands
(e.g. `ana connector create postgres password --name prod --host … --user …
--database … --password-stdin`) so new dialects and auth modes land as
additions rather than growing a conditional flag matrix.

## Configuration

`ana` stores tokens and per-profile endpoints at
Expand Down
2 changes: 1 addition & 1 deletion docs/cli-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ ana auth keys rotate <id>
ana auth keys revoke <id>

ana connector list
ana connector create --type postgres --name X --host ... --db ... # runs Test, then Create
ana connector create postgres password --name X --host ... --database ... # dialect + auth-mode subtree; runs Test, then Create
ana connector test <id>
ana connector update <id> --name X
ana connector delete <id>
Expand Down
8 changes: 5 additions & 3 deletions internal/connector/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ The `ana connector` verb tree: `list`, `get`, `create`, `update`, `delete`, `tes
- `connector.go` — `New`, `Deps`, service path prefix.
- `types.go` — shared wire shapes consumed by create + update: `createReq`, `updateReq`, `configEnvelope`, `postgresSpec`, `createResp`, `getConnectorResp`. Per-dialect spec types will land alongside (`types_snowflake.go`, etc.) as new dialects are probed.
- `list.go` / `get.go` — `GetConnectors` / `GetConnector` (readonly).
- `create.go` — `CreateConnector`. Postgres dialect verified; other dialects assumed from captured samples.
- `update.go` — `UpdateConnector`. Pre-fetches the baseline so interleaved flags merge correctly (see commit `1433e01`).
- `create.go` — `newCreateGroup` (dialect-selector Group) + the shared `resolvePassword` helper reused by per-dialect password leaves and `update.go`.
- `create_postgres.go` — `newPostgresCreateGroup` (Postgres dialect Group whose inheritable `Flags` closure owns `--name`/`--ssl`) and `postgresPasswordCmd` (leaf for the `password` auth mode). Implements `cli.Flagger` so `--help` enumerates own + ancestor flags. Additional Postgres auth modes (key-based, cert-based) land as sibling leaf files — no reshuffling.
- `update.go` — `UpdateConnector`. Pre-fetches the baseline so interleaved flags merge correctly (see commit `1433e01`). Still a flat leaf today; dialect-aware validation + auth-mode-swap subtree are Phase 3g work.
- `delete.go`, `test.go` (TestConnector), `tables.go` (ListConnectorTables), `examples.go` (GetExampleQueries) — remaining CRUD + diagnostic verbs.
- `connector_test.go` — shared `fakeDeps`, `errReader`, `TestNew*`/`TestHelp*`.
- `list_test.go` / `get_test.go` / `create_test.go` / `update_test.go` / `delete_test.go` / `test_test.go` / `tables_test.go` / `examples_test.go` — one per source file. `create_test.go` carries the `requiredFlags()` builder used by the create cases; `update_test.go` covers the baseline-merge path via `cli.FlagWasSet`.
- `create_postgres_password_test.go` — covers the Postgres password leaf end-to-end by dispatching through `newCreateGroup` so ancestor-flag plumbing (`--name`, `--ssl` from the Postgres Group) is exercised the same way real CLI dispatch does. `requiredArgs()` builder starts with `"postgres", "password"` because every test routes through the Group.
- `list_test.go` / `get_test.go` / `update_test.go` / `delete_test.go` / `test_test.go` / `tables_test.go` / `examples_test.go` — one per non-create source file.
2 changes: 1 addition & 1 deletion internal/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func New(deps Deps) *cli.Group {
Children: map[string]cli.Command{
"list": &listCmd{deps: deps},
"get": &getCmd{deps: deps},
"create": &createCmd{deps: deps},
"create": newCreateGroup(deps),
"update": &updateCmd{deps: deps},
"delete": &deleteCmd{deps: deps},
"test": &testCmd{deps: deps},
Expand Down
1 change: 0 additions & 1 deletion internal/connector/connector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ func TestHelpStringsNonEmpty(t *testing.T) {
cases := map[string]cli.Command{
"list": &listCmd{},
"get": &getCmd{},
"create": &createCmd{},
"update": &updateCmd{},
"delete": &deleteCmd{},
"test": &testCmd{},
Expand Down
87 changes: 16 additions & 71 deletions internal/connector/create.go
Original file line number Diff line number Diff line change
@@ -1,84 +1,27 @@
package connector

import (
"context"
"fmt"
"io"

"github.com/highperformance-tech/ana-cli/internal/cli"
)

// createCmd implements `ana connector create` — POST CreateConnector.
// v1 only supports the `postgres` dialect; other values are a usage error.
type createCmd struct{ deps Deps }

func (c *createCmd) Help() string {
return "create Create a new connector (postgres only in v1).\n" +
"Usage: ana connector create --type postgres --name <name> --host <h> --port <p> --user <u> (--password-stdin|--password <p>) --database <db> [--ssl]"
}

func (c *createCmd) Run(ctx context.Context, args []string, stdio cli.IO) error {
fs := cli.NewFlagSet("connector create")
var typ string
fs.Var(cli.EnumFlag(&typ, []string{"postgres"}), "type", "connector type (postgres — required)")
name := fs.String("name", "", "connector name (required)")
host := fs.String("host", "", "database host (required)")
port := fs.Int("port", 0, "database port (required)")
user := fs.String("user", "", "database user (required)")
pass := fs.String("password", "", "database password (discouraged; prefer --password-stdin)")
passStdin := fs.Bool("password-stdin", false, "read password from the first stdin line")
database := fs.String("database", "", "database name (required)")
ssl := fs.Bool("ssl", false, "enable SSL/TLS")
if err := cli.ParseFlags(fs, args); err != nil {
return err
}
if err := cli.RequireFlags(fs, "connector create", "type", "name", "host", "port", "user", "database"); err != nil {
return err
}
// RequireFlags only checks that the flag was set, not its value. Guard
// against explicit empties (`--name ""`) and out-of-range ports so we
// fail fast with a local usage error rather than surfacing a server-side
// error. Deterministic order so tests can assert which flag triggered.
for _, p := range []struct {
name, val string
}{{"name", *name}, {"host", *host}, {"user", *user}, {"database", *database}} {
if p.val == "" {
return cli.UsageErrf("connector create: --%s must not be empty", p.name)
}
}
if *port <= 0 || *port > 65535 {
return cli.UsageErrf("connector create: --port must be in 1..65535 (got %d)", *port)
}
resolvedPass, err := resolvePassword(*pass, *passStdin, stdio.Stdin)
if err != nil {
return fmt.Errorf("connector create: %w", err)
}

req := createReq{Config: configEnvelope{
ConnectorType: "POSTGRES",
Name: *name,
Postgres: &postgresSpec{
Host: *host,
Port: *port,
User: *user,
Password: resolvedPass,
Database: *database,
SSLMode: *ssl,
// newCreateGroup wires the `ana connector create` subtree. Each child is a
// Group for a specific dialect; each dialect's children are leaves for each
// supported auth mode. The two-level shape scales: adding a dialect is a new
// file, adding an auth mode is a sibling leaf under the dialect Group — no
// N×M conditional matrix on a flat `--type`/`--auth` flag pair.
//
// Breaking change from v0.x: `ana connector create --type postgres …` became
// `ana connector create postgres password …`.
func newCreateGroup(deps Deps) *cli.Group {
return &cli.Group{
Summary: "Create a new connector. Pick a dialect, then an auth mode.",
Children: map[string]cli.Command{
"postgres": newPostgresCreateGroup(deps),
},
}}
var raw map[string]any
if err := c.deps.Unary(ctx, servicePath+"/CreateConnector", req, &raw); err != nil {
return fmt.Errorf("connector create: %w", err)
}
var typed createResp
if err := cli.RenderOutput(stdio.Stdout, raw, cli.GlobalFrom(ctx).JSON, &typed, func(w io.Writer, t *createResp) error {
_, err := fmt.Fprintf(w, "connectorId: %d\nname: %s\nconnectorType: %s\n",
t.ConnectorID, t.Name, t.ConnectorType)
return err
}); err != nil {
return fmt.Errorf("connector create: %w", err)
}
return nil
}

// resolvePassword resolves the password from either --password-stdin (reads
Expand All @@ -88,14 +31,16 @@ func (c *createCmd) Run(ctx context.Context, args []string, stdio cli.IO) error
// surrounding whitespace is intentional: a password may legitimately start or
// end with spaces/tabs, and silently trimming would cause hard-to-diagnose
// auth failures.
//
// Lives in create.go rather than a per-dialect file because update.go also
// reuses it when --password{,-stdin} is supplied on an edit.
func resolvePassword(passFlag string, stdinFlag bool, r io.Reader) (string, error) {
if stdinFlag {
pass, err := cli.ReadPassword(r)
if err != nil {
return "", fmt.Errorf("read password: %w", err)
}
if pass == "" {
// Empty stream is a usage error; the flag explicitly promised a line.
return "", cli.UsageErrf("--password-stdin set but stdin was empty")
}
return pass, nil
Expand Down
127 changes: 127 additions & 0 deletions internal/connector/create_postgres.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package connector

import (
"context"
"flag"
"fmt"
"io"

"github.com/highperformance-tech/ana-cli/internal/cli"
)

// newPostgresCreateGroup returns the Postgres create-dialect Group. Flags
// common to every Postgres auth-mode leaf (`--name`, `--ssl`) are declared on
// the Group's inheritable Flags closure; each auth-mode leaf (today only
// `password`) declares its own dialect-specific flags and reads the Group's
// via cli.ApplyAncestorFlags.
//
// The shared `name`/`ssl` vars live in this builder's closure: the CLI is
// single-shot so one set of mutable targets per-Group is fine. If we ever
// need concurrent invocations we'd allocate them per-Run instead.
func newPostgresCreateGroup(deps Deps) *cli.Group {
var name string
var ssl bool
return &cli.Group{
Summary: "Create a Postgres connector. Pick an auth mode.",
Flags: func(fs *flag.FlagSet) {
cli.DeclareString(fs, &name, "name", "", "connector name (required)")
cli.DeclareBool(fs, &ssl, "ssl", false, "enable SSL/TLS")
},
Children: map[string]cli.Command{
"password": &postgresPasswordCmd{deps: deps, name: &name, ssl: &ssl},
},
}
}

// postgresPasswordCmd is the leaf for `ana connector create postgres password`.
// name/ssl are pointers into the parent Group's Flags closure state — the
// Group's inheritable flag registrar binds --name/--ssl on the leaf's fs to
// those addresses, so reading them here after ParseFlags is equivalent to
// reading any other flag target.
type postgresPasswordCmd struct {
deps Deps
name *string
ssl *bool

// Leaf-specific flag targets. Declared in Flags(fs) so both --help and
// Run see the same binding.
host string
port int
user string
database string
password string
passStdin bool
}

func (c *postgresPasswordCmd) Help() string {
return "password Password-based Postgres auth.\n" +
"Usage: ana connector create postgres password --name <n> --host <h> --port <p> --user <u> --database <db> (--password <p>|--password-stdin) [--ssl]"
}

// Flags declares this leaf's own flags on fs. cli.dispatchChild runs this
// plus ApplyAncestorFlags when rendering --help, so the leaf's Flags: block
// lists both its own and the Postgres Group's --name/--ssl.
func (c *postgresPasswordCmd) Flags(fs *flag.FlagSet) {
fs.StringVar(&c.host, "host", "", "database host (required)")
fs.IntVar(&c.port, "port", 0, "database port (required)")
fs.StringVar(&c.user, "user", "", "database user (required)")
fs.StringVar(&c.database, "database", "", "database name (required)")
fs.StringVar(&c.password, "password", "", "database password (discouraged; prefer --password-stdin)")
fs.BoolVar(&c.passStdin, "password-stdin", false, "read password from the first stdin line")
}

func (c *postgresPasswordCmd) Run(ctx context.Context, args []string, stdio cli.IO) error {
fs := cli.NewFlagSet("connector create postgres password")
c.Flags(fs)
cli.ApplyAncestorFlags(ctx, fs)
if err := cli.ParseFlags(fs, args); err != nil {
return err
}
if err := cli.RequireFlags(fs, "connector create postgres password",
"name", "host", "port", "user", "database"); err != nil {
return err
}
// RequireFlags only checks presence; reject explicit empties for fields
// where "" is meaningless, and clamp port to the TCP range so a local
// usage error beats a server-side rejection.
for _, p := range []struct {
name, val string
}{{"name", *c.name}, {"host", c.host}, {"user", c.user}, {"database", c.database}} {
if p.val == "" {
return cli.UsageErrf("connector create postgres password: --%s must not be empty", p.name)
}
}
if c.port <= 0 || c.port > 65535 {
return cli.UsageErrf("connector create postgres password: --port must be in 1..65535 (got %d)", c.port)
}
resolvedPass, err := resolvePassword(c.password, c.passStdin, stdio.Stdin)
if err != nil {
return fmt.Errorf("connector create postgres password: %w", err)
}

req := createReq{Config: configEnvelope{
ConnectorType: "POSTGRES",
Name: *c.name,
Postgres: &postgresSpec{
Host: c.host,
Port: c.port,
User: c.user,
Password: resolvedPass,
Database: c.database,
SSLMode: *c.ssl,
},
}}
var raw map[string]any
if err := c.deps.Unary(ctx, servicePath+"/CreateConnector", req, &raw); err != nil {
return fmt.Errorf("connector create postgres password: %w", err)
}
var typed createResp
if err := cli.RenderOutput(stdio.Stdout, raw, cli.GlobalFrom(ctx).JSON, &typed, func(w io.Writer, t *createResp) error {
_, err := fmt.Fprintf(w, "connectorId: %d\nname: %s\nconnectorType: %s\n",
t.ConnectorID, t.Name, t.ConnectorType)
return err
}); err != nil {
return fmt.Errorf("connector create postgres password: %w", err)
}
return nil
}
Loading
Loading