Skip to content
Draft
18 changes: 11 additions & 7 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,8 @@ asks for a few more characters rather than guessing.
| `createos sandbox push` | Copy a local file into a sandbox |
| `createos sandbox pull` | Copy a file out of a sandbox |
| `createos sandbox tunnel` | Forward a local port to a port inside a sandbox |
| `createos sandbox desktop` | Open a graphical sandbox in your browser |
| `createos sandbox computer` | Control the desktop inside a sandbox |
| `createos sandbox shapes` | List available sandbox sizes (vCPU / RAM / disk) |
| `createos sandbox rootfs` | List built-in OS images you can boot a sandbox from |
| `createos sandbox setup` | Connect a coding harness so its workspaces run on a sandbox |
Expand Down
11 changes: 11 additions & 0 deletions cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,15 @@ func NewApp() *cli.App {
if cmd == "" || cmd == "login" || cmd == "logout" || cmd == "version" || cmd == "ask" || cmd == "upgrade" {
return nil
}
// `sandbox self` talks to the guest agent on loopback inside the
// sandbox, which takes no credential by design. Demanding a
// login here would make the command unusable exactly where it is
// meant to run: inside a sandbox, which has no stored token.
if cmd == "sandbox" || cmd == "sb" {
if sub := c.Args().Get(1); sub == "self" {
return nil
}
}

// CREATEOS_API_KEY env var (or --api-key flag) — injected by Stripe Projects
if apiKey := c.String("api-key"); apiKey != "" {
Expand Down Expand Up @@ -228,6 +237,8 @@ func NewApp() *cli.App {
},
}
installTrailingHelpGuards(app.Commands)
installUsageErrorHelp(app)
installCommandSuggestions(app)

return app
}
Expand Down
203 changes: 203 additions & 0 deletions cmd/root/usage_error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package root

import (
"errors"
"fmt"
"os"
"strings"

"github.com/urfave/cli/v2"
)

// urfave/cli parses global flags only before the first command name, so
// `createos sandbox shapes -o json` dies with "flag provided but not
// defined: -o" — the flag exists, it is simply in the wrong place. The
// message names neither fact, and the same shape has cost real round trips
// in practice.
//
// installUsageErrorHelp attaches the handler to every command in the tree.
// OnUsageError lives on Command and is NOT inherited from the app, so a
// handler set only at the top never runs for `sandbox shapes -o json` —
// the exact case worth catching.
func installUsageErrorHelp(app *cli.App) {
globals := globalFlagNames(app)
handler := func(_ *cli.Context, err error, _ bool) error {
name := undefinedFlagName(err)
if name == "" || !globals[strings.TrimLeft(name, "-")] {
return err
}
return fmt.Errorf("%w\n\n %s is a global flag, so it has to come BEFORE the command:\n %s",
err, name, correctedCommandLine(name))
}
app.OnUsageError = handler
setUsageErrorHandler(app.Commands, handler)
}

func setUsageErrorHandler(commands []*cli.Command, handler cli.OnUsageErrorFunc) {
for _, cmd := range commands {
if cmd == nil {
continue
}
if cmd.OnUsageError == nil {
cmd.OnUsageError = handler
}
setUsageErrorHandler(cmd.Subcommands, handler)
}
}

func globalFlagNames(app *cli.App) map[string]bool {
names := make(map[string]bool)
for _, f := range app.Flags {
for _, n := range f.Names() {
names[n] = true
}
}
return names
}

// undefinedFlagName pulls the flag out of the flag package's message,
// which reads: `flag provided but not defined: -o`.
func undefinedFlagName(err error) string {
const marker = "flag provided but not defined: "
msg := err.Error()
i := strings.Index(msg, marker)
if i < 0 {
return ""
}
name := strings.TrimSpace(msg[i+len(marker):])
if cut := strings.IndexAny(name, " \n"); cut >= 0 {
name = name[:cut]
}
return name
}

// correctedCommandLine rewrites what the user typed with the misplaced
// global flag moved to the front, so the fix can be copied straight back
// into the terminal.
func correctedCommandLine(flagName string) string {
bare := strings.TrimLeft(flagName, "-")
args := os.Args[1:]

moved := make([]string, 0, 2)
rest := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
a := args[i]
trimmed := strings.TrimLeft(a, "-")
if trimmed == bare || strings.HasPrefix(trimmed, bare+"=") {
moved = append(moved, a)
// A value-taking flag written as `-o json` carries its value
// in the next argument; move that too or the corrected line
// is wrong.
if !strings.Contains(a, "=") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
i++
moved = append(moved, args[i])
}
continue
}
rest = append(rest, a)
}
return "createos " + strings.Join(append(moved, rest...), " ")
}

// installCommandSuggestions replaces urfave's bare "No help topic for
// 'ssh'" with the nearest real command, and makes an unknown command name
// fail the run. Agents and people both guess verb names, and a guess that
// lands one edit away from a real command should not cost a round trip to
// the help output — and a guess that is simply wrong must not exit zero.
//
// This cannot be done with urfave's own CommandNotFoundFunc: ShowCommandHelp
// calls that callback and then unconditionally returns nil (see the
// package's help.go), so nothing set there can ever make app.Run return an
// error. Catching the unresolved name has to happen earlier, in the
// command's own Action, before urfave's help fallback runs.
//
// That is safe to do because an Action only ever runs with a positional
// argument still present when dispatch already failed to match that
// argument against a real subcommand — a match would have called that
// subcommand's Run instead. So "argument present here" and "unknown
// command" are the same condition.
func installCommandSuggestions(app *cli.App) {
app.Action = suggestingAction(app.Action, app.Commands, "")
for _, cmd := range app.Commands {
installGroupSuggestions(cmd)
}
}

func installGroupSuggestions(cmd *cli.Command) {
if cmd == nil || len(cmd.Subcommands) == 0 {
return
}
cmd.Action = suggestingAction(cmd.Action, cmd.Subcommands, cmd.Name+" ")
for _, sub := range cmd.Subcommands {
installGroupSuggestions(sub)
}
}

func suggestingAction(fallback cli.ActionFunc, cands []*cli.Command, prefix string) cli.ActionFunc {
return func(c *cli.Context) error {
if name := c.Args().First(); name != "" {
return unknownCommandError(cands, prefix, name)
}
if fallback != nil {
return fallback(c)
}
return cli.ShowSubcommandHelp(c)
}
}

// unknownCommandError builds the same message the old CommandNotFound
// callback printed, but returns it instead of writing to stderr directly —
// main.go's error renderer prints whatever app.Run returns.
func unknownCommandError(candidates []*cli.Command, prefix, name string) error {
noun := "command"
if prefix != "" {
noun = "subcommand"
}
msg := fmt.Sprintf("createos %s: %q is not a %s.", strings.TrimSpace(prefix), name, noun)
if best := nearestCommand(candidates, name); best != "" {
msg += fmt.Sprintf("\n\n Did you mean:\n createos %s%s", prefix, best)
}
msg += fmt.Sprintf("\n\n See everything with:\n createos %s--help", prefix)
return errors.New(msg)
}

// nearestCommand returns the closest command name within a small edit
// distance, or "" when nothing is close enough. The cap matters: a wild
// guess should get the help pointer, not a confidently wrong suggestion.
func nearestCommand(commands []*cli.Command, name string) string {
name = strings.ToLower(name)
best, bestDist := "", 3
for _, cmd := range commands {
if cmd.Hidden {
continue
}
for _, candidate := range append([]string{cmd.Name}, cmd.Aliases...) {
if d := editDistance(name, strings.ToLower(candidate)); d < bestDist {
best, bestDist = cmd.Name, d
}
}
}
return best
}

// editDistance is Levenshtein over two short command names, with one row
// of state rather than a full matrix.
func editDistance(a, b string) int {
prev := make([]int, len(b)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(a); i++ {
cur := make([]int, len(b)+1)
cur[0] = i
for j := 1; j <= len(b); j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
}
prev = cur
}
return prev[len(b)]
}
Loading