package main import ( "bytes" "encoding/json" "flag" "fmt" "os" "path/filepath" ) // defaultBaseURL matches the server's default LISTEN_ADDR (:8080). The dev // server runs on :8090; point at it with --base-url or $TINYFORGE_URL. const defaultBaseURL = "http://localhost:8080" // Config is the persisted CLI state at ~/.tinyforge/config.json. type Config struct { BaseURL string `json:"base_url"` Token string `json:"token"` ExpiresAt string `json:"expires_at"` } // globals holds the cross-cutting flags every command accepts. type globals struct { baseURL *string token *string configPath *string } // addGlobalFlags registers the shared flags on a command's flag set. func addGlobalFlags(fs *flag.FlagSet) *globals { return &globals{ baseURL: fs.String("base-url", "", "Tinyforge server URL (default $TINYFORGE_URL or "+defaultBaseURL+")"), token: fs.String("token", "", "auth token (default $TINYFORGE_TOKEN or cached config)"), configPath: fs.String("config", "", "config file path (default $TINYFORGE_CONFIG or ~/.tinyforge/config.json)"), } } // configFilePath resolves the config file location with precedence: // --config flag > $TINYFORGE_CONFIG > ~/.tinyforge/config.json. func configFilePath(g *globals) (string, error) { if g != nil && *g.configPath != "" { return *g.configPath, nil } if env := os.Getenv("TINYFORGE_CONFIG"); env != "" { return env, nil } home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("locate home directory: %w", err) } return filepath.Join(home, ".tinyforge", "config.json"), nil } // loadConfig reads the config file. A missing file yields a zero Config and no // error — first run is not a failure. func loadConfig(path string) (Config, error) { var cfg Config data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return cfg, nil } return cfg, fmt.Errorf("read config %s: %w", path, err) } // An empty or whitespace-only file (e.g. freshly touched) is treated as // "no config yet" rather than a parse error. if len(bytes.TrimSpace(data)) == 0 { return cfg, nil } if err := json.Unmarshal(data, &cfg); err != nil { return cfg, fmt.Errorf("parse config %s: %w", path, err) } return cfg, nil } // saveConfig writes the config file with 0600 permissions, since it holds a // bearer token. The parent directory is created if absent. func saveConfig(path string, cfg Config) error { if dir := filepath.Dir(path); dir != "" { if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("create config dir: %w", err) } } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return fmt.Errorf("encode config: %w", err) } if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { return fmt.Errorf("write config %s: %w", path, err) } // os.WriteFile only applies the mode when creating the file; Chmod ensures // 0600 even when overwriting a pre-existing, looser-permissioned config. if err := os.Chmod(path, 0o600); err != nil { return fmt.Errorf("secure config %s: %w", path, err) } return nil } // resolveBaseURL applies precedence: --base-url > $TINYFORGE_URL > config > default. func resolveBaseURL(g *globals, cfg Config) string { if g != nil && *g.baseURL != "" { return *g.baseURL } if env := os.Getenv("TINYFORGE_URL"); env != "" { return env } if cfg.BaseURL != "" { return cfg.BaseURL } return defaultBaseURL } // resolveToken applies precedence: --token > $TINYFORGE_TOKEN > config. func resolveToken(g *globals, cfg Config) string { if g != nil && *g.token != "" { return *g.token } if env := os.Getenv("TINYFORGE_TOKEN"); env != "" { return env } return cfg.Token } // session bundles the resolved client with the loaded config and its path, so // commands can both make requests and persist updates (e.g. login). type session struct { client *Client cfg Config configPath string } // newSession loads config and builds a client with resolved base URL + token. func newSession(g *globals) (*session, error) { path, err := configFilePath(g) if err != nil { return nil, err } cfg, err := loadConfig(path) if err != nil { return nil, err } return &session{ client: newClient(resolveBaseURL(g, cfg), resolveToken(g, cfg)), cfg: cfg, configPath: path, }, nil }