Skip to content

Commit bc1cdaa

Browse files
committed
Start on config wizard
1 parent 6189fcb commit bc1cdaa

File tree

5 files changed

+410
-0
lines changed

5 files changed

+410
-0
lines changed

cmd/tess.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,24 @@ func loadConfigFromTOML(path string) (fileConfig, error) {
9090
}
9191

9292
func main() {
93+
// Subcommand dispatch (before parsing flags)
94+
if len(os.Args) > 1 {
95+
switch os.Args[1] {
96+
case "setup":
97+
if err := api.RunSetup(context.Background()); err != nil {
98+
fmt.Fprintf(os.Stderr, "setup error: %v\n", err)
99+
os.Exit(1)
100+
}
101+
return
102+
case "doctor":
103+
code := api.RunDoctor(context.Background())
104+
if code != 0 {
105+
os.Exit(code)
106+
}
107+
return
108+
}
109+
}
110+
93111
cfgFlag := flag.String("config", "", "Path to config TOML (default: ~/.tess/config.toml)")
94112
rcloneRemote := flag.String("rclone-remote", "drive", "rclone remote name to upload to (default: drive)")
95113
rcloneFolderID := flag.String("rclone-folder-id", "", "Google Drive folder ID; if set, upload via rclone to this folder")

internal/config.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package internal
2+
3+
import (
4+
"bufio"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
)
11+
12+
// FileConfig represents the user configuration stored in TOML.
13+
type FileConfig struct {
14+
APIKey string
15+
RcloneRemote string
16+
TemplateHubID string
17+
TemplateCoverID string
18+
TemplateReviewID string
19+
}
20+
21+
// DefaultConfigPath returns ~/.tess/config.toml.
22+
func DefaultConfigPath() (string, error) {
23+
home, err := os.UserHomeDir()
24+
if err != nil {
25+
return "", err
26+
}
27+
return filepath.Join(home, ".tess", "config.toml"), nil
28+
}
29+
30+
// LoadConfig reads a minimal TOML and returns the FileConfig.
31+
func LoadConfig(path string) (FileConfig, error) {
32+
f, err := os.Open(path)
33+
if err != nil {
34+
if errors.Is(err, os.ErrNotExist) {
35+
return FileConfig{}, fmt.Errorf("config file not found: %s", path)
36+
}
37+
return FileConfig{}, err
38+
}
39+
defer f.Close()
40+
var cfg FileConfig
41+
scanner := bufio.NewScanner(f)
42+
for scanner.Scan() {
43+
line := scanner.Text()
44+
if i := strings.Index(line, "#"); i >= 0 {
45+
line = line[:i]
46+
}
47+
line = strings.TrimSpace(line)
48+
if line == "" || strings.HasPrefix(line, "[") {
49+
continue
50+
}
51+
parts := strings.SplitN(line, "=", 2)
52+
if len(parts) != 2 {
53+
continue
54+
}
55+
key := strings.TrimSpace(parts[0])
56+
val := strings.TrimSpace(parts[1])
57+
val = strings.Trim(val, " \t")
58+
if len(val) >= 2 {
59+
if (val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'') {
60+
val = val[1 : len(val)-1]
61+
}
62+
}
63+
switch key {
64+
case "api_key":
65+
cfg.APIKey = val
66+
case "rclone_remote":
67+
cfg.RcloneRemote = strings.TrimSpace(val)
68+
case "template_hub_id":
69+
cfg.TemplateHubID = strings.TrimSpace(val)
70+
case "template_cover_id":
71+
cfg.TemplateCoverID = strings.TrimSpace(val)
72+
case "template_review_id":
73+
cfg.TemplateReviewID = strings.TrimSpace(val)
74+
}
75+
}
76+
if err := scanner.Err(); err != nil {
77+
return FileConfig{}, err
78+
}
79+
if strings.TrimSpace(cfg.APIKey) == "" {
80+
return FileConfig{}, fmt.Errorf("missing 'api_key' in config: %s", path)
81+
}
82+
return cfg, nil
83+
}
84+
85+
// EnsureConfigDir ensures the parent directory for path exists.
86+
func EnsureConfigDir(path string) error {
87+
dir := filepath.Dir(path)
88+
return os.MkdirAll(dir, 0o755)
89+
}
90+
91+
// SaveConfig writes a minimal TOML to path.
92+
func SaveConfig(path string, cfg FileConfig) error {
93+
if err := EnsureConfigDir(path); err != nil {
94+
return err
95+
}
96+
var b strings.Builder
97+
if strings.TrimSpace(cfg.APIKey) != "" {
98+
fmt.Fprintf(&b, "api_key = \"%s\"\n", escape(cfg.APIKey))
99+
}
100+
if strings.TrimSpace(cfg.RcloneRemote) != "" {
101+
fmt.Fprintf(&b, "rclone_remote = \"%s\"\n", escape(cfg.RcloneRemote))
102+
}
103+
if strings.TrimSpace(cfg.TemplateHubID) != "" {
104+
fmt.Fprintf(&b, "template_hub_id = \"%s\"\n", escape(cfg.TemplateHubID))
105+
}
106+
if strings.TrimSpace(cfg.TemplateCoverID) != "" {
107+
fmt.Fprintf(&b, "template_cover_id = \"%s\"\n", escape(cfg.TemplateCoverID))
108+
}
109+
if strings.TrimSpace(cfg.TemplateReviewID) != "" {
110+
fmt.Fprintf(&b, "template_review_id = \"%s\"\n", escape(cfg.TemplateReviewID))
111+
}
112+
return os.WriteFile(path, []byte(b.String()), 0o600)
113+
}
114+
115+
func escape(s string) string {
116+
// Very small escape to avoid stray quotes in TOML values we write.
117+
s = strings.ReplaceAll(s, "\\", "\\\\")
118+
s = strings.ReplaceAll(s, "\"", "\\\"")
119+
return s
120+
}

internal/doctor.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package internal
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"strings"
8+
)
9+
10+
// RunDoctor inspects the user's environment and prints actionable diagnostics.
11+
func RunDoctor(ctx context.Context) int {
12+
// Status helpers
13+
ok := func(msg string) { fmt.Printf("✓ %s\n", msg) }
14+
warn := func(msg string) { fmt.Printf("! %s\n", msg) }
15+
bad := func(msg string) { fmt.Printf("✗ %s\n", msg) }
16+
17+
// Config
18+
cfgPath, err := DefaultConfigPath()
19+
if err != nil {
20+
bad(fmt.Sprintf("determine config path: %v", err))
21+
return 1
22+
}
23+
fmt.Printf("Tess doctor\n\n")
24+
fmt.Printf("Config path: %s\n", cfgPath)
25+
cfg, err := LoadConfig(cfgPath)
26+
if err != nil {
27+
bad(err.Error())
28+
fmt.Printf("Hint: run 'tess setup' to create a config.\n")
29+
return 1
30+
}
31+
masked := maskToken(cfg.APIKey)
32+
ok("Loaded config")
33+
fmt.Printf("- api_key: %s\n", masked)
34+
if strings.TrimSpace(cfg.RcloneRemote) != "" {
35+
fmt.Printf("- rclone_remote: %s\n", strings.TrimSpace(cfg.RcloneRemote))
36+
}
37+
38+
// API token check (lightweight /v1/me)
39+
client, err := NewClient(cfg.APIKey)
40+
if err != nil {
41+
bad(fmt.Sprintf("invalid API key: %v", err))
42+
return 1
43+
}
44+
if me, err := client.GetMe(ctx); err == nil && me != nil && strings.TrimSpace(me.ID) != "" {
45+
ok("Lattice API reachable and token accepted")
46+
fmt.Printf("- Current user: %s (%s)\n", me.Name, me.Email)
47+
} else if err != nil {
48+
bad(fmt.Sprintf("Lattice API check failed: %v", err))
49+
fmt.Printf("- Ensure your key is valid; if missing 'Bearer', Tess adds it automatically.\n")
50+
}
51+
52+
// Optional tools
53+
if err := RcloneAvailable(); err != nil {
54+
warn("rclone not found (Drive upload disabled). Install from https://rclone.org")
55+
} else {
56+
ok("rclone found")
57+
// Check the configured remote exists (if provided)
58+
if strings.TrimSpace(cfg.RcloneRemote) != "" {
59+
exists, err := RemoteExists(ctx, cfg.RcloneRemote)
60+
if err != nil {
61+
warn(fmt.Sprintf("could not verify rclone remotes: %v", err))
62+
} else if !exists {
63+
warn(fmt.Sprintf("rclone remote '%s' not found. Run 'rclone config' and create it (Storage: drive)", cfg.RcloneRemote))
64+
} else {
65+
ok(fmt.Sprintf("rclone remote '%s' present", cfg.RcloneRemote))
66+
}
67+
}
68+
}
69+
if err := HasPandoc(); err != nil {
70+
warn("pandoc not found (DOCX/PDF export disabled). Install from https://pandoc.org")
71+
} else {
72+
ok("pandoc found")
73+
}
74+
75+
// PATH sanity (best-effort)
76+
path := os.Getenv("PATH")
77+
if !strings.Contains(path, "/usr/local/bin") && !strings.Contains(path, "/opt/homebrew/bin") {
78+
warn("/usr/local/bin or /opt/homebrew/bin not in PATH (Homebrew installs may not be visible)")
79+
}
80+
81+
fmt.Printf("\nAll done. If something looks off, try 'tess setup' or check the README.\n")
82+
return 0
83+
}
84+
85+
func maskToken(v string) string {
86+
v = strings.TrimSpace(v)
87+
if v == "" {
88+
return "(empty)"
89+
}
90+
// strip common prefixes for masking logic, then reattach
91+
lower := strings.ToLower(v)
92+
prefix := ""
93+
switch {
94+
case strings.HasPrefix(lower, "bearer "):
95+
prefix = v[:7]
96+
v = v[7:]
97+
case strings.HasPrefix(lower, "basic "):
98+
prefix = v[:6]
99+
v = v[6:]
100+
case strings.HasPrefix(lower, "token "):
101+
prefix = v[:6]
102+
v = v[6:]
103+
case strings.HasPrefix(lower, "lattice "):
104+
prefix = v[:8]
105+
v = v[8:]
106+
}
107+
if len(v) <= 8 {
108+
return prefix + strings.Repeat("*", len(v))
109+
}
110+
return prefix + v[:4] + strings.Repeat("*", len(v)-8) + v[len(v)-4:]
111+
}

internal/rclone.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package internal
33
import (
44
"context"
55
"fmt"
6+
"os"
67
"os/exec"
78
"strings"
89
)
@@ -62,3 +63,57 @@ func CopyByIDToFolder(ctx context.Context, remoteName, folderID, fileID string)
6263
}
6364
return nil
6465
}
66+
67+
// RemoteExists returns true if an rclone remote with the given name exists.
68+
func RemoteExists(ctx context.Context, name string) (bool, error) {
69+
if err := RcloneAvailable(); err != nil {
70+
return false, err
71+
}
72+
cmd := exec.CommandContext(ctx, "rclone", "listremotes")
73+
out, err := cmd.Output()
74+
if err != nil {
75+
return false, fmt.Errorf("rclone listremotes failed: %w", err)
76+
}
77+
target := strings.TrimSpace(name)
78+
for _, ln := range strings.Split(string(out), "\n") {
79+
ln = strings.TrimSpace(strings.TrimSuffix(ln, ":"))
80+
if ln == target && ln != "" {
81+
return true, nil
82+
}
83+
}
84+
return false, nil
85+
}
86+
87+
// RunRcloneConfig launches the interactive rclone config wizard attached to the current stdio.
88+
func RunRcloneConfig(ctx context.Context) error {
89+
if err := RcloneAvailable(); err != nil {
90+
return err
91+
}
92+
cmd := exec.CommandContext(ctx, "rclone", "config")
93+
cmd.Stdin = os.Stdin
94+
cmd.Stdout = os.Stdout
95+
cmd.Stderr = os.Stderr
96+
return cmd.Run()
97+
}
98+
99+
// CreateDriveRemote attempts to non-interactively create a Google Drive remote
100+
// with the given name and scope using rclone's config create command.
101+
// It may still open a browser window to complete OAuth, but avoids the menu wizard.
102+
func CreateDriveRemote(ctx context.Context, name string, scope string) error {
103+
if err := RcloneAvailable(); err != nil {
104+
return err
105+
}
106+
s := strings.TrimSpace(scope)
107+
if s == "" {
108+
s = "drive"
109+
}
110+
args := []string{"config", "create", name, "drive", "scope=" + s}
111+
cmd := exec.CommandContext(ctx, "rclone", args...)
112+
cmd.Stdin = os.Stdin
113+
cmd.Stdout = os.Stdout
114+
cmd.Stderr = os.Stderr
115+
if err := cmd.Run(); err != nil {
116+
return fmt.Errorf("rclone config create failed: %w", err)
117+
}
118+
return nil
119+
}

0 commit comments

Comments
 (0)