@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.
- package/README.md +42 -11
- package/checksums.txt +6 -6
- package/cmd/auth/auth.go +19 -5
- package/cmd/auth/auth_test.go +23 -0
- package/cmd/canvas/canvas.go +7 -7
- package/cmd/download_result.go +3 -2
- package/cmd/marketing.go +19 -3
- package/cmd/marketing_test.go +11 -5
- package/cmd/model.go +5 -4
- package/cmd/root.go +17 -5
- package/cmd/root_update_check_test.go +139 -0
- package/cmd/short_drama_test.go +2 -0
- package/cmd/source_test.go +27 -4
- package/cmd/space.go +199 -0
- package/cmd/space_test.go +469 -0
- package/cmd/upload_file_test.go +3 -1
- package/dist/checksums.txt +6 -6
- package/internal/adaptercli/commands_test.go +9 -5
- package/internal/auth/loopback.go +16 -11
- package/internal/auth/loopback_origins_test.go +152 -0
- package/internal/auth/space.go +26 -0
- package/internal/auth/space_test.go +42 -0
- package/internal/canvas/get.go +9 -2
- package/internal/commandnames/names.go +9 -4
- package/internal/common/client.go +23 -0
- package/internal/common/host_source.go +5 -0
- package/internal/common/host_source_test.go +9 -1
- package/internal/common/runner.go +5 -3
- package/internal/common/space.go +185 -0
- package/internal/common/space_test.go +132 -0
- package/internal/common/submit_run.go +2 -0
- package/internal/common/upload_file.go +2 -0
- package/internal/config/config.go +15 -12
- package/internal/generate_image/generate_image.go +3 -9
- package/internal/short_drama/submit_run.go +3 -9
- package/internal/space/session.go +153 -0
- package/internal/space/store.go +211 -0
- package/internal/space/store_test.go +246 -0
- package/internal/version/version.go +13 -0
- package/internal/version/version_test.go +38 -0
- package/internal/versioncheck/check.go +147 -0
- package/internal/versioncheck/check_test.go +204 -0
- package/internal/versioncheck/semver.go +84 -0
- package/package.json +1 -1
- package/scripts/canvas-command.js +47 -5
- package/scripts/install-wizard.js +1 -1
- package/scripts/run.js +15 -4
- package/scripts/telemetry.js +3 -1
- package/scripts/version-check.js +8 -1
- package/skills/short-drama/SKILL.md +12 -1
- package/skills/xyq-marketing-skill/SKILL.md +22 -3
- package/skills/xyq-marketing-skill/references/api.md +7 -1
- package/skills/xyq-marketing-skill/scripts/marketing.js +51 -19
- package/skills/xyq-nest-skill/SKILL.md +15 -4
- package/skills/xyq-nest-skill/commands/canvas-assets.md +1 -1
- package/skills/xyq-nest-skill/commands/canvas.md +5 -3
- package/skills/xyq-nest-skill/commands/get-credit-balance.md +2 -2
- package/skills/xyq-nest-skill/commands/model.md +1 -1
- package/skills/xyq-nest-skill/scripts/ensure-cli.js +8 -1
- package/skills/xyq-nest-skill/scripts/install.md +1 -1
- 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
|
+
}
|