@pi-in-go/pigpen-pi-typesafe-api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/login.go ADDED
@@ -0,0 +1,106 @@
1
+ package ui
2
+
3
+ import (
4
+ "context"
5
+
6
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
7
+ pitypesafe "github.com/MichaelKinsy/pigpen/components/pi-typesafe-api"
8
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
9
+ )
10
+
11
+ // LoginResult is a completed login.
12
+ type LoginResult struct {
13
+ // Path is where the verified key was saved.
14
+ Path string
15
+ // Models is the number of models the key can access; it proves the API accepted the key.
16
+ Models int
17
+ }
18
+
19
+ // LoginOptions configure the login helpers.
20
+ type LoginOptions struct {
21
+ // HTTPClient is a transport injection for offline tests.
22
+ HTTPClient typesafe.HTTPDoer
23
+ }
24
+
25
+ // LoginWithPrompt asks for a TypeSafe key (hidden input), verifies it against api.typesafe.ai with a model
26
+ // listing, and stores it for every consumer of this package. It returns nil when the user cancels and a
27
+ // *pitypesafe.IntegrationError for an invalid or unverifiable key. It refuses when TYPESAFE_API_KEY is set,
28
+ // because the environment would shadow the stored key. Other backends have no login: their key is an
29
+ // environment variable only.
30
+ func LoginWithPrompt(ctx sdk.Context, opts LoginOptions) (*LoginResult, error) {
31
+ if pitypesafe.EnvKeySet() {
32
+ return nil, &pitypesafe.IntegrationError{Code: pitypesafe.CodeConfiguration, Message: "TYPESAFE_API_KEY is set in the environment and takes precedence over a stored key. Unset it before logging in interactively."}
33
+ }
34
+ if !ctx.HasUI() {
35
+ return nil, &pitypesafe.IntegrationError{Code: pitypesafe.CodeConfiguration, Message: "Logging in needs an interactive session. Set TYPESAFE_API_KEY in the environment instead."}
36
+ }
37
+ entered, ok, err := PromptForAPIKey(ctx)
38
+ if err != nil {
39
+ return nil, err
40
+ }
41
+ if !ok {
42
+ return nil, nil
43
+ }
44
+ key, err := pitypesafe.NormalizeAPIKey(entered)
45
+ if err != nil {
46
+ return nil, err
47
+ }
48
+ // Verify before saving so a bad paste fails here, not on first use.
49
+ client, err := pitypesafe.New(pitypesafe.Options{APIKey: key, HTTPClient: opts.HTTPClient})
50
+ if err != nil {
51
+ return nil, err
52
+ }
53
+ models, err := client.ListModels(context.Background())
54
+ if err != nil {
55
+ return nil, err
56
+ }
57
+ path, err := pitypesafe.StoreAPIKey(key)
58
+ if err != nil {
59
+ return nil, err
60
+ }
61
+ return &LoginResult{Path: path, Models: len(models)}, nil
62
+ }
63
+
64
+ // EnsureResult is the outcome of EnsureAPIKey: the source of the key in use, and the login when one ran.
65
+ type EnsureResult struct {
66
+ Source pitypesafe.KeySource
67
+ Login *LoginResult
68
+ }
69
+
70
+ // EnsureAPIKey uses the configured key for the backend if there is one; otherwise it runs the login prompt. A
71
+ // nil result means the user cancelled. A store that must not be read returns a configuration error with the
72
+ // reason instead of prompting, so a permissions problem stays visible. A backend without a login store (every
73
+ // backend except TypeSafe) returns a configuration error naming its environment variable instead of
74
+ // prompting, because the prompt would verify the key against the wrong service and store it where the
75
+ // TypeSafe key lives. backend nil means the default.
76
+ func EnsureAPIKey(ctx sdk.Context, backend any, opts LoginOptions) (*EnsureResult, error) {
77
+ if backend == nil {
78
+ backend = pitypesafe.DefaultBackend
79
+ }
80
+ situation, err := pitypesafe.KeySituationFor(backend)
81
+ if err != nil {
82
+ return nil, err
83
+ }
84
+ switch situation.Kind {
85
+ case pitypesafe.KeyEnvironment:
86
+ return &EnsureResult{Source: pitypesafe.SourceEnvironment}, nil
87
+ case pitypesafe.KeyStored:
88
+ return &EnsureResult{Source: pitypesafe.SourceStored}, nil
89
+ case pitypesafe.KeyNotRequired:
90
+ return &EnsureResult{}, nil
91
+ case pitypesafe.KeyUnusable:
92
+ return nil, &pitypesafe.IntegrationError{Code: pitypesafe.CodeConfiguration, Message: situation.Reason}
93
+ }
94
+ config, err := pitypesafe.ResolveBackend(backend)
95
+ if err != nil {
96
+ return nil, err
97
+ }
98
+ if !pitypesafe.UsesTypeSafeKey(config.BackendConfig) {
99
+ return nil, &pitypesafe.IntegrationError{Code: pitypesafe.CodeConfiguration, Message: "No " + config.Label + " key. Set " + config.KeyEnv + " in the environment; /typesafe login stores a TypeSafe key only."}
100
+ }
101
+ login, err := LoginWithPrompt(ctx, opts)
102
+ if err != nil || login == nil {
103
+ return nil, err
104
+ }
105
+ return &EnsureResult{Source: pitypesafe.SourceStored, Login: login}, nil
106
+ }
@@ -0,0 +1,28 @@
1
+ package ui_test
2
+
3
+ // Twin helpers for a port whose original has tests. Every upstream test case gets one
4
+ // tw(...) call carrying the upstream title unchanged, or one tskip(...) with the reason it
5
+ // cannot be ported. `pigeq twins check` reads these calls and fails on a missing title, an
6
+ // unknown one (a misspelling) or one reason shared by many skips.
7
+ //
8
+ // pigeq twins list --tests <upstream>/test > port/upstream-tests.json
9
+ // pigeq twins check --ledger port/upstream-tests.json --go extensions/<name> [--files a,b]
10
+ //
11
+ // Copy this file into the port as twin_test.go and replace ui_test with the package name (in
12
+ // an external test package use `ui_test_test`). A parametrized upstream title (it contains
13
+ // `${`) is twinned once, with the template text unchanged.
14
+
15
+ import "testing"
16
+
17
+ // tw runs one exact twin: file is the upstream test file (its name without .test.mjs), title
18
+ // the upstream test title, unchanged.
19
+ func tw(t *testing.T, file, title string, fn func(t *testing.T)) {
20
+ t.Helper()
21
+ t.Run(title, fn)
22
+ }
23
+
24
+ // tskip records a named gap: the upstream case has no Go twin, with its own reason.
25
+ func tskip(t *testing.T, file, title, reason string) {
26
+ t.Helper()
27
+ t.Run(title, func(t *testing.T) { t.Skip(reason) })
28
+ }
package/ui/ui_test.go ADDED
@@ -0,0 +1,285 @@
1
+ package ui_test
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "io"
7
+ "net/http"
8
+ "os"
9
+ "path/filepath"
10
+ "regexp"
11
+ "runtime"
12
+ "strings"
13
+ "sync/atomic"
14
+ "testing"
15
+ "unicode/utf8"
16
+
17
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
18
+ pitypesafe "github.com/MichaelKinsy/pigpen/components/pi-typesafe-api"
19
+ "github.com/MichaelKinsy/pigpen/components/pi-typesafe-api/ui"
20
+ )
21
+
22
+ func isolate(t *testing.T) string {
23
+ t.Helper()
24
+ dir := t.TempDir()
25
+ agent := filepath.Join(dir, "agent")
26
+ t.Setenv("HOME", dir)
27
+ t.Setenv("PIG_HOME", filepath.Join(dir, "pighome"))
28
+ t.Setenv("PIG_CODING_AGENT_DIR", agent)
29
+ t.Setenv("PIG_USE_PI_DIRS", "")
30
+ for _, name := range []string{"TYPESAFE_API_KEY", "OPENROUTER_API_KEY"} {
31
+ t.Setenv(name, "")
32
+ }
33
+ return agent
34
+ }
35
+
36
+ type doerFunc func(*http.Request) (*http.Response, error)
37
+
38
+ func (f doerFunc) Do(r *http.Request) (*http.Response, error) { return f(r) }
39
+
40
+ // loginRig runs a test extension whose /login command calls the helpers under test.
41
+ type loginRig struct {
42
+ t *testing.T
43
+ host *Host
44
+ custom any // nil: cancelled; string: typed key; "unsupported": no custom components
45
+ inputText string
46
+ modelCalls atomic.Int32
47
+ result *ui.LoginResult
48
+ ensured *ui.EnsureResult
49
+ err error
50
+ backend any
51
+ ensure bool
52
+ }
53
+
54
+ func newLoginRig(t *testing.T, hasUI bool) *loginRig {
55
+ t.Helper()
56
+ r := &loginRig{t: t}
57
+ opts := ui.LoginOptions{HTTPClient: doerFunc(func(req *http.Request) (*http.Response, error) {
58
+ if !strings.HasSuffix(req.URL.Path, "/v1/models") {
59
+ t.Errorf("unexpected request to %s", req.URL)
60
+ return nil, io.ErrUnexpectedEOF
61
+ }
62
+ r.modelCalls.Add(1)
63
+ body, _ := json.Marshal(map[string]any{"models": []any{map[string]any{"name": "jev-latest", "description": "", "release_date": "2026-01-01"}, map[string]any{"name": "jev-preview", "description": "", "release_date": "2026-01-01"}}})
64
+ return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil
65
+ })}
66
+ ext := sdk.New("ui-test")
67
+ ext.Command("run", "runs the helper under test", func(ctx sdk.Context, _ string) error {
68
+ if r.ensure {
69
+ r.ensured, r.err = ui.EnsureAPIKey(ctx, r.backend, opts)
70
+ } else {
71
+ r.result, r.err = ui.LoginWithPrompt(ctx, opts)
72
+ }
73
+ return nil
74
+ })
75
+ r.host = StartHost(t, ext, HostOptions{HasUI: &hasUI, OnCall: func(method string, args map[string]any) (map[string]any, string) {
76
+ switch method {
77
+ case "ui.custom":
78
+ switch v := r.custom.(type) {
79
+ case nil:
80
+ return map[string]any{"ok": false}, ""
81
+ case string:
82
+ if v == "unsupported" {
83
+ return nil, "custom components are unavailable"
84
+ }
85
+ return map[string]any{"ok": true, "result": v}, ""
86
+ }
87
+ case "ui.input":
88
+ if r.custom != "unsupported" {
89
+ return nil, "plain input must not be used when custom UI exists"
90
+ }
91
+ return map[string]any{"text": r.inputText, "ok": true}, ""
92
+ }
93
+ return nil, ""
94
+ }})
95
+ return r
96
+ }
97
+
98
+ func (r *loginRig) run() {
99
+ r.t.Helper()
100
+ r.result, r.ensured, r.err = nil, nil, nil
101
+ if failure := r.host.Command("run", ""); failure != "" {
102
+ r.t.Fatalf("command failed: %s", failure)
103
+ }
104
+ }
105
+
106
+ func isCode(err error, code pitypesafe.ErrorCode) bool {
107
+ ie, ok := err.(*pitypesafe.IntegrationError)
108
+ return ok && ie.Code == code
109
+ }
110
+
111
+ func TestLogin(t *testing.T) {
112
+ tw(t, "login", "loginWithPrompt cancels cleanly, rejects bad keys, and stores a verified key with owner-only permissions", func(t *testing.T) {
113
+ agent := isolate(t)
114
+ stored := filepath.Join(agent, "pi-typesafe", "auth.json")
115
+ r := newLoginRig(t, true)
116
+ r.run()
117
+ if r.result != nil || r.err != nil {
118
+ t.Fatalf("cancel: %v %v", r.result, r.err)
119
+ }
120
+ if _, err := os.Stat(stored); err == nil {
121
+ t.Fatal("nothing may be saved on cancel")
122
+ }
123
+ r.custom = "nope"
124
+ r.run()
125
+ if !isCode(r.err, pitypesafe.CodeValidation) || strings.Contains(r.err.Error(), "nope") || r.modelCalls.Load() != 0 {
126
+ t.Fatalf("bad key: %v", r.err)
127
+ }
128
+ r.custom = "ts_live_key_0123456789abcdef"
129
+ r.run()
130
+ if r.err != nil || r.result == nil || *r.result != (ui.LoginResult{Path: stored, Models: 2}) || r.modelCalls.Load() != 1 {
131
+ t.Fatalf("login: %+v %v", r.result, r.err)
132
+ }
133
+ if info, _ := os.Stat(stored); runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
134
+ t.Errorf("mode = %v", info.Mode().Perm())
135
+ }
136
+ })
137
+ tw(t, "login", "loginWithPrompt refuses when the environment key would shadow the store, or without a UI", func(t *testing.T) {
138
+ agent := isolate(t)
139
+ r := newLoginRig(t, true)
140
+ t.Setenv("TYPESAFE_API_KEY", "env-key-0123456789")
141
+ r.custom = "ts_live_key_0123456789abcdef"
142
+ r.run()
143
+ if !isCode(r.err, pitypesafe.CodeConfiguration) || !strings.Contains(r.err.Error(), "TYPESAFE_API_KEY") {
144
+ t.Fatalf("env: %v", r.err)
145
+ }
146
+ t.Setenv("TYPESAFE_API_KEY", "")
147
+ headless := newLoginRig(t, false)
148
+ headless.run()
149
+ if headless.err == nil || !regexp.MustCompile(`interactive`).MatchString(headless.err.Error()) {
150
+ t.Fatalf("headless: %v", headless.err)
151
+ }
152
+ if _, err := os.Stat(filepath.Join(agent, "pi-typesafe", "auth.json")); err == nil {
153
+ t.Fatal("nothing may be saved")
154
+ }
155
+ })
156
+ tw(t, "login", "ensureApiKey reports an existing key without prompting, otherwise logs in", func(t *testing.T) {
157
+ agent := isolate(t)
158
+ stored := filepath.Join(agent, "pi-typesafe", "auth.json")
159
+ r := newLoginRig(t, true)
160
+ r.ensure = true
161
+ t.Setenv("TYPESAFE_API_KEY", "env-key-0123456789")
162
+ r.custom = "must-not-be-used-0123456789"
163
+ r.run()
164
+ if r.err != nil || r.ensured == nil || r.ensured.Source != pitypesafe.SourceEnvironment || r.ensured.Login != nil {
165
+ t.Fatalf("env: %+v %v", r.ensured, r.err)
166
+ }
167
+ if _, err := os.Stat(stored); err == nil {
168
+ t.Fatal("an existing key must not prompt or store")
169
+ }
170
+ t.Setenv("TYPESAFE_API_KEY", "")
171
+ r.custom = nil
172
+ r.run()
173
+ if r.ensured != nil || r.err != nil {
174
+ t.Fatalf("cancelled prompt: %+v %v", r.ensured, r.err)
175
+ }
176
+ r.custom = "ts_live_key_0123456789abcdef"
177
+ r.run()
178
+ want := ui.EnsureResult{Source: pitypesafe.SourceStored, Login: &ui.LoginResult{Path: stored, Models: 2}}
179
+ if r.err != nil || r.ensured == nil || r.ensured.Source != want.Source || *r.ensured.Login != *want.Login {
180
+ t.Fatalf("login: %+v %v", r.ensured, r.err)
181
+ }
182
+ r.custom = "another-key-that-must-not-replace-it"
183
+ r.run()
184
+ if r.err != nil || r.ensured == nil || r.ensured.Source != pitypesafe.SourceStored || r.ensured.Login != nil || r.modelCalls.Load() != 1 {
185
+ t.Fatalf("second call reuses the stored key: %+v %v", r.ensured, r.err)
186
+ }
187
+ })
188
+ tw(t, "login", "ensureApiKey for another backend uses its environment variable and never opens the TypeSafe login", func(t *testing.T) {
189
+ agent := isolate(t)
190
+ r := newLoginRig(t, true)
191
+ r.ensure = true
192
+ // A stored TypeSafe key does not satisfy OpenRouter, and the prompt must not run: it would verify against api.typesafe.ai.
193
+ r.custom = "ts_live_key_0123456789abcdef"
194
+ r.run()
195
+ if r.err != nil || r.ensured == nil || r.ensured.Login == nil || r.ensured.Login.Path != filepath.Join(agent, "pi-typesafe", "auth.json") {
196
+ t.Fatalf("typesafe login: %+v %v", r.ensured, r.err)
197
+ }
198
+ r.backend = "openrouter"
199
+ r.run()
200
+ if !isCode(r.err, pitypesafe.CodeConfiguration) || !strings.Contains(r.err.Error(), "OPENROUTER_API_KEY") || r.modelCalls.Load() != 1 {
201
+ t.Fatalf("openrouter: %v models=%d", r.err, r.modelCalls.Load())
202
+ }
203
+ t.Setenv("OPENROUTER_API_KEY", "sk-or-0123456789abcdef")
204
+ r.run()
205
+ if r.err != nil || r.ensured == nil || r.ensured.Source != pitypesafe.SourceEnvironment || r.modelCalls.Load() != 1 {
206
+ t.Fatalf("openrouter env: %+v %v", r.ensured, r.err)
207
+ }
208
+ })
209
+ }
210
+
211
+ func stripANSI(s string) string { return regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(s, "") }
212
+
213
+ func TestKeyPrompt(t *testing.T) {
214
+ tw(t, "key-prompt", "typed key is masked on screen and returned on Enter", func(t *testing.T) {
215
+ p := ui.NewKeyPrompt()
216
+ for _, c := range "ts_secret_key_0123456789" {
217
+ if res, err := p.HandleInput(string(c)); err != nil || res.Done {
218
+ t.Fatalf("typing ended early: %+v %v", res, err)
219
+ }
220
+ }
221
+ frame := p.Render(60)
222
+ res, err := p.HandleInput("\r")
223
+ if err != nil || !res.Done || res.Value != "ts_secret_key_0123456789" {
224
+ t.Fatalf("enter: %+v %v", res, err)
225
+ }
226
+ screen := strings.Join(frame, "\n")
227
+ if strings.Contains(screen, "ts_secret") || !strings.Contains(screen, strings.Repeat("•", 24)) || !strings.Contains(screen, "TypeSafe API key") {
228
+ t.Fatalf("screen = %q", screen)
229
+ }
230
+ for _, line := range frame {
231
+ if utf8.RuneCountInString(stripANSI(line)) > 60+8 {
232
+ t.Errorf("line too wide: %q", line)
233
+ }
234
+ }
235
+ })
236
+ tw(t, "key-prompt", "Escape cancels without a value", func(t *testing.T) {
237
+ p := ui.NewKeyPrompt()
238
+ _, _ = p.HandleInput("a")
239
+ res, err := p.HandleInput("\x1b")
240
+ if err != nil || !res.Done || res.Value != nil {
241
+ t.Fatalf("escape: %+v %v", res, err)
242
+ }
243
+ })
244
+ tw(t, "key-prompt", "falls back to Pi's plain input when custom UI is unavailable", func(t *testing.T) {
245
+ isolate(t)
246
+ r := newLoginRig(t, true)
247
+ r.custom = "unsupported"
248
+ r.inputText = " ts_plain_key_0123456789 "
249
+ var got string
250
+ var ok bool
251
+ ext := sdk.New("prompt-test")
252
+ ext.Command("prompt", "", func(ctx sdk.Context, _ string) error {
253
+ var err error
254
+ got, ok, err = ui.PromptForAPIKey(ctx)
255
+ return err
256
+ })
257
+ host := StartHost(t, ext, HostOptions{OnCall: func(method string, _ map[string]any) (map[string]any, string) {
258
+ switch method {
259
+ case "ui.custom":
260
+ return nil, "custom components are unavailable"
261
+ case "ui.input":
262
+ return map[string]any{"text": r.inputText, "ok": true}, ""
263
+ }
264
+ return nil, ""
265
+ }})
266
+ if failure := host.Command("prompt", ""); failure != "" || !ok || got != r.inputText {
267
+ t.Fatalf("fallback: %q %v %q", got, ok, failure)
268
+ }
269
+ })
270
+ }
271
+
272
+ func TestKeyPromptEditing(t *testing.T) {
273
+ p := ui.NewKeyPrompt()
274
+ _, _ = p.HandleInput("\x1b[200~ts_pasted_0123456789\x1b[201~")
275
+ _, _ = p.HandleInput("X")
276
+ _, _ = p.HandleInput("\x7f")
277
+ _, _ = p.HandleInput("\x1b[A") // an arrow key is ignored
278
+ res, _ := p.HandleInput("\r")
279
+ if res.Value != "ts_pasted_0123456789" {
280
+ t.Fatalf("value = %q", res.Value)
281
+ }
282
+ if got := ui.NewKeyPrompt().Render(4); len(got) < 3 {
283
+ t.Fatalf("a narrow render still draws every line: %v", got)
284
+ }
285
+ }