@pippit-dev/cli 1.0.41 → 1.0.43

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.
Files changed (61) hide show
  1. package/README.md +42 -11
  2. package/checksums.txt +6 -6
  3. package/cmd/auth/auth.go +19 -5
  4. package/cmd/auth/auth_test.go +23 -0
  5. package/cmd/canvas/canvas.go +7 -7
  6. package/cmd/download_result.go +3 -2
  7. package/cmd/marketing.go +19 -3
  8. package/cmd/marketing_test.go +11 -5
  9. package/cmd/model.go +5 -4
  10. package/cmd/root.go +17 -5
  11. package/cmd/root_update_check_test.go +139 -0
  12. package/cmd/short_drama_test.go +2 -0
  13. package/cmd/source_test.go +27 -4
  14. package/cmd/space.go +199 -0
  15. package/cmd/space_test.go +469 -0
  16. package/cmd/upload_file_test.go +3 -1
  17. package/dist/checksums.txt +6 -6
  18. package/internal/adaptercli/commands_test.go +9 -5
  19. package/internal/auth/loopback.go +16 -11
  20. package/internal/auth/loopback_origins_test.go +152 -0
  21. package/internal/auth/space.go +26 -0
  22. package/internal/auth/space_test.go +42 -0
  23. package/internal/canvas/get.go +9 -2
  24. package/internal/commandnames/names.go +9 -4
  25. package/internal/common/client.go +23 -0
  26. package/internal/common/host_source.go +5 -0
  27. package/internal/common/host_source_test.go +9 -1
  28. package/internal/common/runner.go +5 -3
  29. package/internal/common/space.go +185 -0
  30. package/internal/common/space_test.go +132 -0
  31. package/internal/common/submit_run.go +2 -0
  32. package/internal/common/upload_file.go +2 -0
  33. package/internal/config/config.go +15 -12
  34. package/internal/generate_image/generate_image.go +3 -9
  35. package/internal/short_drama/submit_run.go +3 -9
  36. package/internal/space/session.go +153 -0
  37. package/internal/space/store.go +211 -0
  38. package/internal/space/store_test.go +246 -0
  39. package/internal/version/version.go +13 -0
  40. package/internal/version/version_test.go +38 -0
  41. package/internal/versioncheck/check.go +147 -0
  42. package/internal/versioncheck/check_test.go +204 -0
  43. package/internal/versioncheck/semver.go +84 -0
  44. package/package.json +1 -1
  45. package/scripts/canvas-command.js +47 -5
  46. package/scripts/install-wizard.js +1 -1
  47. package/scripts/run.js +15 -4
  48. package/scripts/telemetry.js +3 -1
  49. package/scripts/version-check.js +8 -1
  50. package/skills/short-drama/SKILL.md +12 -1
  51. package/skills/xyq-marketing-skill/SKILL.md +22 -3
  52. package/skills/xyq-marketing-skill/references/api.md +7 -1
  53. package/skills/xyq-marketing-skill/scripts/marketing.js +51 -19
  54. package/skills/xyq-nest-skill/SKILL.md +15 -4
  55. package/skills/xyq-nest-skill/commands/canvas-assets.md +1 -1
  56. package/skills/xyq-nest-skill/commands/canvas.md +5 -3
  57. package/skills/xyq-nest-skill/commands/get-credit-balance.md +2 -2
  58. package/skills/xyq-nest-skill/commands/model.md +1 -1
  59. package/skills/xyq-nest-skill/scripts/ensure-cli.js +8 -1
  60. package/skills/xyq-nest-skill/scripts/install.md +1 -1
  61. package/skills/xyq-nest-skill/workflows/canvas-edit.md +2 -2
@@ -0,0 +1,246 @@
1
+ package space
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func testStore(t *testing.T) *Store {
13
+ t.Helper()
14
+ return &Store{Dir: t.TempDir(), Account: "account-a", Origin: "https://example.test"}
15
+ }
16
+
17
+ func TestTaskScopeSurvivesDefaultSwitchAndRejectsConflicts(t *testing.T) {
18
+ s := testStore(t)
19
+ a := Selection{TeamID: "12", Name: "A"}
20
+ b := Selection{TeamID: "34", Name: "B"}
21
+ ref := References{ThreadID: "thread", RunID: "run", AssetIDs: []string{"asset"}}
22
+ if err := s.Remember(ref, a); err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ if err := s.SaveDefault(b); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ got, err := (&Session{Store: s}).Resolve(ref)
29
+ if err != nil || got.TeamID != "12" {
30
+ t.Fatalf("old task = %+v, %v", got, err)
31
+ }
32
+ got, err = (&Session{Store: s}).Resolve(References{})
33
+ if err != nil || got.TeamID != "34" {
34
+ t.Fatalf("new task = %+v, %v", got, err)
35
+ }
36
+ if _, err := (&Session{Store: s, Explicit: &b.TeamID}).Resolve(ref); err == nil {
37
+ t.Fatal("explicit conflicting team accepted")
38
+ }
39
+ if _, err := (&Session{Store: s}).Resolve(References{AssetIDs: []string{"asset"}}); err == nil {
40
+ t.Fatal("cross-team asset accepted")
41
+ }
42
+ zero := "0"
43
+ got, err = (&Session{Store: s, Explicit: &zero}).Resolve(References{})
44
+ if err != nil || got.TeamID != "0" {
45
+ t.Fatalf("personal override = %+v, %v", got, err)
46
+ }
47
+ current, _ := s.Current()
48
+ if current.TeamID != "34" {
49
+ t.Fatal("one-shot override changed saved default")
50
+ }
51
+ if err := os.Remove(s.file("thread", "thread")); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ got, err = (&Session{Store: s}).Resolve(ref)
55
+ if err != nil || got.TeamID != "12" {
56
+ t.Fatalf("partial task record = %+v, %v", got, err)
57
+ }
58
+ }
59
+
60
+ func TestCorruptRecordRequiresExplicitRecovery(t *testing.T) {
61
+ s := testStore(t)
62
+ ref := References{ThreadID: "thread", RunID: "run"}
63
+ a := Selection{TeamID: "12", Name: "A"}
64
+ if err := s.Remember(ref, a); err != nil {
65
+ t.Fatal(err)
66
+ }
67
+ if err := os.WriteFile(s.file("run", "run"), []byte("broken"), 0600); err != nil {
68
+ t.Fatal(err)
69
+ }
70
+ if _, err := (&Session{Store: s}).Resolve(ref); err == nil {
71
+ t.Fatal("corrupt record treated as missing")
72
+ }
73
+ other := "34"
74
+ if _, err := (&Session{Store: s, Explicit: &other}).Resolve(ref); err == nil {
75
+ t.Fatal("corrupt run overrode valid thread")
76
+ }
77
+ if _, err := (&Session{Store: s, Explicit: &a.TeamID}).Resolve(ref); err != nil {
78
+ t.Fatal(err)
79
+ }
80
+ if err := s.Remember(ref, a); err != nil {
81
+ t.Fatal(err)
82
+ }
83
+ if _, err := (&Session{Store: s}).Resolve(ref); err != nil {
84
+ t.Fatal(err)
85
+ }
86
+ if err := os.WriteFile(s.file("default", ""), []byte("broken"), 0600); err != nil {
87
+ t.Fatal(err)
88
+ }
89
+ if _, err := (&Session{Store: s}).Resolve(References{}); err == nil {
90
+ t.Fatal("corrupt default fell back to personal")
91
+ }
92
+ zero := "0"
93
+ if _, err := (&Session{Store: s, Explicit: &zero}).Resolve(References{}); err != nil {
94
+ t.Fatal(err)
95
+ }
96
+ }
97
+
98
+ func TestSpaceIdentityAndInt64Boundary(t *testing.T) {
99
+ for _, raw := range []string{"", "-1", "1.5", " 12", "+12", "9223372036854775808"} {
100
+ if _, err := ParseTeamID(raw); err == nil {
101
+ t.Errorf("accepted %q", raw)
102
+ }
103
+ }
104
+ s := testStore(t)
105
+ for raw, want := range map[string]string{"0": "0", "000": "0", "00012": "12", "9223372036854775807": "9223372036854775807"} {
106
+ if got, err := ParseTeamID(raw); err != nil || got != want {
107
+ t.Errorf("%q = %q, %v", raw, got, err)
108
+ }
109
+ if err := s.SaveDefault(Selection{TeamID: raw}); err != nil {
110
+ t.Fatalf("save %q: %v", raw, err)
111
+ }
112
+ if got, err := s.Current(); err != nil || got.TeamID != want {
113
+ t.Errorf("read saved %q = %+v, %v; want team_id %q", raw, got, err, want)
114
+ }
115
+ }
116
+ a, err := NewStore("https://EXAMPLE.test:443/path", "account-a")
117
+ if err != nil {
118
+ t.Fatal(err)
119
+ }
120
+ b, _ := NewStore("https://example.test", "account-a")
121
+ c, _ := NewStore("https://example.test", "account-b")
122
+ d, _ := NewStore("https://other.test", "account-a")
123
+ if a.Dir != b.Dir || a.Dir == c.Dir || a.Dir == d.Dir {
124
+ t.Fatal("account/origin isolation failed")
125
+ }
126
+ }
127
+
128
+ func TestFixedSessionRequiresExplicitRecordRecovery(t *testing.T) {
129
+ for _, teamID := range []string{"0", "12"} {
130
+ for _, bindingOnly := range []bool{false, true} {
131
+ s := testStore(t)
132
+ selected := Selection{TeamID: teamID}
133
+ if err := s.SaveDefault(selected); err != nil {
134
+ t.Fatal(err)
135
+ }
136
+ session := &Session{Store: s}
137
+ if _, err := session.Resolve(References{}); err != nil {
138
+ t.Fatal(err)
139
+ }
140
+ if bindingOnly {
141
+ binding, err := DecodeBinding(session.Token())
142
+ if err != nil {
143
+ t.Fatal(err)
144
+ }
145
+ session = &Session{Store: s, Expected: binding}
146
+ }
147
+ if err := s.SaveDefault(Selection{TeamID: "34"}); err != nil {
148
+ t.Fatal(err)
149
+ }
150
+ ref := References{ThreadID: "thread", RunID: "run"}
151
+ if err := s.Remember(ref, selected); err != nil {
152
+ t.Fatal(err)
153
+ }
154
+ if err := os.WriteFile(s.file("run", "run"), []byte("broken"), 0600); err != nil {
155
+ t.Fatal(err)
156
+ }
157
+ if _, err := session.Resolve(ref); err == nil {
158
+ t.Fatalf("team %s, binding=%v: fixed space bypassed corrupt record", teamID, bindingOnly)
159
+ }
160
+ session.Explicit = &teamID
161
+ got, err := session.Resolve(ref)
162
+ if err != nil || got.TeamID != teamID {
163
+ t.Fatalf("explicit recovery = %+v, %v", got, err)
164
+ }
165
+ if err := s.Remember(References{ThreadID: "other"}, Selection{TeamID: "34"}); err != nil {
166
+ t.Fatal(err)
167
+ }
168
+ if _, err := session.Resolve(References{ThreadID: "other"}); err == nil {
169
+ t.Fatal("fixed space accepted a conflicting task")
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ func TestSessionWithoutStoreKeepsExplicitSpace(t *testing.T) {
176
+ for _, teamID := range []string{"0", "12", "34"} {
177
+ explicit := teamID
178
+ session := &Session{Explicit: &explicit}
179
+ selected, err := session.Resolve(References{})
180
+ if err != nil || selected.TeamID != teamID {
181
+ t.Fatalf("space = %+v, %v", selected, err)
182
+ }
183
+ explicit = "56"
184
+ selected, err = session.Resolve(References{ThreadID: "thread", RunID: "run"})
185
+ if err != nil || selected.TeamID != teamID {
186
+ t.Fatalf("command changed its frozen space: %+v, %v", selected, err)
187
+ }
188
+ session.Remember(References{ThreadID: "thread", RunID: "run"})
189
+ if session.Token() != "" {
190
+ t.Fatal("unknown account must not produce an identity binding")
191
+ }
192
+ }
193
+ session := &Session{Expected: &Binding{Selection: Personal(), Account: "account", Origin: "https://example.test"}}
194
+ if _, err := session.Resolve(References{}); err == nil {
195
+ t.Fatal("identity binding accepted without an identifiable account")
196
+ }
197
+ }
198
+
199
+ func TestCommandSessionFreezesScopeAndChecksIdentity(t *testing.T) {
200
+ s := testStore(t)
201
+ if err := s.SaveDefault(Selection{TeamID: "12", Name: "A"}); err != nil {
202
+ t.Fatal(err)
203
+ }
204
+ account := s.Account
205
+ var warning bytes.Buffer
206
+ session := &Session{Store: s, Stderr: &warning, Identity: func(context.Context) (string, error) { return account, nil }}
207
+ ctx := context.Background()
208
+ if _, err := session.Resolve(References{}); err != nil {
209
+ t.Fatal(err)
210
+ }
211
+ token := session.Token()
212
+ if err := s.SaveDefault(Selection{TeamID: "34", Name: "B"}); err != nil {
213
+ t.Fatal(err)
214
+ }
215
+ got, err := session.Resolve(References{})
216
+ if err != nil || got.TeamID != "12" {
217
+ t.Fatalf("session changed = %+v, %v", got, err)
218
+ }
219
+ binding, err := DecodeBinding(token)
220
+ if err != nil {
221
+ t.Fatal(err)
222
+ }
223
+ next := &Session{Store: s, Expected: binding}
224
+ got, err = next.Resolve(References{})
225
+ if err != nil || got.TeamID != "12" || next.Token() != token {
226
+ t.Fatalf("cross-process binding = %+v, %v", got, err)
227
+ }
228
+ account = "account-b"
229
+ if err := session.CheckIdentity(ctx); err == nil {
230
+ t.Fatal("account switch accepted")
231
+ }
232
+ next.Store = &Store{Dir: s.Dir, Account: account, Origin: s.Origin}
233
+ if _, err := next.Resolve(References{}); err == nil {
234
+ t.Fatal("binding allowed different account")
235
+ }
236
+ // A successful request stays successful even if the record directory fails.
237
+ blocked := filepath.Join(t.TempDir(), "file")
238
+ if err := os.WriteFile(blocked, []byte("file"), 0600); err != nil {
239
+ t.Fatal(err)
240
+ }
241
+ s.Dir = blocked
242
+ session.Remember(References{ThreadID: "created-thread", RunID: "created-run"})
243
+ if !strings.Contains(warning.String(), "created-run") || !strings.Contains(warning.String(), "不要重复提交") {
244
+ t.Fatal(warning.String())
245
+ }
246
+ }
@@ -4,6 +4,7 @@ import (
4
4
  "encoding/json"
5
5
  "os"
6
6
  "path/filepath"
7
+ "runtime/debug"
7
8
  "strings"
8
9
  )
9
10
 
@@ -17,6 +18,18 @@ type packageJSON struct {
17
18
  Version string `json:"version"`
18
19
  }
19
20
 
21
+ // BuildVersion identifies the running binary. A local go run/build must not
22
+ // advertise package.json's release version as a verified binary version.
23
+ func BuildVersion() string {
24
+ if value := strings.TrimSpace(Version); value != "" && value != "dev" {
25
+ return value
26
+ }
27
+ if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
28
+ return strings.TrimPrefix(info.Main.Version, "v")
29
+ }
30
+ return "dev"
31
+ }
32
+
20
33
  func Current() string {
21
34
  if strings.TrimSpace(Version) != "" && Version != "dev" {
22
35
  return strings.TrimSpace(Version)
@@ -0,0 +1,38 @@
1
+ package version
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+ )
8
+
9
+ func TestBuildVersionNeverInfersAReleaseFromWorkingDirectory(t *testing.T) {
10
+ previous := Version
11
+ t.Cleanup(func() { Version = previous })
12
+ dir := t.TempDir()
13
+ if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"@pippit-dev/cli","version":"99.0.0"}`), 0600); err != nil {
14
+ t.Fatal(err)
15
+ }
16
+ previousDir, err := os.Getwd()
17
+ if err != nil {
18
+ t.Fatal(err)
19
+ }
20
+ if err := os.Chdir(dir); err != nil {
21
+ t.Fatal(err)
22
+ }
23
+ t.Cleanup(func() {
24
+ if err := os.Chdir(previousDir); err != nil {
25
+ t.Error(err)
26
+ }
27
+ })
28
+ for _, value := range []string{"", "dev", " dev "} {
29
+ Version = value
30
+ if got := BuildVersion(); got != "dev" {
31
+ t.Fatalf("BuildVersion(%q) = %q", value, got)
32
+ }
33
+ }
34
+ Version = " 1.0.42-canary.1 "
35
+ if got := BuildVersion(); got != "1.0.42-canary.1" {
36
+ t.Fatalf("release BuildVersion = %q", got)
37
+ }
38
+ }
@@ -0,0 +1,147 @@
1
+ // Package versioncheck reports available CLI updates without changing command results.
2
+ package versioncheck
3
+
4
+ import (
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+ "os"
11
+ "path/filepath"
12
+ "time"
13
+ )
14
+
15
+ const (
16
+ latestURL = "https://registry.npmjs.org/@pippit-dev%2Fcli/latest"
17
+ cacheTTL = 24 * time.Hour
18
+ failureTTL = 5 * time.Minute
19
+ requestTimeout = time.Second
20
+ maxResponseBytes = 64 << 10
21
+ )
22
+
23
+ type cache struct {
24
+ Latest string `json:"latest"`
25
+ CheckedAt int64 `json:"checkedAt"`
26
+ }
27
+
28
+ type options struct {
29
+ cacheFile string
30
+ now func() time.Time
31
+ getenv func(string) string
32
+ client *http.Client
33
+ }
34
+
35
+ // Check writes notices only to stderr. Registry, cache and output failures never
36
+ // affect the business command, and no background work outlives this call.
37
+ func Check(ctx context.Context, stderr io.Writer, current, command string) {
38
+ home, err := os.UserHomeDir()
39
+ if err != nil {
40
+ return
41
+ }
42
+ check(ctx, stderr, current, command, options{
43
+ cacheFile: filepath.Join(home, ".pippit_tool_cli", "version-check.json"),
44
+ now: time.Now,
45
+ getenv: os.Getenv,
46
+ client: &http.Client{
47
+ Timeout: requestTimeout,
48
+ CheckRedirect: func(*http.Request, []*http.Request) error {
49
+ return http.ErrUseLastResponse
50
+ },
51
+ },
52
+ })
53
+ }
54
+
55
+ func check(ctx context.Context, stderr io.Writer, current, command string, opts options) {
56
+ if command == "" || command == "help" || command == "version" || command == "install" || command == "update" ||
57
+ opts.getenv("CI") != "" || opts.getenv("PIPPIT_CLI_DISABLE_UPDATE_CHECK") == "1" ||
58
+ opts.getenv("PIPPIT_CLI_UPDATE_CHECKED") == "1" {
59
+ return
60
+ }
61
+ if _, ok := parseVersion(current); !ok {
62
+ return
63
+ }
64
+ now := opts.now()
65
+ cached := readCache(opts.cacheFile)
66
+ ttl := cacheTTL
67
+ if cached.Latest == "" {
68
+ ttl = failureTTL
69
+ }
70
+ age := now.Sub(time.UnixMilli(cached.CheckedAt))
71
+ if cached.CheckedAt <= 0 || age < 0 || age >= ttl {
72
+ cached = cache{Latest: fetchLatest(ctx, opts.client), CheckedAt: now.UnixMilli()}
73
+ writeCache(opts.cacheFile, cached)
74
+ }
75
+ if !isNewer(cached.Latest, current) || stderr == nil {
76
+ return
77
+ }
78
+ _, _ = fmt.Fprintf(stderr, "[pippit-tool-cli] New version available: %s -> %s. Run: pippit-tool-cli update\n", current, cached.Latest)
79
+ if command == "login" {
80
+ _, _ = fmt.Fprintln(stderr, "[pippit-tool-cli] If browser authorization fails, update the CLI and run pippit-tool-cli login again.")
81
+ }
82
+ }
83
+
84
+ func fetchLatest(ctx context.Context, client *http.Client) string {
85
+ ctx, cancel := context.WithTimeout(ctx, requestTimeout)
86
+ defer cancel()
87
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, latestURL, nil)
88
+ if err != nil {
89
+ return ""
90
+ }
91
+ response, err := client.Do(request)
92
+ if err != nil {
93
+ return ""
94
+ }
95
+ defer response.Body.Close()
96
+ if response.StatusCode != http.StatusOK {
97
+ return ""
98
+ }
99
+ data, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes+1))
100
+ if err != nil || len(data) > maxResponseBytes {
101
+ return ""
102
+ }
103
+ var pkg struct {
104
+ Version string `json:"version"`
105
+ }
106
+ if json.Unmarshal(data, &pkg) != nil {
107
+ return ""
108
+ }
109
+ if _, ok := parseVersion(pkg.Version); !ok {
110
+ return ""
111
+ }
112
+ return pkg.Version
113
+ }
114
+
115
+ func readCache(path string) cache {
116
+ file, err := os.Open(path)
117
+ if err != nil {
118
+ return cache{}
119
+ }
120
+ defer file.Close()
121
+ var value cache
122
+ if json.NewDecoder(io.LimitReader(file, maxResponseBytes)).Decode(&value) != nil {
123
+ return cache{}
124
+ }
125
+ if value.Latest != "" {
126
+ if _, ok := parseVersion(value.Latest); !ok {
127
+ return cache{}
128
+ }
129
+ }
130
+ return value
131
+ }
132
+
133
+ func writeCache(path string, value cache) {
134
+ if os.MkdirAll(filepath.Dir(path), 0700) != nil {
135
+ return
136
+ }
137
+ file, err := os.CreateTemp(filepath.Dir(path), ".version-check-*")
138
+ if err != nil {
139
+ return
140
+ }
141
+ defer os.Remove(file.Name())
142
+ err = json.NewEncoder(file).Encode(value)
143
+ closeErr := file.Close()
144
+ if err == nil && closeErr == nil {
145
+ _ = os.Rename(file.Name(), path)
146
+ }
147
+ }
@@ -0,0 +1,204 @@
1
+ package versioncheck
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "io"
9
+ "net/http"
10
+ "os"
11
+ "path/filepath"
12
+ "strings"
13
+ "testing"
14
+ "time"
15
+ )
16
+
17
+ type roundTripFunc func(*http.Request) (*http.Response, error)
18
+
19
+ func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
20
+
21
+ func testOptions(t *testing.T, transport roundTripFunc) options {
22
+ t.Helper()
23
+ return options{
24
+ cacheFile: filepath.Join(t.TempDir(), "version-check.json"),
25
+ now: func() time.Time { return time.UnixMilli(1_800_000_000_000) },
26
+ getenv: func(string) string { return "" },
27
+ client: &http.Client{Timeout: requestTimeout, Transport: transport},
28
+ }
29
+ }
30
+
31
+ func response(body string) *http.Response {
32
+ return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}
33
+ }
34
+
35
+ func TestCheckUsesSharedCacheAndWarnsForEveryInvocation(t *testing.T) {
36
+ fetches := 0
37
+ opts := testOptions(t, func(request *http.Request) (*http.Response, error) {
38
+ fetches++
39
+ if request.Method != "GET" || request.URL.String() != latestURL || request.Header.Get("Authorization") != "" {
40
+ t.Fatalf("unexpected registry request: %s %s", request.Method, request.URL)
41
+ }
42
+ if deadline, ok := request.Context().Deadline(); !ok || time.Until(deadline) > requestTimeout {
43
+ t.Fatal("registry request has no bounded context deadline")
44
+ }
45
+ return response(`{"version":"1.0.42","unrelated_metadata":true}`), nil
46
+ })
47
+ for index := range 2 {
48
+ var stderr bytes.Buffer
49
+ check(context.Background(), &stderr, "1.0.41", "login", opts)
50
+ if strings.Count(stderr.String(), "New version available: 1.0.41 -> 1.0.42") != 1 ||
51
+ !strings.Contains(stderr.String(), "pippit-tool-cli update") || !strings.Contains(stderr.String(), "authorization fails") {
52
+ t.Fatalf("invocation %d notice = %q", index, stderr.String())
53
+ }
54
+ }
55
+ if fetches != 1 {
56
+ t.Fatalf("fetches = %d, want one daily lookup", fetches)
57
+ }
58
+ data, err := os.ReadFile(opts.cacheFile)
59
+ if err != nil {
60
+ t.Fatal(err)
61
+ }
62
+ var shared map[string]any
63
+ if json.Unmarshal(data, &shared) != nil || shared["latest"] != "1.0.42" || shared["checkedAt"] != float64(opts.now().UnixMilli()) {
64
+ t.Fatalf("cache does not match the npm wrapper contract: %s", data)
65
+ }
66
+ if info, err := os.Stat(opts.cacheFile); err != nil || info.Mode().Perm() != 0600 {
67
+ t.Fatalf("cache permissions are not 0600: %v", err)
68
+ }
69
+ for _, current := range []string{"1.0.42", "1.0.43"} {
70
+ var stderr bytes.Buffer
71
+ check(context.Background(), &stderr, current, "status", opts)
72
+ if stderr.Len() != 0 {
73
+ t.Fatalf("up-to-date binary received a notice: %q", stderr.String())
74
+ }
75
+ }
76
+ oldNow := opts.now()
77
+ opts.now = func() time.Time { return oldNow.Add(cacheTTL) }
78
+ check(context.Background(), io.Discard, "1.0.41", "status", opts)
79
+ if fetches != 2 {
80
+ t.Fatalf("expired daily cache fetches = %d", fetches)
81
+ }
82
+ }
83
+
84
+ func TestCheckSkipsDisabledAndNonBusinessInvocations(t *testing.T) {
85
+ for _, tc := range []struct {
86
+ name, current, command, env string
87
+ }{
88
+ {"dev", "dev", "status", ""}, {"invalid", "1.0", "status", ""},
89
+ {"empty command", "1.0.41", "", ""}, {"help", "1.0.41", "help", ""},
90
+ {"version", "1.0.41", "version", ""}, {"install", "1.0.41", "install", ""},
91
+ {"update", "1.0.41", "update", ""}, {"CI", "1.0.41", "status", "CI"},
92
+ {"disabled", "1.0.41", "status", "PIPPIT_CLI_DISABLE_UPDATE_CHECK"},
93
+ {"wrapper checked", "1.0.41", "status", "PIPPIT_CLI_UPDATE_CHECKED"},
94
+ } {
95
+ t.Run(tc.name, func(t *testing.T) {
96
+ opts := testOptions(t, func(*http.Request) (*http.Response, error) {
97
+ t.Fatal("skipped invocation queried registry")
98
+ return nil, errors.New("unexpected network")
99
+ })
100
+ opts.getenv = func(key string) string {
101
+ if key == tc.env {
102
+ return "1"
103
+ }
104
+ return ""
105
+ }
106
+ var stderr bytes.Buffer
107
+ check(context.Background(), &stderr, tc.current, tc.command, opts)
108
+ if _, err := os.Stat(opts.cacheFile); !os.IsNotExist(err) || stderr.Len() != 0 {
109
+ t.Fatal("skipped invocation wrote cache or output")
110
+ }
111
+ })
112
+ }
113
+ }
114
+
115
+ func TestFailedChecksStaySilentAndShareFiveMinuteCooldown(t *testing.T) {
116
+ for _, failure := range []string{"network", "http", "json", "version", "oversize"} {
117
+ t.Run(failure, func(t *testing.T) {
118
+ fetches := 0
119
+ opts := testOptions(t, func(*http.Request) (*http.Response, error) {
120
+ fetches++
121
+ switch failure {
122
+ case "network":
123
+ return nil, errors.New("offline")
124
+ case "http":
125
+ result := response(`{}`)
126
+ result.StatusCode = http.StatusServiceUnavailable
127
+ return result, nil
128
+ case "json":
129
+ return response(`not json`), nil
130
+ case "version":
131
+ return response(`{"version":"dev"}`), nil
132
+ default:
133
+ return response(strings.Repeat(" ", maxResponseBytes+1)), nil
134
+ }
135
+ })
136
+ var stderr bytes.Buffer
137
+ check(context.Background(), &stderr, "1.0.41", "status", opts)
138
+ cached := readCache(opts.cacheFile)
139
+ if cached.Latest != "" || cached.CheckedAt != opts.now().UnixMilli() {
140
+ t.Fatal("failed lookup did not write the shared failure cooldown")
141
+ }
142
+ initial := opts.now()
143
+ opts.now = func() time.Time { return initial.Add(failureTTL - time.Millisecond) }
144
+ check(context.Background(), &stderr, "1.0.41", "status", opts)
145
+ if fetches != 1 || stderr.Len() != 0 {
146
+ t.Fatal("failed lookup retried during cooldown or emitted output")
147
+ }
148
+ opts.now = func() time.Time { return initial.Add(failureTTL) }
149
+ check(context.Background(), &stderr, "1.0.41", "status", opts)
150
+ if fetches != 2 || stderr.Len() != 0 {
151
+ t.Fatal("expired failure cooldown did not retry silently")
152
+ }
153
+ })
154
+ }
155
+ }
156
+
157
+ func TestRegistryLookupHasOneSecondTotalDeadline(t *testing.T) {
158
+ opts := testOptions(t, func(request *http.Request) (*http.Response, error) {
159
+ <-request.Context().Done()
160
+ return nil, request.Context().Err()
161
+ })
162
+ start := time.Now()
163
+ var stderr bytes.Buffer
164
+ check(context.Background(), &stderr, "1.0.41", "status", opts)
165
+ if elapsed := time.Since(start); elapsed > 2*time.Second || stderr.Len() != 0 {
166
+ t.Fatalf("deadline exceeded budget or emitted output: %v / %q", elapsed, stderr.String())
167
+ }
168
+ }
169
+
170
+ func TestRegistryOrCacheFailureCannotSuppressKnownNotice(t *testing.T) {
171
+ opts := testOptions(t, func(*http.Request) (*http.Response, error) {
172
+ return response(`{"version":"1.0.42"}`), nil
173
+ })
174
+ // A regular file in place of the cache directory makes cache persistence fail.
175
+ blocker := filepath.Join(t.TempDir(), "file")
176
+ if err := os.WriteFile(blocker, nil, 0600); err != nil {
177
+ t.Fatal(err)
178
+ }
179
+ opts.cacheFile = filepath.Join(blocker, "version-check.json")
180
+ var stderr bytes.Buffer
181
+ check(context.Background(), &stderr, "1.0.41", "status", opts)
182
+ if !strings.Contains(stderr.String(), "New version available") {
183
+ t.Fatal("cache write failure suppressed a valid update notice")
184
+ }
185
+ }
186
+
187
+ func TestSemanticVersionOrderingRejectsInvalidAndDevelopmentVersions(t *testing.T) {
188
+ for _, tc := range []struct {
189
+ latest, current string
190
+ newer bool
191
+ }{
192
+ {"1.0.42", "1.0.41", true}, {"1.1.0", "1.0.99", true}, {"2.0.0", "1.99.99", true},
193
+ {"1.0.41", "1.0.42", false}, {"1.0.42", "1.0.42+build.1", false},
194
+ {"1.0.42", "1.0.42-canary.1", true}, {"1.0.42-canary.2", "1.0.42-canary.1", true},
195
+ {"1.0.42-canary.10", "1.0.42-canary.2", true}, {"1.0.42-beta", "1.0.42-alpha.1", true},
196
+ {"1.0.42-alpha.1", "1.0.42-alpha", true}, {"v1.0.42", "v1.0.41", true},
197
+ {"1.0.42", "dev", false}, {"bad", "1.0.41", false}, {"1.0.42", "1.0", false},
198
+ {"1.0.42", "1.00.41", false}, {"1.0.42", "1.0.41-01", false}, {"1.0.42", "1.0.41 junk", false},
199
+ } {
200
+ if got := isNewer(tc.latest, tc.current); got != tc.newer {
201
+ t.Errorf("isNewer(%q, %q) = %v, want %v", tc.latest, tc.current, got, tc.newer)
202
+ }
203
+ }
204
+ }