feat(office): add Workspace TUI with shared bootstrap and test suite

Introduce the office Bubble Tea browser (module tree, multi-select lists,
markdown preview) alongside oo, with cmd/internal/bootstrap for shared auth,
unit tests across preview/model/ui/fetch, CI build for both binaries, and
GoReleaser archives for office.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 16:29:04 +01:00
co-authored by Cursor
parent 4acec6bbc9
commit f8a73edef3
32 changed files with 2267 additions and 94 deletions
+50
View File
@@ -0,0 +1,50 @@
// Package bootstrap loads CLI environment and constructs an authenticated
// OnlyOffice client. Shared by cmd/oo and cmd/office; the library never
// loads dotfiles.
package bootstrap
import (
"context"
"fmt"
"os"
"strings"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/joho/godotenv"
)
// LoadEnv reads .env from the current working directory and applies OO_* aliases.
func LoadEnv() {
_ = godotenv.Load(".env")
applyEnvAliases()
}
// NewClient loads env, validates credentials, and authenticates against OnlyOffice.
func NewClient(ctx context.Context) (*onlyoffice.Client, error) {
LoadEnv()
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" || creds.User == "" || creds.Password == "" {
return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST/OO_URL), user (ONLYOFFICE_USER or ONLYOFFICE_NAME/OO_USER), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD/OO_PASS)")
}
c := onlyoffice.NewClient(creds)
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
if err := c.AuthenticateContext(ctx); err != nil {
return nil, err
}
return c, nil
}
func applyEnvAliases() {
setEnvIfEmpty("ONLYOFFICE_URL", "OO_URL")
setEnvIfEmpty("ONLYOFFICE_USER", "OO_USER")
setEnvIfEmpty("ONLYOFFICE_PASS", "OO_PASS")
}
func setEnvIfEmpty(dst, src string) {
if strings.TrimSpace(os.Getenv(dst)) != "" {
return
}
if v := strings.TrimSpace(os.Getenv(src)); v != "" {
_ = os.Setenv(dst, v)
}
}
+95
View File
@@ -0,0 +1,95 @@
package bootstrap_test
import (
"context"
"os"
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
)
func TestLoadEnvFromCWDWithOOAliases(t *testing.T) {
clearEnv(t, "ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS", "OO_URL", "OO_USER", "OO_PASS")
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
if err := os.WriteFile(".env", []byte("OO_URL=https://office.example.com\nOO_USER=user@example.com\nOO_PASS=secret\n"), 0o600); err != nil {
t.Fatal(err)
}
bootstrap.LoadEnv()
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://office.example.com" {
t.Fatalf("ONLYOFFICE_URL=%q", got)
}
if got := os.Getenv("ONLYOFFICE_USER"); got != "user@example.com" {
t.Fatalf("ONLYOFFICE_USER=%q", got)
}
if got := os.Getenv("ONLYOFFICE_PASS"); got != "secret" {
t.Fatalf("ONLYOFFICE_PASS=%q", got)
}
}
func TestLoadEnvDoesNotOverrideCanonicalWithAlias(t *testing.T) {
clearEnv(t, "ONLYOFFICE_URL", "OO_URL")
t.Setenv("ONLYOFFICE_URL", "https://canonical.example")
t.Setenv("OO_URL", "https://alias.example")
bootstrap.LoadEnv()
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://canonical.example" {
t.Fatalf("ONLYOFFICE_URL=%q", got)
}
}
func TestNewClientReturnsErrorWithoutCredentials(t *testing.T) {
clearEnv(t,
"ONLYOFFICE_URL", "ONLYOFFICE_HOST", "ONLYOFFICE_USER", "ONLYOFFICE_NAME",
"ONLYOFFICE_PASS", "ONLYOFFICE_PASSWORD",
"OO_URL", "OO_USER", "OO_PASS",
)
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
_, err = bootstrap.NewClient(context.Background())
if err == nil {
t.Fatal("expected error without credentials")
}
msg := err.Error()
for _, want := range []string{"ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS"} {
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
}
}
func clearEnv(t *testing.T, keys ...string) {
t.Helper()
for _, key := range keys {
old, ok := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if ok {
_ = os.Setenv(key, old)
return
}
_ = os.Unsetenv(key)
})
}
}