Skip to content

Commit b73df2e

Browse files
frahlgclaude
andauthored
feat(cli): first-run guide, identity intro, and a guided wallet migration (#45)
Running `mir` with no args now prints a getting-started walkthrough (serve a machine / reach your machines / identity & backup / LAN-direct), with a one-time welcome banner when there's no config yet — instead of a single terse usage line. The first time a client command creates the owner identity it prints a one-line intro (your wallet address + how to back it up with the 24-word phrase). And a legacy (pre-wallet) identity running `mir attach`/`run`/`pair` now gets a clear, guided migration message — `mir keygen --wallet` mints a NEW identity so each machine must be re-paired — rather than a cryptic failure. Adds client.IdentityExists; tests for the guide and the migration hint. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f057e48 commit b73df2e

6 files changed

Lines changed: 136 additions & 6 deletions

File tree

go/internal/cli/cli.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func RunAgentCompat(argv []string, stdout, stderr io.Writer) int {
3838

3939
func (a *app) run(argv []string) int {
4040
if len(argv) == 0 {
41-
a.usage()
41+
a.guide()
4242
return 2
4343
}
4444
switch argv[0] {
@@ -85,3 +85,38 @@ func (a *app) exit(err error) int {
8585
func (a *app) usage() {
8686
fmt.Fprintln(a.errOut, "usage: "+a.binary+" <up|attach|list|pair|enroll|pair-dev|keygen|wallet|add-machine|run|self-update|--version> [flags]")
8787
}
88+
89+
// guide is the no-argument landing: a friendly walkthrough of the core flow, with a
90+
// first-run welcome when there is no config yet. It goes to stdout (it's help the
91+
// user asked for), while terse usage on an unknown command stays on stderr.
92+
func (a *app) guide() {
93+
b := a.binary
94+
p := func(s string) { fmt.Fprintln(a.out, s) }
95+
if freshSetup() {
96+
p("👋 Welcome to " + b + ". Looks like a fresh setup.")
97+
p("")
98+
p(b + " opens a real shell on your own machines from anywhere — no SSH, fully")
99+
p("end-to-end encrypted. Your identity is a wallet created locally the first time")
100+
p("you run a command; keep its 24-word phrase safe and you can restore it anywhere.")
101+
p("")
102+
}
103+
p(b + " — a real shell on your machines, from anywhere. Every node is symmetric: it")
104+
p("can serve and it can attach.")
105+
p("")
106+
p(" Serve a machine (on the box you want to reach):")
107+
p(" " + b + " up keep it reachable (persistent tmux sessions)")
108+
p(" " + b + " pair make it pairable — prints a code + QR, then waits")
109+
p("")
110+
p(" Reach your machines (where you are):")
111+
p(" " + b + " pair <code> pair to a machine (compare the safety numbers)")
112+
p(" " + b + " attach <name> open its shell, peer-to-peer")
113+
p(" " + b + " attach a b c several at once — Ctrl-O then 1–9 to switch")
114+
p("")
115+
p(" Identity & machines:")
116+
p(" " + b + " wallet address your wallet — this is your owner id")
117+
p(" " + b + " wallet export-phrase back it up (24 words; restores everything)")
118+
p(" " + b + " list machines you've paired")
119+
p("")
120+
p("On the same network, " + b + " attach connects directly over the LAN (no relay) and")
121+
p("falls back to the relay automatically. Full help for any command: " + b + " <command> -h")
122+
}

go/internal/cli/cli_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"errors"
66
"fmt"
77
"io"
8+
"os"
9+
"path/filepath"
810
"strings"
911
"testing"
1012

@@ -60,6 +62,36 @@ func TestRunNoArgs(t *testing.T) {
6062
}
6163
}
6264

65+
// No-args prints the getting-started guide (not just a terse usage line).
66+
func TestNoArgsShowsGuide(t *testing.T) {
67+
var out, errb bytes.Buffer
68+
Run(nil, &out, &errb)
69+
g := out.String()
70+
for _, want := range []string{"mir attach", "mir pair", "wallet", "LAN"} {
71+
if !strings.Contains(g, want) {
72+
t.Fatalf("guide missing %q:\n%s", want, g)
73+
}
74+
}
75+
}
76+
77+
// A legacy (pre-wallet) identity attaching is guided to `mir keygen --wallet`
78+
// rather than failing with a cryptic handshake/usage error.
79+
func TestAttachLegacyIdentityGuidesToKeygen(t *testing.T) {
80+
t.Setenv("MIR_NO_UPDATE_CHECK", "1")
81+
dir := t.TempDir()
82+
legacy := `{"owner_priv":"` + strings.Repeat("aa", 32) + `","owner_pub":"` + strings.Repeat("bb", 32) + `"}`
83+
if err := os.WriteFile(filepath.Join(dir, "owner.json"), []byte(legacy), 0o600); err != nil {
84+
t.Fatal(err)
85+
}
86+
var out, errb bytes.Buffer
87+
if code := Run([]string{"attach", "--dir", dir, "box"}, &out, &errb); code == 0 {
88+
t.Fatal("attach with a wallet-less identity should fail")
89+
}
90+
if !strings.Contains(errb.String(), "keygen --wallet") || !strings.Contains(errb.String(), "re-paired") {
91+
t.Fatalf("expected a keygen + re-pair migration hint, got:\n%s", errb.String())
92+
}
93+
}
94+
6395
func TestKeygenPrintsOwnerKey(t *testing.T) {
6496
t.Setenv("MIR_NO_UPDATE_CHECK", "1")
6597
dir := t.TempDir()

go/internal/cli/client_cmds.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,41 @@ import (
2020
"github.com/srcful/terminal-relay/go/internal/version"
2121
)
2222

23+
// identity loads the client owner identity (creating it on first use), printing a
24+
// one-time intro when it was just created so a new user learns they have a wallet
25+
// identity and how to back it up. The intro goes to stderr, keeping command stdout
26+
// clean for scripts.
27+
func (a *app) identity(dir string) (*client.Identity, error) {
28+
fresh := !client.IdentityExists(dir)
29+
id, err := client.LoadOrCreateIdentity(dir)
30+
if err != nil {
31+
return nil, err
32+
}
33+
if fresh && id.HasWallet() {
34+
fmt.Fprintf(a.errOut, "✓ created your %s identity — wallet %s\n", a.binary, id.WalletAddress)
35+
fmt.Fprintf(a.errOut, " back it up anytime: %s wallet export-phrase (24 words restore your whole identity)\n\n", a.binary)
36+
}
37+
return id, nil
38+
}
39+
40+
// requireWallet returns a guided error when a legacy (pre-wallet) identity tries to
41+
// attach, spelling out the one-time keygen + re-pair migration so the user isn't
42+
// surprised when re-pairing turns out to be necessary.
43+
func (a *app) requireWallet(id *client.Identity) error {
44+
if id.HasWallet() {
45+
return nil
46+
}
47+
b := a.binary
48+
fmt.Fprintln(a.errOut, "This identity predates wallets, so it can't attach on this version of "+b+".")
49+
fmt.Fprintln(a.errOut)
50+
fmt.Fprintln(a.errOut, "Upgrade it (one-time):")
51+
fmt.Fprintln(a.errOut, " "+b+" keygen --wallet")
52+
fmt.Fprintln(a.errOut)
53+
fmt.Fprintln(a.errOut, "That mints a NEW identity (new owner id + wallet), so each machine you paired")
54+
fmt.Fprintln(a.errOut, "before must be re-paired: run `"+b+" pair` on the machine and `"+b+" pair <code>` here.")
55+
return fmt.Errorf("no wallet identity — run `%s keygen --wallet`", b)
56+
}
57+
2358
// cmdSelfUpdate replaces the running binary with the latest GitHub Release
2459
// (verified by SHA256) when a newer version exists. a.binary selects the asset
2560
// (mir / mir-agent), so the deprecated shim updates its own binary.
@@ -63,10 +98,13 @@ func (a *app) cmdRun(args []string) error {
6398
name := rest[0]
6499
cmd := strings.Join(rest[1:], " ")
65100

66-
idn, err := client.LoadOrCreateIdentity(*dir)
101+
idn, err := a.identity(*dir)
67102
if err != nil {
68103
return err
69104
}
105+
if err := a.requireWallet(idn); err != nil {
106+
return err
107+
}
70108
m, err := client.GetMachine(*dir, name)
71109
if err != nil {
72110
return err
@@ -91,7 +129,7 @@ func (a *app) cmdKeygen(args []string) error {
91129
dir := fs.String("dir", defaultDir(), "config directory")
92130
wallet := fs.Bool("wallet", false, "re-key a legacy identity into a prf-rooted wallet identity (changes owner_id; re-pair needed)")
93131
_ = fs.Parse(args)
94-
id, err := client.LoadOrCreateIdentity(*dir)
132+
id, err := a.identity(*dir)
95133
if err != nil {
96134
return err
97135
}
@@ -172,10 +210,13 @@ func (a *app) cmdAttach(args []string) error {
172210
return err
173211
}
174212
servers := ice()
175-
idn, err := client.LoadOrCreateIdentity(*dir)
213+
idn, err := a.identity(*dir)
176214
if err != nil {
177215
return err
178216
}
217+
if err := a.requireWallet(idn); err != nil {
218+
return err
219+
}
179220
// attach is long-lived, so the backgrounded refresh has time to land for the
180221
// next run; surface any cached newer version now (non-blocking).
181222
selfupdate.New(repoSlug, a.binary).MaybeNotify(a.errOut, updateCachePath(*dir), version.Version, 24*time.Hour)

go/internal/cli/pair.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,16 @@ func (a *app) pairInitiate(dir, codeStr string, gate sasGate) error {
138138
if err != nil {
139139
return err
140140
}
141-
idn, err := client.LoadOrCreateIdentity(dir)
141+
idn, err := a.identity(dir)
142142
if err != nil {
143143
return err
144144
}
145+
if err := a.requireWallet(idn); err != nil {
146+
return err
147+
}
145148
w, err := idn.Wallet()
146149
if err != nil {
147-
return fmt.Errorf("pairing needs a wallet; run `mir keygen --wallet`: %w", err)
150+
return err
148151
}
149152
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
150153
defer cancel()

go/internal/cli/shared.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ func defaultDir() string {
2020

2121
func updateCachePath(dir string) string { return filepath.Join(dir, "update-check.json") }
2222

23+
// freshSetup reports whether the default config dir holds no mir state yet, so the
24+
// no-argument guide can lead with a one-time welcome.
25+
func freshSetup() bool {
26+
dir := defaultDir()
27+
for _, f := range []string{"owner.json", "config.json", "machines.json"} {
28+
if _, err := os.Stat(filepath.Join(dir, f)); err == nil {
29+
return false
30+
}
31+
}
32+
return true
33+
}
34+
2335
func hostname() string {
2436
h, err := os.Hostname()
2537
if err != nil {

go/internal/client/store.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ type Machine struct {
3838
func identityPath(dir string) string { return filepath.Join(dir, "owner.json") }
3939
func machinesPath(dir string) string { return filepath.Join(dir, "machines.json") }
4040

41+
// IdentityExists reports whether an owner identity is already stored in dir, so the
42+
// CLI can show a one-time intro the first time it creates one.
43+
func IdentityExists(dir string) bool {
44+
_, err := os.Stat(identityPath(dir))
45+
return err == nil
46+
}
47+
4148
// LoadOrCreateIdentity reads owner.json, creating a fresh owner keypair on first use.
4249
func LoadOrCreateIdentity(dir string) (*Identity, error) {
4350
if err := os.MkdirAll(dir, 0o700); err != nil {

0 commit comments

Comments
 (0)