Skip to content

Commit 614ca7a

Browse files
committed
Clean up domain command i'm unsure of
1 parent c838cc1 commit 614ca7a

6 files changed

Lines changed: 328 additions & 53 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ obol
8888
├── model setup (has sub: custom), status, token, sync, pull, list, prefer, discover, remove
8989
├── app install, sync, list, delete
9090
├── tunnel status, setup, restart, stop, logs (login hidden: browser-managed fallback)
91-
├── domain search, check, register
91+
├── domain list, search, check, register
9292
├── kubectl/helm/helmfile/k9s Passthrough (auto KUBECONFIG)
9393
├── update Helm + CLI version check (--json)
9494
├── upgrade Apply chart upgrades (--defaults-only, --pinned, --major)
@@ -102,7 +102,7 @@ obol
102102
- `sell info <name>` prints purchase instructions (URL, model, buy.py command).
103103
- `sell mcp [name]` runs a foreground x402-paid MCP server: forwards buyer JSON args to a backend HTTP service, injecting the seller's own API key (buyer never sees it). Payment rides MCP `_meta` (`internal/x402mcp`).
104104
- `sell resume` replays every persisted sell offer (inference incl. detached host-gateway relaunch; http/agent/demo-agent via the manifest ledger at `$OBOL_CONFIG_DIR/sell-http/`) — run after a host reboot; `obol stack up` runs the same path. `--install-boot-unit` adds a systemd user unit (Linux). `sell mcp` is foreground-only, no offer, not resumed.
105-
- `tunnel setup [<token>]`: the one permanent-URL command. Connector-token based (dashboard-managed) — no host binary, no account-wide API key. Accepts the bare connector token, the `--token` flag, a positional arg, or the whole `cloudflared tunnel run --token …` line (prefix stripped via `extractConnectorToken`). Reuses the remote runtime (`ProvisionWithToken` → `TUNNEL_TOKEN` secret, chart `management_mode=remote`); DNS/ingress are configured by the user in the Cloudflare dashboard (route Public Hostname → `http://traefik.traefik.svc.cluster.local:80`), not via API. The API-token provisioning path was removed (no more `tunnel provision`, no setup `--api-token/--account-id/--zone-id/--register-domain`). `--management local` (alias hidden `tunnel login`) is the browser fallback (needs `cloudflared`). `tunnel status` reads connector health from cloudflared's in-cluster `/ready`+`/metrics` (port 2000, no token) plus a public HTTP probe; concise by default, `--verbose` for replicas/pods, `--no-probe` to stay offline. Domain registration still lives under `obol domain register` (still uses a Cloudflare API token).
105+
- `tunnel setup [<token>]`: the one permanent-URL command. Connector-token based (dashboard-managed) — no host binary, no account-wide API key. Accepts the bare connector token, the `--token` flag, a positional arg, or the whole `cloudflared tunnel run --token …` line (prefix stripped via `extractConnectorToken`). Reuses the remote runtime (`ProvisionWithToken` → `TUNNEL_TOKEN` secret, chart `management_mode=remote`); DNS/ingress are configured by the user in the Cloudflare dashboard (route Public Hostname → `http://traefik.traefik.svc.cluster.local:80`), not via API. The API-token provisioning path was removed (no more `tunnel provision`, no setup `--api-token/--account-id/--zone-id/--register-domain`). `--management local` (alias hidden `tunnel login`) is the browser fallback (needs `cloudflared`). `tunnel status` reads connector health from cloudflared's in-cluster `/ready`+`/metrics` (port 2000, no token) plus a public HTTP probe; concise by default, `--verbose` for replicas/pods, `--no-probe` to stay offline. Domain management lives under `obol domain` (`list`, `search`, `check`, `register`) — an optional CLI wrapper around Cloudflare Registrar; still uses a scoped Cloudflare **API token** (Account → Domain perm, via `--api-token`/`CLOUDFLARE_API_TOKEN`; on a TTY it walks you through token creation and prompts). `--api-token` deliberately has NO `-t` alias to avoid colliding with `tunnel setup -t` (connector token — a different credential). `register` is billable (needs a payment method on the CF account); on success it prints the `obol tunnel setup --hostname …` handoff.
106106
- `hermes` is passthrough to native hermes CLI via `hermes.CLI()` (cmd/obol/hermes.go:27). No Go-level subcommands registered.
107107
- `bootstrap` (cmd/obol/bootstrap.go) is a hidden command for installer use only — not user-facing.
108108

cmd/obol/tunnel_domain.go

Lines changed: 175 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ package main
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"strings"
89

910
"github.com/ObolNetwork/obol-stack/internal/config"
1011
"github.com/ObolNetwork/obol-stack/internal/tunnel"
12+
"github.com/ObolNetwork/obol-stack/internal/ui"
1113
"github.com/urfave/cli/v3"
1214
)
1315

@@ -38,6 +40,8 @@ func tunnelCommand(cfg *config.Config) *cli.Command {
3840
"Cloudflare dashboard, route its Public Hostname to\n" +
3941
"http://traefik.traefik.svc.cluster.local:80, then paste the token here — you can\n" +
4042
"paste the whole 'cloudflared tunnel run --token …' line and Obol extracts it.\n\n" +
43+
"No domain yet? Register one from the CLI with 'obol domain', or buy/transfer one\n" +
44+
"in the Cloudflare dashboard first — either way it must be a zone in your account.\n\n" +
4145
"Advanced: '--management local' uses a browser login on this machine instead\n" +
4246
"(needs cloudflared installed); 'obol tunnel login' is the same flow directly.",
4347
Flags: tunnelSetupFlags(),
@@ -116,19 +120,45 @@ func tunnelCommand(cfg *config.Config) *cli.Command {
116120
func domainCommand(cfg *config.Config) *cli.Command {
117121
return &cli.Command{
118122
Name: "domain",
119-
Usage: "Search, check, and register Cloudflare Registrar domains",
123+
Usage: "Search, check, and register Cloudflare Registrar domains (optional)",
124+
Description: "Buying a domain through Obol is entirely optional — it's a convenience wrapper\n" +
125+
"around Cloudflare Registrar so you can get a domain without leaving the CLI.\n" +
126+
"If you'd rather, buy or transfer a domain in the Cloudflare dashboard before\n" +
127+
"setting up a tunnel; anything that lands as a zone in your account works.\n\n" +
128+
"These commands need a scoped Cloudflare API token (with the Account → Domain\n" +
129+
"permission) — separate from the tunnel connector token — and registering a\n" +
130+
"domain is billable, so your Cloudflare account needs a saved payment method.\n\n" +
131+
"Once you own a domain, give your stack a permanent URL with 'obol tunnel setup'.",
120132
Commands: []*cli.Command{
133+
{
134+
Name: "list",
135+
Usage: "List domains already registered in your Cloudflare account",
136+
Flags: domainAuthFlags(),
137+
Action: func(ctx context.Context, cmd *cli.Command) error {
138+
u := getUI(cmd)
139+
opts, err := domainListOptionsFromCommand(cmd, u)
140+
if err != nil {
141+
return err
142+
}
143+
result, err := tunnel.ListDomains(opts)
144+
if err != nil {
145+
return err
146+
}
147+
if u.IsJSON() {
148+
return u.JSON(result)
149+
}
150+
printDomainList(u, result)
151+
return nil
152+
},
153+
},
121154
{
122155
Name: "search",
123156
Usage: "Search for available Cloudflare Registrar domains",
124-
Flags: []cli.Flag{
157+
Flags: append([]cli.Flag{
125158
&cli.StringFlag{Name: "query", Aliases: []string{"q"}, Usage: "Keyword, phrase, or domain to search for"},
126159
&cli.StringSliceFlag{Name: "extensions", Usage: "Optional extension filter(s), e.g. --extensions com --extensions dev"},
127160
&cli.IntFlag{Name: "limit", Usage: "Maximum number of suggestions to return", Value: 10},
128-
&cli.StringFlag{Name: "account-id", Aliases: []string{"a"}, Usage: "Cloudflare account ID", Sources: cli.EnvVars("CLOUDFLARE_ACCOUNT_ID")},
129-
&cli.StringFlag{Name: "api-token", Aliases: []string{"t"}, Usage: "Cloudflare API token", Sources: cli.EnvVars("CLOUDFLARE_API_TOKEN")},
130-
&cli.StringFlag{Name: "from-json", Usage: "Read search options from JSON file (or - for stdin)"},
131-
},
161+
}, domainAuthFlags()...),
132162
Action: func(ctx context.Context, cmd *cli.Command) error {
133163
u := getUI(cmd)
134164
opts, err := domainSearchOptionsFromCommand(cmd, u)
@@ -150,14 +180,10 @@ func domainCommand(cfg *config.Config) *cli.Command {
150180
Name: "check",
151181
Usage: "Check authoritative availability for one or more domains",
152182
ArgsUsage: "<domain> [<domain> ...]",
153-
Flags: []cli.Flag{
154-
&cli.StringFlag{Name: "account-id", Aliases: []string{"a"}, Usage: "Cloudflare account ID", Sources: cli.EnvVars("CLOUDFLARE_ACCOUNT_ID")},
155-
&cli.StringFlag{Name: "api-token", Aliases: []string{"t"}, Usage: "Cloudflare API token", Sources: cli.EnvVars("CLOUDFLARE_API_TOKEN")},
156-
&cli.StringFlag{Name: "from-json", Usage: "Read check options from JSON file (or - for stdin)"},
157-
},
183+
Flags: domainAuthFlags(),
158184
Action: func(ctx context.Context, cmd *cli.Command) error {
159185
u := getUI(cmd)
160-
opts, err := domainCheckOptionsFromCommand(cmd)
186+
opts, err := domainCheckOptionsFromCommand(cmd, u)
161187
if err != nil {
162188
return err
163189
}
@@ -174,21 +200,18 @@ func domainCommand(cfg *config.Config) *cli.Command {
174200
},
175201
{
176202
Name: "register",
177-
Usage: "Register a domain through Cloudflare Registrar",
203+
Usage: "Register a domain through Cloudflare Registrar (billable)",
178204
ArgsUsage: "<domain>",
179-
Flags: []cli.Flag{
205+
Flags: append([]cli.Flag{
180206
&cli.IntFlag{Name: "years", Usage: "Registration term in years (default 1 or registry minimum)", Value: 1},
181207
&cli.BoolFlag{Name: "auto-renew", Usage: "Enable automatic renewal"},
182208
&cli.StringFlag{Name: "privacy-mode", Usage: "WHOIS privacy mode", Value: "redaction"},
183209
&cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "Confirm the billable registration without prompting"},
184-
&cli.BoolFlag{Name: "respond-async", Usage: "Request an immediate async workflow response from Cloudflare"},
185-
&cli.StringFlag{Name: "account-id", Aliases: []string{"a"}, Usage: "Cloudflare account ID", Sources: cli.EnvVars("CLOUDFLARE_ACCOUNT_ID")},
186-
&cli.StringFlag{Name: "api-token", Aliases: []string{"t"}, Usage: "Cloudflare API token", Sources: cli.EnvVars("CLOUDFLARE_API_TOKEN")},
187-
&cli.StringFlag{Name: "from-json", Usage: "Read registration options from JSON file (or - for stdin)"},
188-
},
210+
&cli.BoolFlag{Name: "respond-async", Hidden: true, Usage: "Request an immediate async workflow response from Cloudflare"},
211+
}, domainAuthFlags()...),
189212
Action: func(ctx context.Context, cmd *cli.Command) error {
190213
u := getUI(cmd)
191-
opts, err := domainRegisterOptionsFromCommand(cmd)
214+
opts, err := domainRegisterOptionsFromCommand(cmd, u)
192215
if err != nil {
193216
return err
194217
}
@@ -207,6 +230,50 @@ func domainCommand(cfg *config.Config) *cli.Command {
207230
}
208231
}
209232

233+
// domainAuthFlags are the shared Cloudflare credential flags for `obol domain`.
234+
// Note: --api-token deliberately has no -t alias, to avoid colliding with
235+
// `obol tunnel setup -t` (which takes a tunnel connector token, a different
236+
// credential). The token here is a scoped Cloudflare API token.
237+
func domainAuthFlags() []cli.Flag {
238+
return []cli.Flag{
239+
&cli.StringFlag{Name: "account-id", Aliases: []string{"a"}, Usage: "Cloudflare account ID", Sources: cli.EnvVars("CLOUDFLARE_ACCOUNT_ID")},
240+
&cli.StringFlag{Name: "api-token", Usage: "Cloudflare API token (Account → Domain permission)", Sources: cli.EnvVars("CLOUDFLARE_API_TOKEN")},
241+
&cli.StringFlag{Name: "from-json", Usage: "Read options from JSON file (or - for stdin)"},
242+
}
243+
}
244+
245+
// resolveDomainAPIToken returns the Cloudflare API token from the flag/env or,
246+
// in an interactive session, walks the user through creating a scoped token and
247+
// prompts for it. This mirrors the tunnel connector-token flow.
248+
func resolveDomainAPIToken(u *ui.UI, supplied string) (string, error) {
249+
if token := strings.TrimSpace(supplied); token != "" {
250+
return token, nil
251+
}
252+
253+
if !u.IsTTY() || u.IsJSON() {
254+
return "", errors.New("a Cloudflare API token is required: pass --api-token or set CLOUDFLARE_API_TOKEN.\n" +
255+
"Create one with the Account → Domain permission at https://dash.cloudflare.com/profile/api-tokens")
256+
}
257+
258+
u.Blank()
259+
u.Bold("Cloudflare API token needed")
260+
u.Print("Managing Cloudflare Registrar domains from the CLI needs a scoped API token.")
261+
u.Dim("This is a different credential from the tunnel connector token.")
262+
u.Print(" 1. Open https://dash.cloudflare.com/profile/api-tokens → Create Token")
263+
u.Print(" 2. Grant the Account → Domain permission (and select your account).")
264+
u.Print(" 3. Create the token and copy it.")
265+
u.Blank()
266+
token, err := u.SecretInput("Paste your Cloudflare API token")
267+
if err != nil {
268+
return "", err
269+
}
270+
token = strings.TrimSpace(token)
271+
if token == "" {
272+
return "", errors.New("no Cloudflare API token provided")
273+
}
274+
return token, nil
275+
}
276+
210277
func tunnelTransportProtocolFlag() cli.Flag {
211278
return &cli.StringFlag{
212279
Name: "transport-protocol",
@@ -252,10 +319,31 @@ func setupOptionsFromCommand(cmd *cli.Command) (tunnel.SetupOptions, error) {
252319
}, nil
253320
}
254321

255-
func domainSearchOptionsFromCommand(cmd *cli.Command, u interface {
256-
Input(string, string) (string, error)
257-
},
258-
) (tunnel.DomainSearchOptions, error) {
322+
func domainListOptionsFromCommand(cmd *cli.Command, u *ui.UI) (tunnel.DomainListOptions, error) {
323+
if jsonPath := cmd.String("from-json"); jsonPath != "" {
324+
var opts tunnel.DomainListOptions
325+
data, err := readJSONInput(jsonPath)
326+
if err != nil {
327+
return opts, err
328+
}
329+
if err := json.Unmarshal(data, &opts); err != nil {
330+
return opts, fmt.Errorf("parse domain list JSON: %w", err)
331+
}
332+
return opts, nil
333+
}
334+
335+
token, err := resolveDomainAPIToken(u, cmd.String("api-token"))
336+
if err != nil {
337+
return tunnel.DomainListOptions{}, err
338+
}
339+
340+
return tunnel.DomainListOptions{
341+
AccountID: cmd.String("account-id"),
342+
APIToken: token,
343+
}, nil
344+
}
345+
346+
func domainSearchOptionsFromCommand(cmd *cli.Command, u *ui.UI) (tunnel.DomainSearchOptions, error) {
259347
if jsonPath := cmd.String("from-json"); jsonPath != "" {
260348
var opts tunnel.DomainSearchOptions
261349
data, err := readJSONInput(jsonPath)
@@ -277,16 +365,21 @@ func domainSearchOptionsFromCommand(cmd *cli.Command, u interface {
277365
query = input
278366
}
279367

368+
token, err := resolveDomainAPIToken(u, cmd.String("api-token"))
369+
if err != nil {
370+
return tunnel.DomainSearchOptions{}, err
371+
}
372+
280373
return tunnel.DomainSearchOptions{
281374
Query: query,
282375
Extensions: cmd.StringSlice("extensions"),
283376
Limit: cmd.Int("limit"),
284377
AccountID: cmd.String("account-id"),
285-
APIToken: cmd.String("api-token"),
378+
APIToken: token,
286379
}, nil
287380
}
288381

289-
func domainCheckOptionsFromCommand(cmd *cli.Command) (tunnel.DomainCheckOptions, error) {
382+
func domainCheckOptionsFromCommand(cmd *cli.Command, u *ui.UI) (tunnel.DomainCheckOptions, error) {
290383
if jsonPath := cmd.String("from-json"); jsonPath != "" {
291384
var opts tunnel.DomainCheckOptions
292385
data, err := readJSONInput(jsonPath)
@@ -299,14 +392,19 @@ func domainCheckOptionsFromCommand(cmd *cli.Command) (tunnel.DomainCheckOptions,
299392
return opts, nil
300393
}
301394

395+
token, err := resolveDomainAPIToken(u, cmd.String("api-token"))
396+
if err != nil {
397+
return tunnel.DomainCheckOptions{}, err
398+
}
399+
302400
return tunnel.DomainCheckOptions{
303401
Domains: cmd.Args().Slice(),
304402
AccountID: cmd.String("account-id"),
305-
APIToken: cmd.String("api-token"),
403+
APIToken: token,
306404
}, nil
307405
}
308406

309-
func domainRegisterOptionsFromCommand(cmd *cli.Command) (tunnel.DomainRegisterOptions, error) {
407+
func domainRegisterOptionsFromCommand(cmd *cli.Command, u *ui.UI) (tunnel.DomainRegisterOptions, error) {
310408
if jsonPath := cmd.String("from-json"); jsonPath != "" {
311409
var opts tunnel.DomainRegisterOptions
312410
data, err := readJSONInput(jsonPath)
@@ -319,6 +417,11 @@ func domainRegisterOptionsFromCommand(cmd *cli.Command) (tunnel.DomainRegisterOp
319417
return opts, nil
320418
}
321419

420+
token, err := resolveDomainAPIToken(u, cmd.String("api-token"))
421+
if err != nil {
422+
return tunnel.DomainRegisterOptions{}, err
423+
}
424+
322425
return tunnel.DomainRegisterOptions{
323426
DomainName: cmd.Args().First(),
324427
Years: cmd.Int("years"),
@@ -327,62 +430,78 @@ func domainRegisterOptionsFromCommand(cmd *cli.Command) (tunnel.DomainRegisterOp
327430
ConfirmCharge: cmd.Bool("yes"),
328431
RespondAsync: cmd.Bool("respond-async"),
329432
AccountID: cmd.String("account-id"),
330-
APIToken: cmd.String("api-token"),
433+
APIToken: token,
331434
}, nil
332435
}
333436

334-
func printDomainSuggestions(u interface {
335-
Blank()
336-
Bold(string)
337-
Print(string)
338-
Detail(string, string)
339-
}, result *tunnel.DomainSearchResult,
340-
) {
437+
func printDomainList(u *ui.UI, result *tunnel.DomainListResult) {
438+
u.Blank()
439+
u.Bold("Registered Domains")
440+
if len(result.Domains) == 0 {
441+
u.Print("No domains registered in this Cloudflare account.")
442+
u.Dim("Find one with: obol domain search <keyword>")
443+
return
444+
}
445+
for _, domain := range result.Domains {
446+
u.Print("- " + domain.Name)
447+
if domain.ExpiresAt != "" {
448+
u.Detail(" Expires", domain.ExpiresAt)
449+
}
450+
renew := "off"
451+
if domain.AutoRenew {
452+
renew = "on"
453+
}
454+
u.Detail(" Auto-renew", renew)
455+
}
456+
u.Blank()
457+
u.Dim("Give your stack a permanent URL on one of these: obol tunnel setup --hostname <subdomain>.<domain>")
458+
}
459+
460+
func printDomainSuggestions(u *ui.UI, result *tunnel.DomainSearchResult) {
341461
u.Blank()
342462
u.Bold("Domain Suggestions")
463+
registrable := false
343464
for _, domain := range result.Domains {
344465
summary := "not registrable"
345466
if domain.Registrable {
346467
summary = tunnelSummaryPrice(domain)
468+
registrable = true
347469
}
348470
if domain.Reason != "" {
349471
summary = summary + " — " + domain.Reason
350472
}
351473
u.Print("- " + domain.Name)
352474
u.Detail(" Status", summary)
353475
}
476+
if registrable {
477+
u.Blank()
478+
u.Dim("Register one with: obol domain register <name>")
479+
}
354480
}
355481

356-
func printDomainChecks(u interface {
357-
Blank()
358-
Bold(string)
359-
Print(string)
360-
Detail(string, string)
361-
}, result *tunnel.DomainCheckResult,
362-
) {
482+
func printDomainChecks(u *ui.UI, result *tunnel.DomainCheckResult) {
363483
u.Blank()
364484
u.Bold("Domain Availability")
485+
registrable := false
365486
for _, domain := range result.Domains {
366487
status := "not registrable"
367488
if domain.Registrable {
368489
status = tunnelSummaryPrice(domain)
490+
registrable = true
369491
}
370492
if domain.Reason != "" {
371493
status = status + " — " + domain.Reason
372494
}
373495
u.Print("- " + domain.Name)
374496
u.Detail(" Status", status)
375497
}
498+
if registrable {
499+
u.Blank()
500+
u.Dim("Register one with: obol domain register <name>")
501+
}
376502
}
377503

378-
func printDomainRegistration(u interface {
379-
Blank()
380-
Bold(string)
381-
Print(string)
382-
Detail(string, string)
383-
Successf(string, ...any)
384-
}, result *tunnel.DomainRegisterResult,
385-
) {
504+
func printDomainRegistration(u *ui.UI, result *tunnel.DomainRegisterResult) {
386505
u.Blank()
387506
u.Successf("Domain registration submitted for %s", result.Availability.Name)
388507
u.Detail("Price", tunnelSummaryPrice(result.Availability))
@@ -395,6 +514,11 @@ func printDomainRegistration(u interface {
395514
u.Detail("Domain Resource", result.Workflow.Links.Resource)
396515
}
397516
}
517+
u.Blank()
518+
u.Bold("Next: put your new domain to work")
519+
u.Print("Give your stack a permanent public URL on it with a Cloudflare tunnel:")
520+
u.Printf(" obol tunnel setup --hostname <subdomain>.%s", result.Availability.Name)
521+
u.Dim("A Registrar domain is automatically a zone in your account — all the tunnel needs.")
398522
}
399523

400524
func tunnelSummaryPrice(domain tunnel.CloudflareRegistrarDomainAlias) string {

0 commit comments

Comments
 (0)