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
22 changes: 14 additions & 8 deletions cli/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,25 @@ cli/
├── helpers.go # getClient(cmd), checkResponse, etc.
├── output.go # shared output framework (stdout/stderr split, table|json, color, confirm)
├── activity.go # `orva activity`
├── backup.go # `orva backup download/restore`
├── channels.go # `orva channels …`
├── chat.go # `orva chat` (interactive AI REPL + one-shot -p, SSE)
├── completion.go # `orva completion {bash|zsh|fish|powershell}`
├── completions.go # dynamic shell completions (fn names, runtimes, models)
├── cron.go # `orva cron …`
├── deploy.go # `orva deploy <path> [--watch]`
├── deploy.go # `orva deploy <path> [--follow]` (--watch = deprecated alias)
├── deployments.go # `orva deployments list/get/logs`
├── diff.go # `orva diff <function>` (unified diff between deployments)
├── dns.go # `orva dns get/set`
├── docs.go # `orva docs` (renders embedded docs/reference.md)
├── executions.go # `orva executions list/get/logs/delete/prune/replay`
├── firewall.go # `orva firewall list/add/enable/disable/delete/resolve`
├── fixtures.go # `orva fixtures list/get/save/delete/test`
├── functions.go # `orva functions …`
├── invoke.go # `orva invoke <name>` (--body/--stream/--route/-H/-X)
├── jobs.go # `orva jobs …`
├── keys.go # `orva keys …`
├── kv.go # `orva kv `
├── kv.go # `orva kv list/get/put/delete/incr/cas`
├── login.go # `orva login --endpoint --api-key [--test]`
├── logs.go # `orva logs [--follow]` (SSE)
├── pool.go # `orva pool get/set` (per-fn warm-pool autoscaler)
Expand Down Expand Up @@ -129,9 +131,10 @@ is built by `make build` from `./backend/cmd/orva`.
- **No `os.Exit` inside subcommand bodies.** Use `RunE` and return errors
so tests can observe failures. The existing `Run`-style commands are
pre-refactor; new commands should use `RunE`.
- **Add new subcommands to `clientFactories` in `root.go`.** Otherwise
they won't show up — and `cli/commands/commands_test.go::TestCommandTree`
will fail in CI.
- **Register new top-level commands in `RegisterClient` (`root.go`).** Add the
`*Cmd` var to the `root.AddCommand(...)` list and give it a group in
`commandGroups()`. Otherwise it won't show up — and
`cli/commands/commands_test.go::TestCommandTree` will fail in CI.

## Testing

Expand Down Expand Up @@ -160,11 +163,14 @@ weekly schedule to catch GH-API / release-asset drift.

1. Create `cli/commands/<name>.go` with `package commands`.
2. Define `var <name>Cmd = &cobra.Command{…}` + an `init()` for flags.
3. Add `<name>Cmd` to the `clientFactories` slice in `root.go`.
3. Add `<name>Cmd` to the `root.AddCommand(...)` list in `RegisterClient` (`root.go`)
and assign it a group in `commandGroups()`.
4. Add the leaf path to `commands_test.go::TestCommandTree`'s `paths` list.
5. Add any required flags to `TestRequiredFlagsPresent`.
6. Run `go test ./cli/commands/` — should pass.
7. Run `bash test/cli/command-tree.sh` — golden diff should remain zero.
6. (If a subcommand takes a function name) wire fn-name completion in
`wireCompletions` (`completions.go`).
7. Run `go test ./cli/commands/` — should pass.
8. Run `bash test/cli/command-tree.sh` — golden diff should remain zero.

## Self-update (`orva upgrade`)

Expand Down
69 changes: 49 additions & 20 deletions cli/commands/channels.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,51 +26,77 @@ an agentic workflow without giving it Orva management.
var channelsListCmd = &cobra.Command{
Use: "list",
Short: "List agent channels",
RunE: runChannelsList,
Long: `List agent channels (function bundles exposed at /mcp under a static token).

orva channels list
orva channels list -o json | jq '.[].name'`,
RunE: runChannelsList,
}

var channelsCreateCmd = &cobra.Command{
Use: "create [name]",
Use: "create <name>",
Short: "Create a new channel",
Long: "Create a channel. The token plaintext is printed once and never shown again.",
Args: cobra.ExactArgs(1),
RunE: runChannelsCreate,
Long: `Create a channel bundling one or more functions under a static bearer token.
The token plaintext is printed once and never shown again — store it.

orva channels create prod --functions greeter,echo
orva channels create ci --functions deploy-hook --expires-in-days 90`,
Args: cobra.ExactArgs(1),
RunE: runChannelsCreate,
}

var channelsShowCmd = &cobra.Command{
Use: "show [id|name]",
Use: "show <id|name>",
Short: "Show a channel + its function set",
Args: cobra.ExactArgs(1),
RunE: runChannelsShow,
Long: `Show a channel's metadata and the functions it exposes.

orva channels show prod`,
Args: cobra.ExactArgs(1),
RunE: runChannelsShow,
}

var channelsAddFunctionsCmd = &cobra.Command{
Use: "add-functions [id|name] [fn1] [fn2] ...",
Use: "add-functions <id|name> <fn>...",
Short: "Add functions to a channel",
Args: cobra.MinimumNArgs(2),
RunE: runChannelsAddFunctions,
Long: `Add one or more functions (by id or name) to an existing channel.

orva channels add-functions prod echo summarize`,
Args: cobra.MinimumNArgs(2),
RunE: runChannelsAddFunctions,
}

var channelsRemoveFunctionsCmd = &cobra.Command{
Use: "remove-functions [id|name] [fn1] [fn2] ...",
Use: "remove-functions <id|name> <fn>...",
Short: "Remove functions from a channel",
Args: cobra.MinimumNArgs(2),
RunE: runChannelsRemoveFunctions,
Long: `Remove one or more functions (by id or name) from a channel.

orva channels remove-functions prod echo`,
Args: cobra.MinimumNArgs(2),
RunE: runChannelsRemoveFunctions,
}

var channelsRotateCmd = &cobra.Command{
Use: "rotate [id|name]",
Use: "rotate <id|name>",
Short: "Rotate the channel's token (invalidates the old one)",
Args: cobra.ExactArgs(1),
RunE: runChannelsRotate,
Long: `Issue a fresh token for the channel and invalidate the old one. The new
token plaintext is printed once.

orva channels rotate prod`,
Args: cobra.ExactArgs(1),
RunE: runChannelsRotate,
}

var channelsDeleteCmd = &cobra.Command{
Use: "delete [id|name]",
Use: "delete <id|name>",
Aliases: []string{"rm"},
Short: "Delete a channel",
Args: cobra.ExactArgs(1),
RunE: runChannelsDelete,
Long: `Delete a channel and invalidate its token. Prompts for confirmation unless
--yes is passed.

orva channels delete prod
orva channels delete prod --yes`,
Args: cobra.ExactArgs(1),
RunE: runChannelsDelete,
}

func init() {
Expand Down Expand Up @@ -317,6 +343,9 @@ func runChannelsDelete(cmd *cobra.Command, args []string) error {
return err
}
resp.Body.Close()
if outputJSON(cmd) {
return emitJSON(map[string]any{"deleted": true, "id": id, "name": args[0]})
}
okf(cmd, "Channel %s deleted.", args[0])
return nil
}
Expand Down
1 change: 1 addition & 0 deletions cli/commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ func TestRequiredFlagsPresent(t *testing.T) {
{[]string{"deploy"}, "runtime"},
{[]string{"deploy"}, "entrypoint"},
{[]string{"deploy"}, "watch"},
{[]string{"deploy"}, "follow"},
{[]string{"invoke"}, "body"},
{[]string{"invoke"}, "method"},
{[]string{"invoke"}, "header"},
Expand Down
3 changes: 3 additions & 0 deletions cli/commands/cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,9 @@ func runCronDelete(cmd *cobra.Command, args []string) error {
}
resp.Body.Close()

if outputJSON(cmd) {
return emitJSON(map[string]any{"deleted": true, "id": id})
}
okf(cmd, "Deleted cron schedule %s", id)
return nil
}
Expand Down
13 changes: 10 additions & 3 deletions cli/commands/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ auto-detects the entrypoint:

Examples:
orva deploy ./src --name greeter --runtime node
orva deploy ./src --name greeter --runtime node --watch # stream build logs`,
orva deploy ./src --name greeter --runtime node --follow # stream build logs`,
Args: cobra.ExactArgs(1),
RunE: runDeploy,
}
Expand All @@ -39,7 +39,11 @@ func init() {
deployCmd.Flags().String("name", "", "function name (required)")
deployCmd.Flags().String("runtime", "", "runtime: node or python (required)")
deployCmd.Flags().String("entrypoint", "", "entrypoint file (optional; auto-detects handler.ts when tsconfig.json + handler.ts present)")
deployCmd.Flags().Bool("watch", false, "stream build logs and wait for the deploy to finish (non-zero exit on build failure)")
// --follow/-f matches logs/activity/deployments-logs; --watch is the
// original name, kept as a hidden alias for back-compat.
deployCmd.Flags().BoolP("follow", "f", false, "stream build logs and wait for the deploy to finish (non-zero exit on build failure)")
deployCmd.Flags().Bool("watch", false, "deprecated alias for --follow")
_ = deployCmd.Flags().MarkHidden("watch")
deployCmd.MarkFlagRequired("name")
deployCmd.MarkFlagRequired("runtime")
}
Expand All @@ -54,7 +58,10 @@ func runDeploy(cmd *cobra.Command, args []string) error {
name, _ := cmd.Flags().GetString("name")
runtime, _ := cmd.Flags().GetString("runtime")
entrypoint, _ := cmd.Flags().GetString("entrypoint")
watch, _ := cmd.Flags().GetBool("watch")
watch, _ := cmd.Flags().GetBool("follow")
if w, _ := cmd.Flags().GetBool("watch"); w {
watch = true // honor the deprecated --watch alias
}

// Verify the source path exists.
info, err := os.Stat(srcPath)
Expand Down
7 changes: 7 additions & 0 deletions cli/commands/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ func runDNSSet(cmd *cobra.Command, _ []string) error {
records = append(records, dnsRecord{Host: name, IP: ip})
}

// This replaces the entire DNS config — omitted flags clear their section
// (e.g. passing no --server wipes all servers). Confirm so it can't silently
// nuke the config; --yes skips the prompt and is required on a non-TTY.
if err := confirm(cmd, "Replace the sandbox DNS config? (omitted flags clear their section)"); err != nil {
return err
}

body := map[string]any{
"servers": cleanServers,
"search": strings.TrimSpace(search),
Expand Down
4 changes: 2 additions & 2 deletions cli/commands/firewall.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ func runFirewallResolve(cmd *cobra.Command, args []string) error {
// applied set when the host isn't a configured hostname rule.
if ips, ok := result.Status.HostnameMap[hostname]; ok {
if len(ips) == 0 {
fmt.Printf("(%s resolved to no addresses)\n", hostname)
infof(cmd, "%s resolved to no addresses", hostname)
return nil
}
for _, ip := range ips {
Expand All @@ -344,7 +344,7 @@ func runFirewallResolve(cmd *cobra.Command, args []string) error {
infof(cmd, "%q is not a configured hostname rule; showing the full applied set:", hostname)
all := append(append([]string{}, result.Status.IPv4...), result.Status.IPv6...)
if len(all) == 0 {
fmt.Println("(no resolved IPs in the applied set)")
infof(cmd, "no resolved IPs in the applied set")
return nil
}
for _, ip := range all {
Expand Down
5 changes: 4 additions & 1 deletion cli/commands/fixtures.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ func runFixturesGet(cmd *cobra.Command, args []string) error {
ht.flush()
}
if f.Body != "" {
fmt.Println("\nbody:")
infof(cmd, "\nbody:") // label → stderr; the body itself stays on stdout for piping
fmt.Println(f.Body)
}
return nil
Expand Down Expand Up @@ -334,6 +334,9 @@ func runFixturesDelete(cmd *cobra.Command, args []string) error {
}
resp.Body.Close()

if outputJSON(cmd) {
return emitJSON(map[string]any{"deleted": true, "name": name})
}
okf(cmd, "deleted fixture %q", name)
return nil
}
Expand Down
9 changes: 9 additions & 0 deletions cli/commands/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ func checkResponse(resp *http.Response) error {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)

// Auth failures are the most common first-run stumble — point at the fix
// instead of a bare "API error (401)".
switch resp.StatusCode {
case http.StatusUnauthorized:
return fmt.Errorf("not authenticated (HTTP 401) — run `orva login`, or pass --endpoint/--api-key (or set ORVA_ENDPOINT / ORVA_API_KEY)")
case http.StatusForbidden:
return fmt.Errorf("not authorized (HTTP 403) — the API key is valid but lacks the required permission for this command")
}

var errResp struct {
Error struct {
Code string `json:"code"`
Expand Down
50 changes: 50 additions & 0 deletions cli/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,5 +146,55 @@ func RegisterClient(root *cobra.Command) {
for cmd, group := range commandGroups() {
cmd.GroupID = group
}
addConvenienceAliases(root)
wireCompletions(root)
}

// addConvenienceAliases adds the reflexive aliases developers reach for:
// short/singular names for the noun groups (fn, secret, route, key) and
// `ls`/`rm`/`del` on every list/delete leaf. Purely additive — primary names
// are unchanged, so scripts and the command-tree golden test are unaffected.
func addConvenienceAliases(root *cobra.Command) {
top := map[string][]string{
"functions": {"fn", "fns"},
"secrets": {"secret"},
"routes": {"route"},
"keys": {"key"},
}
for _, c := range root.Commands() {
if a, ok := top[c.Name()]; ok {
c.Aliases = appendMissing(c.Aliases, a...)
}
}
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
for _, sub := range c.Commands() {
switch sub.Name() {
case "list":
sub.Aliases = appendMissing(sub.Aliases, "ls")
case "delete":
sub.Aliases = appendMissing(sub.Aliases, "rm", "del")
}
walk(sub)
}
}
walk(root)
}

// appendMissing appends each value not already present (alias de-dup so we
// never hand cobra a duplicate, which it rejects).
func appendMissing(have []string, add ...string) []string {
for _, a := range add {
found := false
for _, h := range have {
if h == a {
found = true
break
}
}
if !found {
have = append(have, a)
}
}
return have
}
7 changes: 7 additions & 0 deletions cli/commands/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,13 @@ func runSystemVacuum(cmd *cobra.Command, args []string) error {
return err
}

// VACUUM holds an exclusive lock — writes block until it finishes. Confirm
// so it isn't run by accident; --yes skips the prompt and is required on a
// non-TTY.
if err := confirm(cmd, "Run VACUUM now? Writes to the database will block until it completes."); err != nil {
return err
}

infof(cmd, "Running VACUUM (writes will block briefly)...")
resp, err := client.Post("/api/v1/system/vacuum", nil)
if err != nil {
Expand Down
Loading
Loading