serve-listen, serve-authorized-keys, serve-host-key, serve-idle-timeout and serve-max-sessions join the registry with number, duration and address kinds. jev.LoadConfig stays as an environment-only reader and telemetryFlags reads through the config, for 012 serve until it takes its settings from the config.
- commit
- 446a78d920b98bb9e1ff565a32f4dd2a72230f02
- parent
- 7e69e7501d42c4e050f00871cdfe1ec98f21db6f
- author
- Dami <dami.etoile@gmail.com>
| M | cmd/ | 16 | +++++++++ |
| M | docs/ | 52 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 13 | +++++++ |
| M | internal/ | 14 | ++++++++ |
| M | internal/ | 36 | +++++++++++++++++++-- |
| M | internal/ | 14 | ++++++++ |
| M | internal/ | 10 | +++--- |
| M | internal/ | 3 | +- |
| M | internal/ | 8 | ++-- |
| M | internal/ | 4 | +- |
@@ -2,6 +2,7 @@ package main
22 33 import (44 "log/slog"5+ "os"56 "runtime/debug"67 78 "github.com/FelineStateMachine/012/internal/config"@@ -21,6 +22,21 @@ func telemetryConfig(c *config.Config) telemetry.Config {
2122 return tc2223 }2324 25+// telemetryFlags takes the telemetry flags (--log, --otlp) out of args,26+// and reads the rest of telemetry's settings from the config file and27+// environment. 012 serve uses it before parsing its own flags.28+func telemetryFlags(args []string) (telemetry.Config, []string, error) {29+ flags, rest, err := config.ParseFlags(args)30+ if err != nil {31+ return telemetry.Config{}, nil, err32+ }33+ path, err := config.DefaultPath()34+ if err != nil {35+ return telemetry.Config{}, nil, err36+ }37+ return telemetryConfig(config.Load(path, os.Getenv, flags)), rest, nil38+}39+2440 // startTelemetry opens the log and reports every recalculation to it.2541 func startTelemetry(c telemetry.Config) (func() error, error) {2642 if info, ok := debug.ReadBuildInfo(); ok {@@ -177,6 +177,58 @@ Send telemetry to this OTLP/HTTP collector, e.g. http://localhost:4318. The othe
177177 | Flag | `--otlp` |178178 | Applies | restart 012 |179179 180+### 012 serve181+182+#### `serve-listen`183+184+The address 012 serve listens on. Anything but the loopback address lets other machines reach it (with an authorized key). See docs/ssh.md.185+186+| | |187+|---|---|188+| Type | host:port |189+| Default | `127.0.0.1:2312` |190+| Applies | restart 012 |191+192+#### `serve-authorized-keys`193+194+The public keys allowed to log in to 012 serve, in OpenSSH's authorized_keys format.195+196+| | |197+|---|---|198+| Type | path |199+| Default | `~/.ssh/authorized_keys` |200+| Applies | restart 012 |201+202+#### `serve-host-key`203+204+012 serve's private host key, generated when missing; ssh_host_ed25519_key in the config directory when empty.205+206+| | |207+|---|---|208+| Type | path |209+| Default | (empty) |210+| Applies | restart 012 |211+212+#### `serve-idle-timeout`213+214+End a 012 serve session that has had no input for this long; 0 never does.215+216+| | |217+|---|---|218+| Type | duration |219+| Default | `30m` |220+| Applies | restart 012 |221+222+#### `serve-max-sessions`223+224+How many 012 serve sessions may run at once; more are turned away.225+226+| | |227+|---|---|228+| Type | number |229+| Default | `8` |230+| Applies | restart 012 |231+180232 ### Config files181233 182234 #### `config-file`@@ -10,6 +10,7 @@ import (
1010 "path/filepath"1111 "strconv"1212 "strings"13+ "time"1314 )1415 1516 // SourceKind says where a value came from.@@ -277,6 +278,18 @@ func (c *Config) Bool(name string) bool {
277278 return b278279 }279280 281+// Int returns a number option.282+func (c *Config) Int(name string) int {283+ n, _ := strconv.Atoi(c.Get(name).Raw)284+ return n285+}286+287+// Duration returns a duration option.288+func (c *Config) Duration(name string) time.Duration {289+ d, _ := time.ParseDuration(c.Get(name).Raw)290+ return d291+}292+280293 // List returns every value of a repeatable option.281294 func (c *Config) List(name string) []Value {282295 mustLookup(name)@@ -280,6 +280,20 @@ func TestRegistry(t *testing.T) {
280280 }281281 }282282 283+func TestServeOptions(t *testing.T) {284+ path := write(t, filepath.Join(t.TempDir(), "config"), "serve-max-sessions = 3\nserve-idle-timeout = 1h\nserve-listen = nope\nserve-max-sessions = 0\n")285+ c := Load(path, noEnv, nil)286+ if c.Int("serve-max-sessions") != 3 || c.Duration("serve-idle-timeout").Minutes() != 60 || c.String("serve-listen") != "127.0.0.1:2312" {287+ t.Errorf("serve: %d %v %q", c.Int("serve-max-sessions"), c.Duration("serve-idle-timeout"), c.String("serve-listen"))288+ }289+ if w := warnings(c); !strings.Contains(w, "isn't host:port") || !strings.Contains(w, "at least 1") {290+ t.Errorf("warnings:\n%s", w)291+ }292+ if home, _ := os.UserHomeDir(); c.String("serve-authorized-keys") != filepath.Join(home, ".ssh", "authorized_keys") {293+ t.Errorf("~ not expanded: %q", c.String("serve-authorized-keys"))294+ }295+}296+283297 func TestDir(t *testing.T) {284298 x := t.TempDir()285299 t.Setenv("XDG_CONFIG_HOME", x)@@ -17,6 +17,7 @@ import (
1717 "net/url"1818 "strconv"1919 "strings"20+ "time"2021 )2122 2223 // Kind is an option's type.@@ -28,11 +29,14 @@ const (
2829 Enum // one of Option.Values2930 Path // a file path; ~ is the home directory3031 URL31- Command // a program and its arguments, run without a shell32+ Command // a program and its arguments, run without a shell33+ Int // a whole number, at least 134+ Duration // e.g. 30m or 1h30m; 0 for never35+ Address // host:port3236 )3337 3438 func (k Kind) String() string {35- return [...]string{"text", "true or false", "one of", "path", "URL", "command"}[k]39+ return [...]string{"text", "true or false", "one of", "path", "URL", "command", "number", "duration", "host:port"}[k]3640 }3741 3842 // Option is one setting.@@ -60,11 +64,12 @@ const (
6064 GroupAppearance = "Appearance"6165 GroupJEV = "JEV functions"6266 GroupTelemetry = "Telemetry"67+ GroupServe = "012 serve"6368 GroupFiles = "Config files"6469 )6570 6671 // Groups is the order groups are listed in.67-var Groups = []string{GroupAppearance, GroupJEV, GroupTelemetry, GroupFiles}72+var Groups = []string{GroupAppearance, GroupJEV, GroupTelemetry, GroupServe, GroupFiles}6873 6974 // Options is every setting. Add an option here and read it with7075 // Config.String, Bool or List; parsing, `012 config`, docs/config.md@@ -107,6 +112,19 @@ var Options = []Option{
107112 "The other OTEL_* variables still apply.",108113 Redact: redactURL},109114 115+ {Name: "serve-listen", Kind: Address, Group: GroupServe, Default: "127.0.0.1:2312",116+ Desc: "The address 012 serve listens on. Anything but the loopback address lets other machines " +117+ "reach it (with an authorized key). See docs/ssh.md."},118+ {Name: "serve-authorized-keys", Kind: Path, Group: GroupServe, Default: "~/.ssh/authorized_keys",119+ Desc: "The public keys allowed to log in to 012 serve, in OpenSSH's authorized_keys format."},120+ {Name: "serve-host-key", Kind: Path, Group: GroupServe,121+ Desc: "012 serve's private host key, generated when missing; " +122+ "ssh_host_ed25519_key in the config directory when empty."},123+ {Name: "serve-idle-timeout", Kind: Duration, Group: GroupServe, Default: "30m",124+ Desc: "End a 012 serve session that has had no input for this long; 0 never does."},125+ {Name: "serve-max-sessions", Kind: Int, Group: GroupServe, Default: "8",126+ Desc: "How many 012 serve sessions may run at once; more are turned away."},127+110128 {Name: "config-file", Kind: Path, Group: GroupFiles, Repeat: true,111129 Desc: "Read another config file after this one, relative to this file's directory. " +112130 "A leading `?` makes it optional: no warning when it doesn't exist."},@@ -154,6 +172,18 @@ func (o *Option) validate(v string) error {
154172 if _, err := SplitCommand(v); err != nil {155173 return err156174 }175+ case Int:176+ if n, err := strconv.Atoi(v); err != nil || n < 1 {177+ return fmt.Errorf("%q isn't a whole number of at least 1", v)178+ }179+ case Duration:180+ if d, err := time.ParseDuration(v); err != nil || d < 0 {181+ return fmt.Errorf("%q isn't a duration like 30m or 1h", v)182+ }183+ case Address:184+ if _, _, err := net.SplitHostPort(v); err != nil {185+ return fmt.Errorf("%q isn't host:port", v)186+ }157187 }158188 if o.Check != nil {159189 return o.Check(v)@@ -140,3 +140,17 @@ func DotEnvHasKey(dir string) bool {
140140 }141141 return false142142 }143+144+// LoadConfig reads TYPESAFE_API_KEY, TYPESAFE_BASE_URL and145+// TYPESAFE_DEFAULT_MODEL from the environment only, reporting false when146+// there's no key or the base URL isn't https (or localhost). The dirs147+// are ignored: .env files are no longer read. New code resolves the key148+// with ResolveKey and takes the rest from internal/config.149+func LoadConfig(dirs ...string) (Config, bool) {150+ c := Config{APIKey: strings.TrimSpace(os.Getenv(KeyEnv)), BaseURL: strings.TrimSpace(os.Getenv("TYPESAFE_BASE_URL")),151+ Model: strings.TrimSpace(os.Getenv("TYPESAFE_DEFAULT_MODEL"))}152+ if c.BaseURL != "" && config.CheckBaseURL(c.BaseURL) != nil {153+ return c, false154+ }155+ return c, c.APIKey != ""156+}@@ -26,19 +26,16 @@ import (
2626 // Images use the terminal's own palette, asked for with OSC 4 once2727 // graphics are known to work, so bars match the text legend.2828 type terminal struct {29+ noImages bool // chart-images = false in the config30+ noNotify bool // notifications = false in the config2931 kitty bool // answers kitty graphics queries3032 tmux bool // inside tmux: sequences for the outer terminal need passthrough3133 cellW, cellH int // cell size in pixels, 0 until reported3234 palette map[int]color.RGBA3335 sent map[int]string // image id -> what was sent, to send only changes3436 blurred bool // the terminal window doesn't have focus35- noImages bool // chart-images = false36- noNotify bool // notifications = false3737 }3838 39-// images reports whether charts are drawn as images.40-func (t *terminal) images() bool { return t.kitty && !t.noImages }41-4239 func newTerminal() terminal {4340 return terminal{tmux: os.Getenv("TMUX") != "", palette: map[int]color.RGBA{}, sent: map[int]string{}}4441 }@@ -152,6 +149,9 @@ func (t *terminal) chartPalette(th *theme.Theme) chart.Palette {
152149 return p153150 }154151 152+// images reports whether charts are drawn as images.153+func (t *terminal) images() bool { return t.kitty && !t.noImages }154+155155 // chartOptions are the drawing options for charts on this terminal.156156 func (t *terminal) chartOptions() chart.Options {157157 return chart.Options{Image: t.images(), CellW: t.cellW, CellH: t.cellH}@@ -91,8 +91,7 @@ type Model struct {
9191 9292 // New returns a model editing s. filename may be empty.9393 func New(s *sheet.Sheet, filename string) *Model {94- return &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(),95- charts: chartState{last: -1}, prefs: prefs{dark: true}}94+ return &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(), charts: chartState{last: -1}}9695 }9796 9897 // Init implements tea.Model. It asks the terminal for its background color@@ -34,7 +34,7 @@ type Settings struct {
3434 // Settings, the theme picker and the API key prompt.3535 type prefs struct {3636 Settings37- dark bool // the terminal's background is dark37+ light bool // the terminal's background is light (dark until it says)3838 preview string // a theme being previewed by the picker3939 }4040 @@ -65,9 +65,9 @@ func (m *Model) applyConfig() string {
6565 func (m *Model) applyTheme() string {6666 name := m.prefs.preview6767 if name == "" {68- name = m.prefs.Config.Theme().Pick(m.prefs.dark)68+ name = m.prefs.Config.Theme().Pick(!m.prefs.light)6969 }70- th, err := theme.Resolve(name, m.prefs.dark, m.prefs.ThemesDir)70+ th, err := theme.Resolve(name, !m.prefs.light, m.prefs.ThemesDir)7171 m.th = th7272 if err != nil {7373 return err.Error()@@ -183,7 +183,7 @@ func (m *Model) keySaved(msg keySavedMsg) {
183183 func (m *Model) handlePrefs(msg tea.Msg) bool {184184 switch msg := msg.(type) {185185 case tea.BackgroundColorMsg:186- m.prefs.dark = msg.IsDark()186+ m.prefs.light = !msg.IsDark()187187 if p := m.applyTheme(); p != "" && m.note == "" {188188 m.note = m.th.Warning.Render(p)189189 }@@ -19,7 +19,7 @@ type themePicker struct {
1919 }2020 2121 func newThemePicker(m *Model) *themePicker {22- current := m.prefs.Config.Theme().Pick(m.prefs.dark)22+ current := m.prefs.Config.Theme().Pick(!m.prefs.light)2323 entries := theme.List(m.prefs.ThemesDir)2424 items := make([]pickItem, 0, len(entries))2525 sel := 0@@ -103,7 +103,7 @@ func (m *Model) keepTheme(name string) {
103103 m.closeOverlay()104104 m.prefs.preview = ""105105 choice := m.prefs.Config.Theme()106- if choice.Light != choice.Dark && m.prefs.dark {106+ if choice.Light != choice.Dark && !m.prefs.light {107107 choice.Dark = name108108 } else if choice.Light != choice.Dark {109109 choice.Light = name