Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion go/internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down Expand Up @@ -85,3 +85,38 @@ func (a *app) exit(err error) int {
func (a *app) usage() {
fmt.Fprintln(a.errOut, "usage: "+a.binary+" <up|attach|list|pair|enroll|pair-dev|keygen|wallet|add-machine|run|self-update|--version> [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 <code> pair to a machine (compare the safety numbers)")
p(" " + b + " attach <name> 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 + " <command> -h")
}
32 changes: 32 additions & 0 deletions go/internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -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()
Expand Down
47 changes: 44 additions & 3 deletions go/internal/cli/client_cmds.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>` 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.
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions go/internal/cli/pair.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
12 changes: 12 additions & 0 deletions go/internal/cli/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions go/internal/client/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading