diff --git a/go/internal/cli/cli.go b/go/internal/cli/cli.go index eb9c971..5d0eb5e 100644 --- a/go/internal/cli/cli.go +++ b/go/internal/cli/cli.go @@ -38,7 +38,7 @@ func RunAgentCompat(argv []string, stdout, stderr io.Writer) int { func (a *app) run(argv []string) int { if len(argv) == 0 { - a.usage() + a.guide() return 2 } switch argv[0] { @@ -85,3 +85,38 @@ func (a *app) exit(err error) int { func (a *app) usage() { fmt.Fprintln(a.errOut, "usage: "+a.binary+" [flags]") } + +// guide is the no-argument landing: a friendly walkthrough of the core flow, with a +// first-run welcome when there is no config yet. It goes to stdout (it's help the +// user asked for), while terse usage on an unknown command stays on stderr. +func (a *app) guide() { + b := a.binary + p := func(s string) { fmt.Fprintln(a.out, s) } + if freshSetup() { + p("👋 Welcome to " + b + ". Looks like a fresh setup.") + p("") + p(b + " opens a real shell on your own machines from anywhere — no SSH, fully") + p("end-to-end encrypted. Your identity is a wallet created locally the first time") + p("you run a command; keep its 24-word phrase safe and you can restore it anywhere.") + p("") + } + p(b + " — a real shell on your machines, from anywhere. Every node is symmetric: it") + p("can serve and it can attach.") + p("") + p(" Serve a machine (on the box you want to reach):") + p(" " + b + " up keep it reachable (persistent tmux sessions)") + p(" " + b + " pair make it pairable — prints a code + QR, then waits") + p("") + p(" Reach your machines (where you are):") + p(" " + b + " pair pair to a machine (compare the safety numbers)") + p(" " + b + " attach open its shell, peer-to-peer") + p(" " + b + " attach a b c several at once — Ctrl-O then 1–9 to switch") + p("") + p(" Identity & machines:") + p(" " + b + " wallet address your wallet — this is your owner id") + p(" " + b + " wallet export-phrase back it up (24 words; restores everything)") + p(" " + b + " list machines you've paired") + p("") + p("On the same network, " + b + " attach connects directly over the LAN (no relay) and") + p("falls back to the relay automatically. Full help for any command: " + b + " -h") +} diff --git a/go/internal/cli/cli_test.go b/go/internal/cli/cli_test.go index 0ec4a2b..e1b4587 100644 --- a/go/internal/cli/cli_test.go +++ b/go/internal/cli/cli_test.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "io" + "os" + "path/filepath" "strings" "testing" @@ -60,6 +62,36 @@ func TestRunNoArgs(t *testing.T) { } } +// No-args prints the getting-started guide (not just a terse usage line). +func TestNoArgsShowsGuide(t *testing.T) { + var out, errb bytes.Buffer + Run(nil, &out, &errb) + g := out.String() + for _, want := range []string{"mir attach", "mir pair", "wallet", "LAN"} { + if !strings.Contains(g, want) { + t.Fatalf("guide missing %q:\n%s", want, g) + } + } +} + +// A legacy (pre-wallet) identity attaching is guided to `mir keygen --wallet` +// rather than failing with a cryptic handshake/usage error. +func TestAttachLegacyIdentityGuidesToKeygen(t *testing.T) { + t.Setenv("MIR_NO_UPDATE_CHECK", "1") + dir := t.TempDir() + legacy := `{"owner_priv":"` + strings.Repeat("aa", 32) + `","owner_pub":"` + strings.Repeat("bb", 32) + `"}` + if err := os.WriteFile(filepath.Join(dir, "owner.json"), []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + var out, errb bytes.Buffer + if code := Run([]string{"attach", "--dir", dir, "box"}, &out, &errb); code == 0 { + t.Fatal("attach with a wallet-less identity should fail") + } + if !strings.Contains(errb.String(), "keygen --wallet") || !strings.Contains(errb.String(), "re-paired") { + t.Fatalf("expected a keygen + re-pair migration hint, got:\n%s", errb.String()) + } +} + func TestKeygenPrintsOwnerKey(t *testing.T) { t.Setenv("MIR_NO_UPDATE_CHECK", "1") dir := t.TempDir() diff --git a/go/internal/cli/client_cmds.go b/go/internal/cli/client_cmds.go index a69f798..ca8ce9d 100644 --- a/go/internal/cli/client_cmds.go +++ b/go/internal/cli/client_cmds.go @@ -20,6 +20,41 @@ import ( "github.com/srcful/terminal-relay/go/internal/version" ) +// identity loads the client owner identity (creating it on first use), printing a +// one-time intro when it was just created so a new user learns they have a wallet +// identity and how to back it up. The intro goes to stderr, keeping command stdout +// clean for scripts. +func (a *app) identity(dir string) (*client.Identity, error) { + fresh := !client.IdentityExists(dir) + id, err := client.LoadOrCreateIdentity(dir) + if err != nil { + return nil, err + } + if fresh && id.HasWallet() { + fmt.Fprintf(a.errOut, "✓ created your %s identity — wallet %s\n", a.binary, id.WalletAddress) + fmt.Fprintf(a.errOut, " back it up anytime: %s wallet export-phrase (24 words restore your whole identity)\n\n", a.binary) + } + return id, nil +} + +// requireWallet returns a guided error when a legacy (pre-wallet) identity tries to +// attach, spelling out the one-time keygen + re-pair migration so the user isn't +// surprised when re-pairing turns out to be necessary. +func (a *app) requireWallet(id *client.Identity) error { + if id.HasWallet() { + return nil + } + b := a.binary + fmt.Fprintln(a.errOut, "This identity predates wallets, so it can't attach on this version of "+b+".") + fmt.Fprintln(a.errOut) + fmt.Fprintln(a.errOut, "Upgrade it (one-time):") + fmt.Fprintln(a.errOut, " "+b+" keygen --wallet") + fmt.Fprintln(a.errOut) + fmt.Fprintln(a.errOut, "That mints a NEW identity (new owner id + wallet), so each machine you paired") + fmt.Fprintln(a.errOut, "before must be re-paired: run `"+b+" pair` on the machine and `"+b+" pair ` here.") + return fmt.Errorf("no wallet identity — run `%s keygen --wallet`", b) +} + // cmdSelfUpdate replaces the running binary with the latest GitHub Release // (verified by SHA256) when a newer version exists. a.binary selects the asset // (mir / mir-agent), so the deprecated shim updates its own binary. @@ -63,10 +98,13 @@ func (a *app) cmdRun(args []string) error { name := rest[0] cmd := strings.Join(rest[1:], " ") - idn, err := client.LoadOrCreateIdentity(*dir) + idn, err := a.identity(*dir) if err != nil { return err } + if err := a.requireWallet(idn); err != nil { + return err + } m, err := client.GetMachine(*dir, name) if err != nil { return err @@ -91,7 +129,7 @@ func (a *app) cmdKeygen(args []string) error { dir := fs.String("dir", defaultDir(), "config directory") wallet := fs.Bool("wallet", false, "re-key a legacy identity into a prf-rooted wallet identity (changes owner_id; re-pair needed)") _ = fs.Parse(args) - id, err := client.LoadOrCreateIdentity(*dir) + id, err := a.identity(*dir) if err != nil { return err } @@ -172,10 +210,13 @@ func (a *app) cmdAttach(args []string) error { return err } servers := ice() - idn, err := client.LoadOrCreateIdentity(*dir) + idn, err := a.identity(*dir) if err != nil { return err } + if err := a.requireWallet(idn); err != nil { + return err + } // attach is long-lived, so the backgrounded refresh has time to land for the // next run; surface any cached newer version now (non-blocking). selfupdate.New(repoSlug, a.binary).MaybeNotify(a.errOut, updateCachePath(*dir), version.Version, 24*time.Hour) diff --git a/go/internal/cli/pair.go b/go/internal/cli/pair.go index 9c1f2b2..dc983cd 100644 --- a/go/internal/cli/pair.go +++ b/go/internal/cli/pair.go @@ -138,13 +138,16 @@ func (a *app) pairInitiate(dir, codeStr string, gate sasGate) error { if err != nil { return err } - idn, err := client.LoadOrCreateIdentity(dir) + idn, err := a.identity(dir) if err != nil { return err } + if err := a.requireWallet(idn); err != nil { + return err + } w, err := idn.Wallet() if err != nil { - return fmt.Errorf("pairing needs a wallet; run `mir keygen --wallet`: %w", err) + return err } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() diff --git a/go/internal/cli/shared.go b/go/internal/cli/shared.go index 77bace3..1c260d7 100644 --- a/go/internal/cli/shared.go +++ b/go/internal/cli/shared.go @@ -20,6 +20,18 @@ func defaultDir() string { func updateCachePath(dir string) string { return filepath.Join(dir, "update-check.json") } +// freshSetup reports whether the default config dir holds no mir state yet, so the +// no-argument guide can lead with a one-time welcome. +func freshSetup() bool { + dir := defaultDir() + for _, f := range []string{"owner.json", "config.json", "machines.json"} { + if _, err := os.Stat(filepath.Join(dir, f)); err == nil { + return false + } + } + return true +} + func hostname() string { h, err := os.Hostname() if err != nil { diff --git a/go/internal/client/store.go b/go/internal/client/store.go index be0d8b9..4898f10 100644 --- a/go/internal/client/store.go +++ b/go/internal/client/store.go @@ -38,6 +38,13 @@ type Machine struct { func identityPath(dir string) string { return filepath.Join(dir, "owner.json") } func machinesPath(dir string) string { return filepath.Join(dir, "machines.json") } +// IdentityExists reports whether an owner identity is already stored in dir, so the +// CLI can show a one-time intro the first time it creates one. +func IdentityExists(dir string) bool { + _, err := os.Stat(identityPath(dir)) + return err == nil +} + // LoadOrCreateIdentity reads owner.json, creating a fresh owner keypair on first use. func LoadOrCreateIdentity(dir string) (*Identity, error) { if err := os.MkdirAll(dir, 0o700); err != nil {