refactor: route cli commands through kong

This commit is contained in:
Peter Steinberger 2026-05-04 22:45:43 +01:00
parent 5c19a4d39a
commit 352a6e1618
No known key found for this signature in database
15 changed files with 407 additions and 370 deletions

View File

@ -4,6 +4,7 @@
### Added
- Added generated command help for grouped commands so `crabbox actions --help`, `crabbox cache --help`, `crabbox desktop --help`, and similar entrypoints exit cleanly.
- Added optional Tailscale reachability for managed Linux leases with `--tailscale`, `--network auto|tailscale|public`, brokered OAuth auth-key minting, and non-secret tailnet metadata in status/inspect output.
- Added managed AWS Windows desktop leases with OpenSSH, Git for Windows, loopback TightVNC, per-lease VNC passwords, and `crabbox vnc`.
- Added AWS macOS desktop lease plumbing for EC2 Mac Dedicated Hosts, including Screen Sharing setup and per-lease credentials.
@ -17,9 +18,14 @@
- Added generated Windows console login details and auto-logon for managed AWS Windows desktop leases.
- Added a minimal XFCE desktop profile with panel/window manager for managed VNC leases.
### Changed
- Switched top-level CLI routing to Kong while preserving existing per-command flags, passthrough remote commands, aliases, and exit-code behavior.
### Fixed
- Fixed `crabbox run --junit` so all-passing JUnit files record results instead of leaving the coordinator run stuck when the failure list is empty.
- Fixed `crabbox desktop launch --browser` on freshly warmed desktop leases by creating the remote workdir before launching the app.
- Fixed failed Blacksmith Testbox warmups so printed or newly listed `tbx_...` boxes are stopped instead of being left queued after an upstream workflow error.
- Fixed Worker deploy smoke to prefer the Crabbox-scoped Cloudflare token when it is present in the environment or local profile.
- Fixed brokered Tailscale requests on coordinators without OAuth secrets so they fail as disabled instead of entering the auth-key minting path.

View File

@ -10,7 +10,7 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
## CLI Surface
- Command router and top-level help: `internal/cli/app.go`
- Kong command router and top-level help: `internal/cli/cli_kong.go`, `internal/cli/app.go`
- Shared flag parsing and exit helpers: `internal/cli/flags.go`, `internal/cli/errors.go`
- Config defaults, YAML keys, env overrides, target selection, and class maps: `internal/cli/config.go`, `internal/cli/target.go`, `worker/src/config.ts`
- Network target resolution and Tailscale metadata: `internal/cli/network.go`

1
go.mod
View File

@ -5,6 +5,7 @@ go 1.26
toolchain go1.26.2
require (
github.com/alecthomas/kong v1.15.0
github.com/aws/aws-sdk-go-v2 v1.41.7
github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/aws/aws-sdk-go-v2/service/ec2 v1.299.1

8
go.sum
View File

@ -1,3 +1,9 @@
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI=
github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
@ -29,6 +35,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=

View File

@ -27,68 +27,6 @@ func (r GitHubRepo) Slug() string {
return r.Owner + "/" + r.Name
}
func (a App) actions(ctx context.Context, args []string) error {
if len(args) == 0 {
a.printActionsHelp()
return exit(2, "missing actions subcommand")
}
if wantsHelp(args) {
a.printActionsHelp()
return nil
}
switch args[0] {
case "hydrate":
return a.actionsHydrate(ctx, args[1:])
case "register":
return a.actionsRegister(ctx, args[1:])
case "dispatch":
return a.actionsDispatch(ctx, args[1:])
default:
return exit(2, "unknown actions command %q", args[0])
}
}
func (a App) printActionsHelp() {
fmt.Fprintln(a.Stdout, `Usage:
crabbox actions hydrate --id <lease-id-or-slug> [flags]
crabbox actions register --id <lease-id-or-slug> [flags]
crabbox actions dispatch [flags]
Subcommands:
hydrate Register a runner, dispatch the hydrate workflow, wait for readiness
register Register an existing Linux lease as a GitHub Actions runner
dispatch Dispatch the configured GitHub Actions workflow
Hydrate Flags:
--id <lease-id-or-slug>
--repo <owner/name>
--workflow <file|name|id>
--ref <ref>
--wait-timeout <duration>
--keep-alive-minutes <n>
--reclaim
--timing-json
-f, --field <key=value>
Register Flags:
--id <lease-id-or-slug>
--repo <owner/name>
--name <runner-name>
--labels <csv>
--version <version|latest>
--ephemeral
--reclaim
Dispatch Flags:
--repo <owner/name>
--workflow <file|name|id>
--ref <ref>
-f, --field <key=value>
Docs:
docs/commands/actions.md`)
}
func (a App) actionsHydrate(ctx context.Context, args []string) error {
started := time.Now()
fs := newFlagSet("actions hydrate", a.Stderr)

View File

@ -6,47 +6,6 @@ import (
"fmt"
)
func (a App) admin(ctx context.Context, args []string) error {
if len(args) == 0 {
a.printAdminHelp()
return exit(2, "missing admin subcommand")
}
if wantsHelp(args) {
a.printAdminHelp()
return nil
}
switch args[0] {
case "leases":
return a.adminLeases(ctx, args[1:])
case "release":
return a.adminRelease(ctx, args[1:])
case "delete":
return a.adminDelete(ctx, args[1:])
default:
return exit(2, "unknown admin command %q", args[0])
}
}
func (a App) printAdminHelp() {
fmt.Fprintln(a.Stdout, `Usage:
crabbox admin leases [flags]
crabbox admin release <lease-id-or-slug> [flags]
crabbox admin delete <lease-id-or-slug> --force [flags]
Subcommands:
leases List coordinator lease records
release Mark a lease released, optionally deleting the backing server
delete Delete the backing server and mark the lease released
Flags:
leases: --state <state> --owner <email> --org <name> --limit <n> --json
release: --id <lease-id-or-slug> --delete --json
delete: --id <lease-id-or-slug> --force --json
Docs:
docs/commands/admin.md`)
}
func (a App) adminLeases(ctx context.Context, args []string) error {
fs := newFlagSet("admin leases", a.Stderr)
state := fs.String("state", "", "filter by state")

View File

@ -31,80 +31,13 @@ func (a App) Run(ctx context.Context, args []string) error {
return nil
case "help":
if len(args) > 1 {
next := append([]string{}, args[1:]...)
next = append(next, "--help")
return a.Run(ctx, next)
return a.runKong(ctx, args)
}
a.printHelp()
return nil
case "-v", "--version", "version":
fmt.Fprintln(a.Stdout, version)
return nil
case "doctor":
return a.doctor(ctx, args[1:])
case "login":
return a.login(ctx, args[1:])
case "logout":
return a.logout(ctx, args[1:])
case "whoami":
return a.whoami(ctx, args[1:])
case "admin":
return a.admin(ctx, args[1:])
case "history":
return a.history(ctx, args[1:])
case "logs":
return a.logs(ctx, args[1:])
case "events":
return a.events(ctx, args[1:])
case "attach":
return a.attach(ctx, args[1:])
case "results":
return a.results(ctx, args[1:])
case "cache":
return a.cache(ctx, args[1:])
case "config":
return a.config(ctx, args[1:])
case "init":
return a.initProject(ctx, args[1:])
case "image":
return a.image(ctx, args[1:])
case "pool":
return a.pool(ctx, args[1:])
case "machine":
return a.machine(ctx, args[1:])
case "list":
return a.list(ctx, args[1:])
case "usage":
return a.usage(ctx, args[1:])
case "actions":
return a.actions(ctx, args[1:])
case "cleanup":
return a.cleanup(ctx, args[1:])
case "warmup":
return a.warmup(ctx, args[1:])
case "run":
return a.runCommand(ctx, args[1:])
case "desktop":
return a.desktop(ctx, args[1:])
case "sync-plan":
return a.syncPlan(ctx, args[1:])
case "status":
return a.status(ctx, args[1:])
case "ssh":
return a.ssh(ctx, args[1:])
case "vnc":
return a.vnc(ctx, args[1:])
case "webvnc":
return a.webvnc(ctx, args[1:])
case "screenshot":
return a.screenshot(ctx, args[1:])
case "inspect":
return a.inspect(ctx, args[1:])
case "stop", "release":
return a.stop(ctx, args[1:])
default:
return exit(2, "unknown command %q", args[0])
}
return a.runKong(ctx, args)
}
func (a App) printHelp() {
@ -246,10 +179,3 @@ func parseFlags(fs *flag.FlagSet, args []string) error {
}
return nil
}
func wantsHelp(args []string) bool {
if len(args) == 0 {
return false
}
return args[0] == "-h" || args[0] == "--help" || args[0] == "help"
}

View File

@ -14,48 +14,6 @@ type cacheEntry struct {
Note string `json:"note,omitempty"`
}
func (a App) cache(ctx context.Context, args []string) error {
if len(args) == 0 {
a.printCacheHelp()
return exit(2, "missing cache subcommand")
}
if wantsHelp(args) {
a.printCacheHelp()
return nil
}
switch args[0] {
case "list", "stats":
return a.cacheStats(ctx, args[1:])
case "purge":
return a.cachePurge(ctx, args[1:])
case "warm":
return a.cacheWarm(ctx, args[1:])
default:
return exit(2, "unknown cache command %q", args[0])
}
}
func (a App) printCacheHelp() {
fmt.Fprintln(a.Stdout, `Usage:
crabbox cache stats --id <lease-id-or-slug> [flags]
crabbox cache list --id <lease-id-or-slug> [flags]
crabbox cache purge --id <lease-id-or-slug> --kind <kind> --force [flags]
crabbox cache warm --id <lease-id-or-slug> -- <command...>
Subcommands:
list, stats Show remote cache usage
purge Remove selected cache content
warm Run a command that populates caches
Flags:
stats/list: --id <lease-id-or-slug> --reclaim --json
purge: --id <lease-id-or-slug> --kind pnpm|npm|docker|git|all --force --reclaim
warm: --id <lease-id-or-slug> --reclaim
Docs:
docs/commands/cache.md`)
}
func (a App) cacheStats(ctx context.Context, args []string) error {
fs := newFlagSet("cache stats", a.Stderr)
id := fs.String("id", "", "lease id or slug")

367
internal/cli/cli_kong.go Normal file
View File

@ -0,0 +1,367 @@
package cli
import (
"context"
"errors"
"fmt"
"github.com/alecthomas/kong"
)
type crabboxKongCLI struct {
Version kong.VersionFlag `name:"version" short:"v" help:"Print version."`
VersionCmd versionKongCmd `cmd:"" name:"version" help:"Print version."`
Init initKongCmd `cmd:"" passthrough:"" help:"Onboard the current repo for Crabbox."`
Login loginKongCmd `cmd:"" passthrough:"" help:"Open GitHub login, store broker credentials, verify access."`
Logout logoutKongCmd `cmd:"" passthrough:"" help:"Remove the stored broker token."`
Whoami whoamiKongCmd `cmd:"" passthrough:"" help:"Show broker identity."`
Doctor doctorKongCmd `cmd:"" passthrough:"" help:"Check local and broker/provider readiness."`
Warmup warmupKongCmd `cmd:"" passthrough:"" help:"Lease a box and wait until it is ready."`
Run runKongCmd `cmd:"" passthrough:"" help:"Sync the repo, run a remote command, stream output."`
Desktop desktopKongCmd `cmd:"" help:"Launch apps into a visible desktop session."`
SyncPlan syncPlanKongCmd `cmd:"" name:"sync-plan" passthrough:"" help:"Show local sync manifest size hotspots."`
History historyKongCmd `cmd:"" passthrough:"" help:"List recorded remote runs."`
Logs logsKongCmd `cmd:"" passthrough:"" help:"Print recorded run logs."`
Events eventsKongCmd `cmd:"" passthrough:"" help:"Print recorded run events."`
Attach attachKongCmd `cmd:"" passthrough:"" help:"Follow recorded events for an active run."`
Results resultsKongCmd `cmd:"" passthrough:"" help:"Show recorded test result summaries."`
Cache cacheKongCmd `cmd:"" help:"Inspect, purge, or warm remote caches."`
Status statusKongCmd `cmd:"" passthrough:"" help:"Show lease state; add --wait to block until ready."`
List listKongCmd `cmd:"" passthrough:"" help:"List Crabbox machines."`
Image imageKongCmd `cmd:"" help:"Create or promote brokered AWS runner images."`
Usage usageKongCmd `cmd:"" passthrough:"" help:"Show cost and usage estimates by user, org, or fleet."`
Admin adminKongCmd `cmd:"" help:"Lease admin controls for trusted operators."`
Actions actionsKongCmd `cmd:"" help:"Register GitHub Actions runners or dispatch workflows."`
Ssh sshKongCmd `cmd:"" name:"ssh" passthrough:"" help:"Print the SSH command for a lease."`
Vnc vncKongCmd `cmd:"" name:"vnc" passthrough:"" help:"Print or open VNC connection details for a desktop lease."`
Webvnc webvncKongCmd `cmd:"" name:"webvnc" passthrough:"" help:"Bridge a desktop lease into the authenticated web portal."`
Screenshot screenshotKongCmd `cmd:"" passthrough:"" help:"Capture a PNG from a desktop lease."`
Inspect inspectKongCmd `cmd:"" passthrough:"" help:"Print lease/provider details; add --json for scripts."`
Stop stopKongCmd `cmd:"" passthrough:"" help:"Release a lease or delete a direct-provider machine."`
Release releaseKongCmd `cmd:"" passthrough:"" help:"Alias for stop."`
Cleanup cleanupKongCmd `cmd:"" passthrough:"" help:"Sweep expired direct-provider machines."`
Config configKongCmd `cmd:"" help:"Show or update user config."`
Pool poolKongCmd `cmd:"" help:"Alias commands for machine pools."`
Machine machineKongCmd `cmd:"" help:"Alias commands for direct-provider machines."`
}
type kongExit struct {
code int
}
func (a App) runKong(ctx context.Context, args []string) (err error) {
args = normalizeKongHelpArgs(args)
var cli crabboxKongCLI
parser, err := kong.New(&cli,
kong.Name("crabbox"),
kong.Description("Crabbox leases remote test boxes, syncs your dirty checkout, runs commands, and cleans up."),
kong.Vars{"version": version},
kong.Writers(a.Stdout, a.Stderr),
kong.Exit(func(code int) {
panic(kongExit{code: code})
}),
)
if err != nil {
return err
}
defer func() {
recovered := recover()
if recovered == nil {
return
}
if exit, ok := recovered.(kongExit); ok {
if exit.code == 0 {
err = nil
} else {
err = ExitError{Code: exit.code}
}
return
}
panic(recovered)
}()
kctx, err := parser.Parse(args)
if err != nil {
var parseErr *kong.ParseError
if errors.As(err, &parseErr) {
return exit(2, "%v", parseErr)
}
return err
}
kctx.BindTo(ctx, (*context.Context)(nil))
return kctx.Run(a)
}
func normalizeKongHelpArgs(args []string) []string {
if len(args) > 1 && args[0] == "help" {
next := append([]string{}, args[1:]...)
next = append(next, "--help")
return next
}
return args
}
type initKongCmd struct {
Args []string `arg:"" optional:""`
}
type loginKongCmd struct {
Args []string `arg:"" optional:""`
}
type logoutKongCmd struct {
Args []string `arg:"" optional:""`
}
type whoamiKongCmd struct {
Args []string `arg:"" optional:""`
}
type doctorKongCmd struct {
Args []string `arg:"" optional:""`
}
type warmupKongCmd struct {
Args []string `arg:"" optional:""`
}
type runKongCmd struct {
Args []string `arg:"" optional:""`
}
type syncPlanKongCmd struct {
Args []string `arg:"" optional:""`
}
type historyKongCmd struct {
Args []string `arg:"" optional:""`
}
type logsKongCmd struct {
Args []string `arg:"" optional:""`
}
type eventsKongCmd struct {
Args []string `arg:"" optional:""`
}
type attachKongCmd struct {
Args []string `arg:"" optional:""`
}
type resultsKongCmd struct {
Args []string `arg:"" optional:""`
}
type statusKongCmd struct {
Args []string `arg:"" optional:""`
}
type listKongCmd struct {
Args []string `arg:"" optional:""`
}
type usageKongCmd struct {
Args []string `arg:"" optional:""`
}
type sshKongCmd struct {
Args []string `arg:"" optional:""`
}
type vncKongCmd struct {
Args []string `arg:"" optional:""`
}
type webvncKongCmd struct {
Args []string `arg:"" optional:""`
}
type screenshotKongCmd struct {
Args []string `arg:"" optional:""`
}
type inspectKongCmd struct {
Args []string `arg:"" optional:""`
}
type stopKongCmd struct {
Args []string `arg:"" optional:""`
}
type releaseKongCmd struct {
Args []string `arg:"" optional:""`
}
type cleanupKongCmd struct {
Args []string `arg:"" optional:""`
}
type desktopKongCmd struct {
Launch desktopLaunchKongCmd `cmd:"" passthrough:"" help:"Start an app inside a desktop lease."`
}
type desktopLaunchKongCmd struct {
Args []string `arg:"" optional:""`
}
type cacheKongCmd struct {
List cacheListKongCmd `cmd:"" passthrough:"" help:"Show remote cache usage."`
Stats cacheStatsKongCmd `cmd:"" passthrough:"" help:"Show remote cache usage."`
Purge cachePurgeKongCmd `cmd:"" passthrough:"" help:"Remove selected cache content."`
Warm cacheWarmKongCmd `cmd:"" passthrough:"" help:"Run a command that populates caches."`
}
type cacheListKongCmd struct {
Args []string `arg:"" optional:""`
}
type cacheStatsKongCmd struct {
Args []string `arg:"" optional:""`
}
type cachePurgeKongCmd struct {
Args []string `arg:"" optional:""`
}
type cacheWarmKongCmd struct {
Args []string `arg:"" optional:""`
}
type imageKongCmd struct {
Create imageCreateKongCmd `cmd:"" passthrough:"" help:"Create an AMI from a brokered AWS lease."`
Promote imagePromoteKongCmd `cmd:"" passthrough:"" help:"Promote an AMI for brokered AWS runners."`
}
type imageCreateKongCmd struct {
Args []string `arg:"" optional:""`
}
type imagePromoteKongCmd struct {
Args []string `arg:"" optional:""`
}
type adminKongCmd struct {
Leases adminLeasesKongCmd `cmd:"" passthrough:"" help:"List coordinator lease records."`
Release adminReleaseKongCmd `cmd:"" passthrough:"" help:"Mark a lease released."`
Delete adminDeleteKongCmd `cmd:"" passthrough:"" help:"Delete the backing server and mark the lease released."`
}
type adminLeasesKongCmd struct {
Args []string `arg:"" optional:""`
}
type adminReleaseKongCmd struct {
Args []string `arg:"" optional:""`
}
type adminDeleteKongCmd struct {
Args []string `arg:"" optional:""`
}
type actionsKongCmd struct {
Hydrate actionsHydrateKongCmd `cmd:"" passthrough:"" help:"Register a runner, dispatch the hydrate workflow, wait for readiness."`
Register actionsRegisterKongCmd `cmd:"" passthrough:"" help:"Register an existing Linux lease as a GitHub Actions runner."`
Dispatch actionsDispatchKongCmd `cmd:"" passthrough:"" help:"Dispatch the configured GitHub Actions workflow."`
}
type actionsHydrateKongCmd struct {
Args []string `arg:"" optional:""`
}
type actionsRegisterKongCmd struct {
Args []string `arg:"" optional:""`
}
type actionsDispatchKongCmd struct {
Args []string `arg:"" optional:""`
}
type configKongCmd struct {
Path configPathKongCmd `cmd:"" help:"Print the user config path."`
Show configShowKongCmd `cmd:"" passthrough:"" help:"Print merged config without secret values."`
SetBroker configSetBrokerKongCmd `cmd:"" name:"set-broker" passthrough:"" help:"Store broker URL and optional tokens in user config."`
}
type configPathKongCmd struct{}
type configShowKongCmd struct {
Args []string `arg:"" optional:""`
}
type configSetBrokerKongCmd struct {
Args []string `arg:"" optional:""`
}
type poolKongCmd struct {
List poolListKongCmd `cmd:"" passthrough:"" help:"Alias for list."`
}
type poolListKongCmd struct {
Args []string `arg:"" optional:""`
}
type machineKongCmd struct {
Cleanup machineCleanupKongCmd `cmd:"" passthrough:"" help:"Alias for cleanup."`
}
type machineCleanupKongCmd struct {
Args []string `arg:"" optional:""`
}
type versionKongCmd struct{}
func (c *initKongCmd) Run(ctx context.Context, app App) error { return app.initProject(ctx, c.Args) }
func (c *loginKongCmd) Run(ctx context.Context, app App) error { return app.login(ctx, c.Args) }
func (c *logoutKongCmd) Run(ctx context.Context, app App) error { return app.logout(ctx, c.Args) }
func (c *whoamiKongCmd) Run(ctx context.Context, app App) error { return app.whoami(ctx, c.Args) }
func (c *doctorKongCmd) Run(ctx context.Context, app App) error { return app.doctor(ctx, c.Args) }
func (c *warmupKongCmd) Run(ctx context.Context, app App) error { return app.warmup(ctx, c.Args) }
func (c *runKongCmd) Run(ctx context.Context, app App) error { return app.runCommand(ctx, c.Args) }
func (c *syncPlanKongCmd) Run(ctx context.Context, app App) error { return app.syncPlan(ctx, c.Args) }
func (c *historyKongCmd) Run(ctx context.Context, app App) error { return app.history(ctx, c.Args) }
func (c *logsKongCmd) Run(ctx context.Context, app App) error { return app.logs(ctx, c.Args) }
func (c *eventsKongCmd) Run(ctx context.Context, app App) error { return app.events(ctx, c.Args) }
func (c *attachKongCmd) Run(ctx context.Context, app App) error { return app.attach(ctx, c.Args) }
func (c *resultsKongCmd) Run(ctx context.Context, app App) error { return app.results(ctx, c.Args) }
func (c *statusKongCmd) Run(ctx context.Context, app App) error { return app.status(ctx, c.Args) }
func (c *listKongCmd) Run(ctx context.Context, app App) error { return app.list(ctx, c.Args) }
func (c *usageKongCmd) Run(ctx context.Context, app App) error { return app.usage(ctx, c.Args) }
func (c *sshKongCmd) Run(ctx context.Context, app App) error { return app.ssh(ctx, c.Args) }
func (c *vncKongCmd) Run(ctx context.Context, app App) error { return app.vnc(ctx, c.Args) }
func (c *webvncKongCmd) Run(ctx context.Context, app App) error { return app.webvnc(ctx, c.Args) }
func (c *screenshotKongCmd) Run(ctx context.Context, app App) error {
return app.screenshot(ctx, c.Args)
}
func (c *inspectKongCmd) Run(ctx context.Context, app App) error { return app.inspect(ctx, c.Args) }
func (c *stopKongCmd) Run(ctx context.Context, app App) error { return app.stop(ctx, c.Args) }
func (c *releaseKongCmd) Run(ctx context.Context, app App) error { return app.stop(ctx, c.Args) }
func (c *cleanupKongCmd) Run(ctx context.Context, app App) error { return app.cleanup(ctx, c.Args) }
func (c *desktopLaunchKongCmd) Run(ctx context.Context, app App) error {
return app.desktopLaunch(ctx, c.Args)
}
func (c *cacheListKongCmd) Run(ctx context.Context, app App) error {
return app.cacheStats(ctx, c.Args)
}
func (c *cacheStatsKongCmd) Run(ctx context.Context, app App) error {
return app.cacheStats(ctx, c.Args)
}
func (c *cachePurgeKongCmd) Run(ctx context.Context, app App) error {
return app.cachePurge(ctx, c.Args)
}
func (c *cacheWarmKongCmd) Run(ctx context.Context, app App) error { return app.cacheWarm(ctx, c.Args) }
func (c *imageCreateKongCmd) Run(ctx context.Context, app App) error {
return app.imageCreate(ctx, c.Args)
}
func (c *imagePromoteKongCmd) Run(ctx context.Context, app App) error {
return app.imagePromote(ctx, c.Args)
}
func (c *adminLeasesKongCmd) Run(ctx context.Context, app App) error {
return app.adminLeases(ctx, c.Args)
}
func (c *adminReleaseKongCmd) Run(ctx context.Context, app App) error {
return app.adminRelease(ctx, c.Args)
}
func (c *adminDeleteKongCmd) Run(ctx context.Context, app App) error {
return app.adminDelete(ctx, c.Args)
}
func (c *actionsHydrateKongCmd) Run(ctx context.Context, app App) error {
return app.actionsHydrate(ctx, c.Args)
}
func (c *actionsRegisterKongCmd) Run(ctx context.Context, app App) error {
return app.actionsRegister(ctx, c.Args)
}
func (c *actionsDispatchKongCmd) Run(ctx context.Context, app App) error {
return app.actionsDispatch(ctx, c.Args)
}
func (c *configPathKongCmd) Run(ctx context.Context, app App) error {
path := userConfigPath()
if path == "" {
return exit(2, "user config directory is unavailable")
}
fmt.Fprintln(app.Stdout, path)
return nil
}
func (c *configShowKongCmd) Run(app App) error {
return app.configShow(c.Args)
}
func (c *configSetBrokerKongCmd) Run(app App) error {
return app.configSetBroker(c.Args)
}
func (c *poolListKongCmd) Run(ctx context.Context, app App) error {
return app.list(ctx, c.Args)
}
func (c *machineCleanupKongCmd) Run(ctx context.Context, app App) error {
return app.cleanup(ctx, c.Args)
}
func (c *versionKongCmd) Run(app App) error {
fmt.Fprintln(app.Stdout, version)
return nil
}

View File

@ -1,7 +1,6 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"io"
@ -9,51 +8,6 @@ import (
"strings"
)
func (a App) config(_ context.Context, args []string) error {
if len(args) == 0 {
a.printConfigHelp()
return exit(2, "missing config subcommand")
}
if wantsHelp(args) {
a.printConfigHelp()
return nil
}
switch args[0] {
case "path":
path := userConfigPath()
if path == "" {
return exit(2, "user config directory is unavailable")
}
fmt.Fprintln(a.Stdout, path)
return nil
case "show":
return a.configShow(args[1:])
case "set-broker":
return a.configSetBroker(args[1:])
default:
return exit(2, "unknown config command %q", args[0])
}
}
func (a App) printConfigHelp() {
fmt.Fprintln(a.Stdout, `Usage:
crabbox config path
crabbox config show [--json]
crabbox config set-broker --url <url> [flags]
Subcommands:
path Print the user config path
show Print merged config without secret values
set-broker Store broker URL and optional tokens in user config
Flags:
show: --json
set-broker: --url <url> --provider hetzner|aws --token-stdin --admin-token-stdin
Docs:
docs/commands/config.md`)
}
func (a App) configShow(args []string) error {
fs := newFlagSet("config show", a.Stderr)
jsonOut := fs.Bool("json", false, "print JSON")

View File

@ -7,48 +7,6 @@ import (
"strings"
)
func (a App) desktop(ctx context.Context, args []string) error {
if len(args) == 0 {
a.printDesktopHelp()
return exit(2, "missing desktop subcommand")
}
if wantsHelp(args) {
a.printDesktopHelp()
return nil
}
switch args[0] {
case "launch":
return a.desktopLaunch(ctx, args[1:])
default:
return exit(2, "unknown desktop command %q", args[0])
}
}
func (a App) printDesktopHelp() {
fmt.Fprintln(a.Stdout, `Usage:
crabbox desktop launch --id <lease-id-or-slug> [flags] -- <command...>
crabbox desktop launch --id <lease-id-or-slug> --browser [--url <url>]
Subcommands:
launch Start an app inside a desktop lease
Flags:
--id <lease-id-or-slug>
--provider hetzner|aws|ssh
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>
--static-user <user>
--static-port <port>
--static-work-root <path>
--browser
--url <url>
--reclaim
Docs:
docs/commands/desktop.md`)
}
func (a App) desktopLaunch(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("desktop launch", a.Stderr)
@ -146,6 +104,7 @@ func posixDesktopLaunchRemoteCommand(workdir string, env map[string]string, comm
var b bytes.Buffer
b.WriteString("set -eu\n")
if workdir != "" {
b.WriteString("mkdir -p " + shellQuote(workdir) + "\n")
b.WriteString("cd " + shellQuote(workdir) + "\n")
}
for key, value := range env {
@ -205,6 +164,7 @@ func windowsDesktopLaunchScript(workdir string, env map[string]string, command [
var b bytes.Buffer
b.WriteString("$ErrorActionPreference = \"Stop\"\n")
if workdir != "" {
b.WriteString("New-Item -ItemType Directory -Force -Path " + psQuote(workdir) + " | Out-Null\n")
b.WriteString("Set-Location -LiteralPath " + psQuote(workdir) + "\n")
}
for key, value := range env {

View File

@ -13,6 +13,7 @@ func TestDesktopLaunchRemoteCommandUsesDetachedPOSIXSession(t *testing.T) {
[]string{"/usr/bin/chromium", "https://example.com"},
)
for _, want := range []string{
"mkdir -p '/work/crabbox/cbx_1/repo'",
"cd '/work/crabbox/cbx_1/repo'",
"DISPLAY=':99'",
"BROWSER='/usr/bin/chromium'",
@ -52,6 +53,7 @@ func TestWindowsDesktopLaunchScriptStartsAndForegroundsProcess(t *testing.T) {
[]string{`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`, "https://example.com"},
)
for _, want := range []string{
`New-Item -ItemType Directory -Force -Path 'C:\crabbox\cbx_1\repo'`,
`Set-Location -LiteralPath 'C:\crabbox\cbx_1\repo'`,
`$env:BROWSER = 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe'`,
"Shell.Application",

View File

@ -8,25 +8,6 @@ import (
"time"
)
func (a App) image(ctx context.Context, args []string) error {
if len(args) == 0 {
return exit(2, "usage: crabbox image <create|promote> [flags]")
}
switch args[0] {
case "-h", "--help", "help":
fmt.Fprintln(a.Stdout, `Usage:
crabbox image create --id <cbx_id> --name <ami-name> [--wait]
crabbox image promote <ami-id>`)
return nil
case "create":
return a.imageCreate(ctx, args[1:])
case "promote":
return a.imagePromote(ctx, args[1:])
default:
return exit(2, "unknown image command %q", args[0])
}
}
func (a App) imageCreate(ctx context.Context, args []string) error {
fs := newFlagSet("image create", a.Stderr)
id := fs.String("id", "", "AWS lease id to image")

View File

@ -128,3 +128,20 @@ func TestTopLevelHelpIsWorkflowFirst(t *testing.T) {
}
}
}
func TestKongRouterPreservesVersionAndUsageExitCodes(t *testing.T) {
var stdout bytes.Buffer
app := App{Stdout: &stdout, Stderr: &bytes.Buffer{}}
if err := app.Run(context.Background(), []string{"--version"}); err != nil {
t.Fatalf("--version error: %v", err)
}
if strings.TrimSpace(stdout.String()) != version {
t.Fatalf("--version output=%q, want %q", stdout.String(), version)
}
err := app.Run(context.Background(), []string{"nope"})
var exitErr ExitError
if !AsExitError(err, &exitErr) || exitErr.Code != 2 {
t.Fatalf("unknown command error=%v, want exit 2", err)
}
}

View File

@ -8,21 +8,6 @@ import (
"time"
)
func (a App) pool(ctx context.Context, args []string) error {
if wantsHelp(args) {
fmt.Fprintln(a.Stdout, `Usage:
crabbox pool list [--json]
Alias:
crabbox pool list is an alias for crabbox list`)
return nil
}
if len(args) == 0 || args[0] != "list" {
return exit(2, "usage: crabbox pool list [--json]")
}
return a.list(ctx, args[1:])
}
func (a App) list(ctx context.Context, args []string) error {
fs := newFlagSet("list", a.Stderr)
provider := fs.String("provider", defaultConfig().Provider, "provider: hetzner, aws, ssh, or blacksmith-testbox")
@ -147,31 +132,6 @@ func coordinatorMachineOrphanField(labels map[string]string, activeLeaseIDs map[
return ""
}
func (a App) machine(ctx context.Context, args []string) error {
if len(args) == 0 {
fmt.Fprintln(a.Stdout, `Usage:
crabbox machine cleanup [--dry-run]
Alias:
crabbox machine cleanup is an alias for crabbox cleanup`)
return exit(2, "missing machine subcommand")
}
if wantsHelp(args) {
fmt.Fprintln(a.Stdout, `Usage:
crabbox machine cleanup [--dry-run]
Alias:
crabbox machine cleanup is an alias for crabbox cleanup`)
return nil
}
switch args[0] {
case "cleanup":
return a.cleanup(ctx, args[1:])
default:
return exit(2, "unknown machine command %q", args[0])
}
}
func (a App) cleanup(ctx context.Context, args []string) error {
fs := newFlagSet("machine cleanup", a.Stderr)
provider := fs.String("provider", defaultConfig().Provider, "provider: hetzner or aws")