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
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,37 @@ nan runs ls [--ws NAME] [--state S] [--limit 20] [--json] # newest first
nan runs show <id> [--json] # state, result, prompt
nan runs logs <id> [-f] [--after N] [--json] # -f follows until it ends
nan runs cancel <id> [--json]
nan runs proposal <id> [--diff] [--json] # what an automation run proposed
nan runs reject <id> [--reason R] # turn the proposal down
```

### Automations, approvals and secrets

Automations are created and edited in the portal. From the CLI you can list,
inspect and run them, read their webhook deliveries, see what waits for your
approval, and list your secrets (names and metadata, never values):

```bash
nan automations ls [--json]
nan automations show <id|name> [--json]
nan automations run <id|name> [--input @payload.json | --input - | --input JSON] [--detach] [--json]
nan automations deliveries <id|name> [--limit 50] [--json]
nan approvals ls [--state pending|approved|rejected|expired|cancelled|auto|all] [--limit 20] [--json]
nan approvals show <run-id> [--diff] # same as nan runs proposal
nan secrets ls [--json] # needs nan auth login
```

Approving a proposal, writing a secret and creating or editing an automation
happen only in the portal at <https://cloud.nan.builders>, signed in through
the browser. Each of them can hand a secret to an agent or let it act on your
repository without asking, so none is possible with a credential an agent or
a CI job could read: this machine's CLI session, a platform token or an API
key. A platform token needs the `automations:read` scope to list and inspect
automations and `automations:run` to start one.

### Exit codes

`nan run` and `nan runs logs -f` exit with the run's outcome, so a script can
`nan run`, `nan runs logs -f` and `nan automations run` exit with the run's outcome, so a script can
branch on it without parsing text:

| Code | Meaning |
Expand All @@ -163,6 +189,7 @@ branch on it without parsing text:
| 2 | timed out |
| 3 | cancelled (after a double Ctrl-C: cancel requested — `nan runs show` has the final state) |
| 4 | the workspace is not set up for it: agent not installed, no inference key, bad configuration |
| 10 | awaiting approval: an automation run whose proposed actions wait for you (`nan runs proposal <id>`, approve in the portal) |
| 64 | usage error (also: more than one workspace and no `--ws`) |
| 65 | not signed in, session expired, or not allowed |
| 69 | nan.builders unavailable, or the stream was lost after retries (the run goes on; the id is printed) |
Expand All @@ -187,7 +214,8 @@ JSON object per line): <https://nan.builders/docs/runs>.
`nan auth login --api-token`).

Create platform tokens in **Settings > Tokens** at
<https://cloud.nan.builders>. A token works for runs and for listing your
<https://cloud.nan.builders>. A token works for runs, for automations (with the
`automations:read` and `automations:run` scopes) and for listing your
workspaces, and nothing else: it is not an inference key, and `nan me`,
`nan metrics` and the dashboard still need `nan auth login`.

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

import (
"context"
"errors"
"fmt"
"strings"
"text/tabwriter"

"github.com/nxssie/nan-cli/internal/api"
"github.com/nxssie/nan-cli/internal/runs"
"github.com/spf13/cobra"
)

var (
approvalsState string
approvalsLimit int
approvalsJSON bool
)

const approveInPortal = "there is no approve command in the CLI: approve in the portal at " + runs.ApprovalsURL +
" (approving needs a browser sign-in, so an agent or a CI job that can read this machine's session or tokens can never approve its own actions). " +
"Read the proposal with: nan runs proposal <run-id>; reject it with: nan runs reject <run-id>"

var approvalsCmd = &cobra.Command{
Use: "approvals",
Short: "See the proposals waiting for your approval",
Long: `See the automation runs whose proposed actions wait for your approval.

Read a proposal with nan approvals show <run-id> (the same as nan runs
proposal), and reject it with nan runs reject <run-id>. Approving happens
only in the portal, at ` + runs.ApprovalsURL + `: it needs you
signed in through the browser, so that nothing that can read this machine's
CLI session or tokens (an agent in a workspace, a CI job) can approve the
actions an agent proposed.

Authentication is the same as nan run (see nan run --help); a platform token
needs the runs scope.

Docs: https://nan.builders/docs/runs`,
Annotations: sysexits,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 && (args[0] == "approve" || args[0] == "accept") {
return usageErrorf("%s", approveInPortal)
}
if len(args) > 0 {
return usageErrorf("unknown command %q for \"nan approvals\"; try: nan approvals --help", runs.Truncate(runs.SanitizeLine(args[0]), 64))
}
return cmd.Help()
},
}

var approvalsLsCmd = &cobra.Command{
Use: "ls",
Short: "List approvals, pending ones by default",
Args: cobra.NoArgs,
Annotations: sysexits,
RunE: func(cmd *cobra.Command, args []string) error {
state, err := approvalsStateParam(approvalsState)
if err != nil {
return err
}
if approvalsLimit < 1 || approvalsLimit > 100 {
return usageErrorf("--limit must be between 1 and 100")
}
return withRunsEnv(func(env *runsEnv) error { return doApprovalsLs(cmd.Context(), env, state) })
},
}

var approvalsShowCmd = &cobra.Command{
Use: "show <run-id>",
Short: "Print a run's proposal (the same as nan runs proposal)",
Args: oneRunID,
Annotations: sysexits,
RunE: func(cmd *cobra.Command, args []string) error {
return withRunsEnv(func(env *runsEnv) error {
return doRunsProposal(cmd.Context(), env, args[0], proposalDiff, proposalJSON)
})
},
}

func init() {
rootCmd.AddCommand(approvalsCmd)
approvalsCmd.AddCommand(approvalsLsCmd, approvalsShowCmd)
approvalsCmd.PersistentFlags().StringVar(&tokenFileFlag, "token-file", "", tokenFileUsage)

approvalsLsCmd.Flags().StringVar(&approvalsState, "state", api.ApprovalPending, "Only approvals in this state: pending, approved, rejected, expired, cancelled, auto, or all")
approvalsLsCmd.Flags().IntVar(&approvalsLimit, "limit", 20, "How many approvals to list (1-100)")
approvalsLsCmd.Flags().BoolVar(&approvalsJSON, "json", false, "Print the API's list object as JSON")

approvalsShowCmd.Flags().BoolVar(&proposalDiff, "diff", false, "Also print the full diff, when one is stored")
approvalsShowCmd.Flags().BoolVar(&proposalJSON, "json", false, "Print the approval object (proposal included) as JSON")
}

// approvalsStateParam is the query value for --state: "all" is no filter.
func approvalsStateParam(state string) (string, error) {
if state == "all" {
return "", nil
}
for _, s := range api.ApprovalStates {
if s == state {
return state, nil
}
}
return "", usageErrorf("--state must be one of %s, or all", strings.Join(api.ApprovalStates, ", "))
}

func doApprovalsLs(ctx context.Context, env *runsEnv, state string) error {
list, err := env.client.ListApprovals(ctx, api.ListApprovalsParams{State: state, Limit: approvalsLimit})
if err != nil {
var apiErr *api.APIError
if errors.As(err, &apiErr) && apiErr.Status == 404 {
return exitf(exitAuth, "automations are not enabled for your account")
}
return apiExit(err, env.cred)
}
if approvalsJSON {
return printJSON(env.stdout, list, true)
}
if len(list.Data) == 0 {
if state == api.ApprovalPending {
fmt.Fprintln(env.stderr, "Nothing is waiting for your approval.")
} else {
fmt.Fprintln(env.stderr, "No approvals match.")
}
return nil
}
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "RUN\tSTATE\tAUTOMATION\tWAITING FOR APPROVAL\tEXPIRES")
for _, a := range list.Data {
expires := "-"
if a.ExpiresAt != nil && a.State == api.ApprovalPending {
expires = localTime(*a.ExpiresAt)
}
automation := runs.SanitizeLine(a.Automation.Label())
if automation == "" {
automation = "-"
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
runs.SanitizeLine(a.RunID),
runs.SanitizeLine(a.State),
runs.Truncate(automation, 40),
runs.Truncate(waitingKinds(a), 60),
expires)
}
if err := tw.Flush(); err != nil {
return err
}
if list.NextCursor != nil {
fmt.Fprintf(env.stderr, "showing the %d most recent; raise --limit (up to 100) for more\n", len(list.Data))
}
if state == api.ApprovalPending {
fmt.Fprintf(env.stderr, "\nreview one: nan runs proposal <run-id>\napprove: in the portal, %s (approving needs a browser sign-in)\nreject: nan runs reject <run-id>\n", runs.ApprovalsURL)
}
return nil
}

// waitingKinds is the kinds of the actions an approval covers: those from
// the first one that needs approval on, without repeats.
func waitingKinds(a api.Approval) string {
if a.Proposal == nil || a.ApprovalFrom == nil || *a.ApprovalFrom < 0 {
return "-"
}
var kinds []string
seen := map[string]bool{}
for i, act := range a.Proposal.Actions {
if i < *a.ApprovalFrom || seen[act.Kind] {
continue
}
seen[act.Kind] = true
kinds = append(kinds, runs.SanitizeLine(act.Kind))
}
if len(kinds) == 0 {
return "-"
}
return strings.Join(kinds, ", ")
}
Loading
Loading