diff --git a/.gitignore b/.gitignore index d17bd44..45487d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /mdo +*.md +!README.md diff --git a/README.md b/README.md index 5c24904..0ea8ba0 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Leitor descartável de Markdown para o terminal e navegador, escrito em Go. +> [!NOTE] +> This is a vibe-coded project: it is built experimentally with AI-assisted +> coding and human direction/review. + image image @@ -16,6 +20,30 @@ O `mdo` valida o arquivo, escolhe uma porta local livre, abre o navegador e ence Somente arquivos com extensão exata `.md` são aceitos. +### Compartilhar temporariamente pela internet + +Use `--live` (ou `-l`) para criar um link público temporário com ngrok: + +```bash +mdo --live README.md +# ou +mdo -l README.md +``` + +Antes disso, instale o [ngrok Agent CLI](https://ngrok.com/download), crie ou +acesse sua conta e configure o authtoken uma vez: + +```bash +ngrok config add-authtoken +``` + +O `mdo` imprime o link HTTPS que deve ser compartilhado e mantém o servidor e +o túnel ativos até você pressionar `Ctrl+C`. O modo usa apenas o domínio de +desenvolvimento automático do plano gratuito: não configura domínio reservado +nem recursos pagos. Consulte o [quickstart do ngrok](https://ngrok.com/docs/share-localhost/quickstart) +para obter o token e instruções de instalação. Sem `-l`/`--live`, o `mdo` +continua funcionando somente em localhost e não procura nem exige o ngrok. + ## Recursos - CommonMark e GitHub Flavored Markdown diff --git a/internal/app/app.go b/internal/app/app.go index a608900..61f6e0e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,13 +11,16 @@ import ( "net" "net/http" "os" + "os/signal" "path/filepath" "strings" "sync" + "syscall" "time" "github.com/egomes/mdo/internal/browser" "github.com/egomes/mdo/internal/document" + "github.com/egomes/mdo/internal/ngrok" webassets "github.com/egomes/mdo/internal/web" ) @@ -26,14 +29,20 @@ const ( shutdownTimeout = 20 * time.Second ) -var errUsage = errors.New("uso: mdo ") +var errUsage = errors.New("usage: mdo [-l|--live] ") + +type runOptions struct { + markdownPath string + live bool +} func Run(args []string) error { - if len(args) != 1 { - return errUsage + options, err := parseArgs(args) + if err != nil { + return err } - path, source, err := readMarkdown(args[0]) + path, source, err := readMarkdown(options.markdownPath) if err != nil { return err } @@ -58,7 +67,34 @@ func Run(args []string) error { return fmt.Errorf("montar página: %w", err) } - return serveAndOpen(path, token, page) + var ngrokBinary string + if options.live { + ngrokBinary, err = ngrok.Lookup() + if err != nil { + return err + } + } + + return serveAndOpen(path, token, page, options.live, ngrokBinary) +} + +func parseArgs(args []string) (runOptions, error) { + var options runOptions + for _, arg := range args { + switch arg { + case "-l", "--live": + options.live = true + default: + if strings.HasPrefix(arg, "-") || options.markdownPath != "" { + return runOptions{}, errUsage + } + options.markdownPath = arg + } + } + if options.markdownPath == "" { + return runOptions{}, errUsage + } + return options, nil } func readMarkdown(input string) (string, []byte, error) { @@ -97,7 +133,7 @@ func readMarkdown(input string) (string, []byte, error) { return path, data, nil } -func serveAndOpen(markdownPath, token string, page []byte) error { +func serveAndOpen(markdownPath, token string, page []byte, live bool, ngrokBinary string) error { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return fmt.Errorf("abrir porta local: %w", err) @@ -137,12 +173,24 @@ func serveAndOpen(markdownPath, token string, page []byte) error { } serveErr := make(chan error, 1) go func() { serveErr <- server.Serve(listener) }() + defer shutdownServer(server) url := "http://" + listener.Addr().String() + basePath + var tunnel *ngrok.Tunnel + if live { + tunnel, err = ngrok.Start(ngrokBinary, listener.Addr().String()) + if err != nil { + return err + } + defer tunnel.Close() + fmt.Printf("Live URL: %s%s\nPress Ctrl+C to stop sharing.\n", tunnel.URL(), basePath) + } if err := browser.Open(url); err != nil { - _ = server.Close() return fmt.Errorf("abrir navegador (%s): %w", url, err) } + if live { + return waitForLiveServer(serveErr, tunnel) + } select { case <-ready: @@ -153,12 +201,35 @@ func serveAndOpen(markdownPath, token string, page []byte) error { case <-time.After(shutdownTimeout): } + return nil +} + +func waitForLiveServer(serveErr <-chan error, tunnel *ngrok.Tunnel) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + select { + case <-ctx.Done(): + return nil + case err := <-serveErr: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("local server: %w", err) + } + return nil + case <-tunnel.Done(): + err := tunnel.Err() + if err != nil { + return fmt.Errorf("ngrok stopped: %w", err) + } + return errors.New("ngrok stopped unexpectedly") + } +} + +func shutdownServer(server *http.Server) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := server.Shutdown(ctx); err != nil { _ = server.Close() } - return nil } func setSecurityHeaders(w http.ResponseWriter) { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 55a17c3..6c69489 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -80,9 +80,39 @@ func TestReadMarkdownRejectsLargeFile(t *testing.T) { } func TestRunRequiresOneArgument(t *testing.T) { - for _, args := range [][]string{nil, {"one.md", "two.md"}} { + for _, args := range [][]string{nil, {"one.md", "two.md"}, {"--unknown", "one.md"}} { if err := Run(args); err != errUsage { t.Errorf("Run(%q) error = %v, want %v", args, err, errUsage) } } } + +func TestParseArgs(t *testing.T) { + tests := []struct { + name string + args []string + want runOptions + fail bool + }{ + {name: "local", args: []string{"file.md"}, want: runOptions{markdownPath: "file.md"}}, + {name: "short live flag", args: []string{"-l", "file.md"}, want: runOptions{markdownPath: "file.md", live: true}}, + {name: "long live flag after file", args: []string{"file.md", "--live"}, want: runOptions{markdownPath: "file.md", live: true}}, + {name: "missing file", args: []string{"--live"}, fail: true}, + {name: "unknown flag", args: []string{"--share", "file.md"}, fail: true}, + {name: "two files", args: []string{"one.md", "two.md"}, fail: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseArgs(tt.args) + if tt.fail { + if err != errUsage { + t.Fatalf("parseArgs(%q) error = %v, want %v", tt.args, err, errUsage) + } + return + } + if err != nil || got != tt.want { + t.Fatalf("parseArgs(%q) = %#v, %v; want %#v, nil", tt.args, got, err, tt.want) + } + }) + } +} diff --git a/internal/ngrok/ngrok.go b/internal/ngrok/ngrok.go new file mode 100644 index 0000000..a0a32ff --- /dev/null +++ b/internal/ngrok/ngrok.go @@ -0,0 +1,195 @@ +// Package ngrok starts a free ngrok HTTP tunnel for a local address. +package ngrok + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + "time" +) + +const ( + quickstartURL = "https://ngrok.com/docs/share-localhost/quickstart" + downloadURL = "https://ngrok.com/download" + signupURL = "https://dashboard.ngrok.com/signup" + startupTimeout = 15 * time.Second +) + +var findExecutable = exec.LookPath + +// Lookup verifies that ngrok is available before a local server is started. +func Lookup() (string, error) { + path, err := findExecutable("ngrok") + if err != nil { + return "", setupError("--live requires ngrok") + } + return path, nil +} + +// Tunnel is a running ngrok agent process. +type Tunnel struct { + url string + cmd *exec.Cmd + finished <-chan struct{} + mu sync.Mutex + waitErr error +} + +// URL is the public HTTPS URL assigned by ngrok. +func (t *Tunnel) URL() string { return t.url } + +// Done is closed when the ngrok process stops. +func (t *Tunnel) Done() <-chan struct{} { return t.finished } + +// Err returns the process result after Done has been closed. +func (t *Tunnel) Err() error { + t.mu.Lock() + defer t.mu.Unlock() + return t.waitErr +} + +// Close stops the agent and waits briefly for it to exit. +func (t *Tunnel) Close() error { + if t == nil || t.cmd.Process == nil { + return nil + } + if err := t.cmd.Process.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) { + return err + } + select { + case <-t.finished: + return t.Err() + case <-time.After(2 * time.Second): + if err := t.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { + return err + } + <-t.finished + return t.Err() + } +} + +// Start starts a free HTTP endpoint forwarding to target, such as 127.0.0.1:8080. +func Start(binary, target string) (*Tunnel, error) { + cmd := exec.Command(binary, commandArgs(target)...) + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("prepare ngrok output: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("prepare ngrok errors: %w", err) + } + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start ngrok: %w", err) + } + + lines := make(chan string, 32) + var readers sync.WaitGroup + readers.Add(2) + go readLines(stdout, lines, &readers) + go readLines(stderr, lines, &readers) + go func() { + readers.Wait() + close(lines) + }() + finished := make(chan struct{}) + tunnel := &Tunnel{cmd: cmd, finished: finished} + go func() { + tunnel.mu.Lock() + tunnel.waitErr = cmd.Wait() + tunnel.mu.Unlock() + close(finished) + }() + + var output bytes.Buffer + timer := time.NewTimer(startupTimeout) + defer timer.Stop() + for { + select { + case line, ok := <-lines: + if !ok { + <-finished + return nil, startupError(tunnel.Err(), output.String()) + } + appendOutput(&output, line) + if url := publicURL(line); url != "" { + tunnel.url = url + go drain(lines) + return tunnel, nil + } + case <-finished: + return nil, startupError(tunnel.Err(), output.String()) + case <-timer.C: + _ = cmd.Process.Kill() + <-finished + return nil, startupError(errors.New("timed out waiting for a public URL"), output.String()) + } + } +} + +func commandArgs(target string) []string { + return []string{"http", target, "--log=stdout", "--log-format=json"} +} + +func drain(lines <-chan string) { + for range lines { + } +} + +func readLines(reader io.Reader, lines chan<- string, wg *sync.WaitGroup) { + defer wg.Done() + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 1024), 64<<10) + for scanner.Scan() { + lines <- scanner.Text() + } +} + +func publicURL(line string) string { + var entry struct { + URL string `json:"url"` + } + if json.Unmarshal([]byte(line), &entry) != nil || !strings.HasPrefix(entry.URL, "https://") { + return "" + } + return strings.TrimSuffix(entry.URL, "/") +} + +func appendOutput(output *bytes.Buffer, line string) { + const maxOutput = 4096 + if output.Len() >= maxOutput { + return + } + line = strings.TrimSpace(line) + if line == "" { + return + } + remaining := maxOutput - output.Len() + if len(line) > remaining { + line = line[:remaining] + } + output.WriteString(line) + output.WriteByte('\n') +} + +func startupError(err error, output string) error { + message := "ngrok could not start" + if err != nil { + message += ": " + err.Error() + } + if output != "" { + message += "\nngrok output: " + strings.TrimSpace(output) + } + return setupError(message) +} + +func setupError(message string) error { + return fmt.Errorf("%s\n1. Create an account: %s\n2. Install ngrok: %s\n3. Configure it: ngrok config add-authtoken \nQuickstart: %s", message, signupURL, downloadURL, quickstartURL) +} diff --git a/internal/ngrok/ngrok_test.go b/internal/ngrok/ngrok_test.go new file mode 100644 index 0000000..1e71517 --- /dev/null +++ b/internal/ngrok/ngrok_test.go @@ -0,0 +1,64 @@ +package ngrok + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +func TestPublicURL(t *testing.T) { + tests := []struct { + line string + want string + }{ + {`{"url":"https://example.ngrok-free.app"}`, "https://example.ngrok-free.app"}, + {`{"url":"https://example.ngrok-free.app/"}`, "https://example.ngrok-free.app"}, + {`{"url":"http://example.ngrok-free.app"}`, ""}, + {`not json`, ""}, + } + for _, tt := range tests { + if got := publicURL(tt.line); got != tt.want { + t.Errorf("publicURL(%q) = %q, want %q", tt.line, got, tt.want) + } + } +} + +func TestCommandArgsUseOnlyFreeHTTPTunnelOptions(t *testing.T) { + want := []string{"http", "127.0.0.1:8080", "--log=stdout", "--log-format=json"} + if got := commandArgs("127.0.0.1:8080"); !reflect.DeepEqual(got, want) { + t.Errorf("commandArgs() = %q, want %q", got, want) + } +} + +func TestStartupErrorIncludesConfigurationHelp(t *testing.T) { + err := startupError(nil, "authtoken is required") + message := err.Error() + for _, want := range []string{ + "ngrok could not start", + signupURL, + downloadURL, + "ngrok config add-authtoken ", + quickstartURL, + } { + if !strings.Contains(message, want) { + t.Errorf("startupError() = %q, want %q", message, want) + } + } +} + +func TestLookupExplainsHowToSetUpNgrok(t *testing.T) { + original := findExecutable + t.Cleanup(func() { findExecutable = original }) + findExecutable = func(string) (string, error) { return "", errors.New("not found") } + + _, err := Lookup() + if err == nil { + t.Fatal("Lookup() error = nil, want setup instructions") + } + for _, want := range []string{signupURL, downloadURL, quickstartURL, "ngrok config add-authtoken "} { + if !strings.Contains(err.Error(), want) { + t.Errorf("Lookup() error = %q, want %q", err, want) + } + } +} diff --git a/internal/web/embed_test.go b/internal/web/embed_test.go index 2d5eb14..44d27fd 100644 --- a/internal/web/embed_test.go +++ b/internal/web/embed_test.go @@ -20,6 +20,7 @@ func TestPageIncludesDocumentAndEmbeddedAssets(t *testing.T) { for _, expected := range []string{ `README <test>.md`, `

Hello

`, + ``, `globalThis["mermaid"]`, `const token = "abc123"`, `fetch(`, `enableDiagramZoom`, `Aumentar zoom`, `requestFullscreen`, `Navegação do diagrama`, `Mover diagrama para a esquerda`, diff --git a/internal/web/page.html b/internal/web/page.html index 999a88b..97600fb 100644 --- a/internal/web/page.html +++ b/internal/web/page.html @@ -4,6 +4,7 @@ + {{.Title}} · mdo