@pippit-dev/cli 1.0.41 → 1.0.42

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/checksums.txt CHANGED
@@ -1,6 +1,6 @@
1
- 6f7daba79b3423b148e30373bb87e7e8998df3d76be2196e39f84af209f0ea50 pippit-tool-cli-1.0.41-darwin-amd64.tar.gz
2
- 9f03ae01c213a76f45b0dfa110272b45e5b5ecd54b188313c9d256ccbe512980 pippit-tool-cli-1.0.41-darwin-arm64.tar.gz
3
- ea603412d1c5b0baa42efd34bec92fcd0907333babd64a6e08514d4e2dcb86e5 pippit-tool-cli-1.0.41-linux-amd64.tar.gz
4
- cebd1f0f34157624c0fc8203eec306bd6c7e31a2db44089f52bca9c74c57ee03 pippit-tool-cli-1.0.41-linux-arm64.tar.gz
5
- 729f413373d185964a42c178a7e0eeafc3fca490670fb753dc36fb38bb47aaf7 pippit-tool-cli-1.0.41-windows-amd64.zip
6
- b49491ae751e72f994da9388777a43f1b6b5781399dc2da15ced6d74f48dad6d pippit-tool-cli-1.0.41-windows-arm64.zip
1
+ a27b2392e4a291329599d6ee02dd704fde6eb2bf34f3390b30a973bce0009259 pippit-tool-cli-1.0.42-darwin-amd64.tar.gz
2
+ 921fa4fff779f0c7fca7a6176d0aa47c23fe3debd78df29d3bca50ae1eff0e54 pippit-tool-cli-1.0.42-darwin-arm64.tar.gz
3
+ f786c2954c1d537638ef248c420bbf8d9fe8040f6ee6ce7a5467c6fe73689747 pippit-tool-cli-1.0.42-linux-amd64.tar.gz
4
+ a6adb356975c1724e553ec8fdc9aa602a259a081171f41c84680946c1459cea9 pippit-tool-cli-1.0.42-linux-arm64.tar.gz
5
+ 9d1385bec216144594b83c42c244ebd9cc6eafe7758f54a1dc636f7ee231aea7 pippit-tool-cli-1.0.42-windows-amd64.zip
6
+ 008d4f238b2fd66db3e4ede1425ec6919853889359dd60b379939b7f73452154 pippit-tool-cli-1.0.42-windows-arm64.zip
package/cmd/root.go CHANGED
@@ -20,6 +20,7 @@ import (
20
20
  "github.com/Pippit-dev/pippit-cli/internal/common"
21
21
  "github.com/Pippit-dev/pippit-cli/internal/config"
22
22
  "github.com/Pippit-dev/pippit-cli/internal/version"
23
+ "github.com/Pippit-dev/pippit-cli/internal/versioncheck"
23
24
  "github.com/spf13/cobra"
24
25
  )
25
26
 
@@ -89,6 +90,9 @@ func newRootCommandWithAdapter(stdout, stderr io.Writer, runner *common.Runner,
89
90
  // Only commands already exposing --source receive attribution. Explicit empty
90
91
  // flags suppress fallback, and no source is persisted between CLI processes.
91
92
  root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error {
93
+ if policy.CLIOptions().SelfUpdateEnabled && cmd.Runnable() {
94
+ versioncheck.Check(cmd.Context(), stderr, version.BuildVersion(), cmd.Name())
95
+ }
92
96
  flag := cmd.Flags().Lookup("source")
93
97
  if flag != nil && !flag.Changed {
94
98
  if source := common.DetectHostSource(); source != "" {
@@ -0,0 +1,139 @@
1
+ package cmd
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/Pippit-dev/pippit-cli/internal/adapter"
14
+ "github.com/Pippit-dev/pippit-cli/internal/version"
15
+ "github.com/spf13/cobra"
16
+ )
17
+
18
+ type noSelfUpdateAdapter struct{ adapter.Default }
19
+
20
+ func (noSelfUpdateAdapter) CLIOptions() adapter.CLIOptions {
21
+ options := adapter.Default{}.CLIOptions()
22
+ options.SelfUpdateEnabled = false
23
+ return options
24
+ }
25
+
26
+ func setupRootUpdateCache(t *testing.T) string {
27
+ t.Helper()
28
+ home := t.TempDir()
29
+ t.Setenv("HOME", home)
30
+ t.Setenv("USERPROFILE", home)
31
+ for _, key := range []string{"CI", "PIPPIT_CLI_DISABLE_UPDATE_CHECK", "PIPPIT_CLI_UPDATE_CHECKED"} {
32
+ t.Setenv(key, "")
33
+ }
34
+ previous := version.Version
35
+ version.Version = "1.0.41"
36
+ t.Cleanup(func() { version.Version = previous })
37
+ cacheFile := filepath.Join(home, ".pippit_tool_cli", "version-check.json")
38
+ if err := os.MkdirAll(filepath.Dir(cacheFile), 0700); err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ data := fmt.Sprintf(`{"latest":"1.0.42","checkedAt":%d}`, time.Now().UnixMilli())
42
+ if err := os.WriteFile(cacheFile, []byte(data), 0600); err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ return cacheFile
46
+ }
47
+
48
+ func TestNativeBusinessInvocationReportsUpdateBeforeUnchangedJSON(t *testing.T) {
49
+ setupRootUpdateCache(t)
50
+ for _, commandName := range []string{"probe", "login"} {
51
+ for range 2 {
52
+ var stdout, stderr bytes.Buffer
53
+ root := NewRootCommand(&stdout, &stderr)
54
+ probe := &cobra.Command{Use: "probe"}
55
+ if commandName == "login" {
56
+ probe, _, _ = root.Find([]string{"login"})
57
+ } else {
58
+ root.AddCommand(probe)
59
+ }
60
+ probe.RunE = func(*cobra.Command, []string) error {
61
+ if !strings.Contains(stderr.String(), "1.0.41 -> 1.0.42") {
62
+ t.Fatal("business operation started before the synchronous notice")
63
+ }
64
+ _, err := fmt.Fprintln(&stdout, `{"ok":true}`)
65
+ return err
66
+ }
67
+ root.SetArgs([]string{commandName})
68
+ if err := root.Execute(); err != nil {
69
+ t.Fatal(err)
70
+ }
71
+ if !json.Valid(stdout.Bytes()) || stdout.String() != "{\"ok\":true}\n" ||
72
+ strings.Count(stderr.String(), "New version available") != 1 {
73
+ t.Fatalf("stdout/stderr contract changed: %q / %q", stdout.String(), stderr.String())
74
+ }
75
+ if (commandName == "login") != strings.Contains(stderr.String(), "authorization fails") {
76
+ t.Fatal("login-specific notice was omitted or leaked into another command")
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ func TestNativeUpdateCheckRespectsAdapterAndInvocationBoundaries(t *testing.T) {
83
+ setupRootUpdateCache(t)
84
+ for _, scenario := range []string{"help", "version", "empty", "invalid", "invalid flag", "update", "adapter", "dev", "CI", "PIPPIT_CLI_DISABLE_UPDATE_CHECK", "PIPPIT_CLI_UPDATE_CHECKED"} {
85
+ t.Run(scenario, func(t *testing.T) {
86
+ policy := adapter.Adapter(adapter.Default{})
87
+ args := []string{"probe"}
88
+ switch scenario {
89
+ case "help":
90
+ args = []string{"probe", "--help"}
91
+ case "version":
92
+ args = []string{"--version"}
93
+ case "empty":
94
+ args = []string{}
95
+ case "invalid":
96
+ args = []string{"unknown-command"}
97
+ case "invalid flag":
98
+ args = []string{"probe", "--unknown"}
99
+ case "update":
100
+ args = []string{"update"}
101
+ case "adapter":
102
+ policy = noSelfUpdateAdapter{}
103
+ case "dev":
104
+ previous := version.Version
105
+ version.Version = "dev"
106
+ t.Cleanup(func() { version.Version = previous })
107
+ default:
108
+ t.Setenv(scenario, "1")
109
+ }
110
+ var stdout, stderr bytes.Buffer
111
+ root := NewRootCommandWithAdapter(&stdout, &stderr, policy)
112
+ root.AddCommand(&cobra.Command{Use: "probe", Run: func(*cobra.Command, []string) {}})
113
+ if scenario == "update" {
114
+ update, _, _ := root.Find([]string{"update"})
115
+ update.RunE = func(*cobra.Command, []string) error { return nil }
116
+ }
117
+ root.SetArgs(args)
118
+ err := root.Execute()
119
+ if (scenario == "invalid" || scenario == "invalid flag") != (err != nil) {
120
+ t.Fatalf("unexpected command result: %v", err)
121
+ }
122
+ if strings.Contains(stderr.String(), "New version available") {
123
+ t.Fatalf("skipped invocation emitted an update notice: %s", stderr.String())
124
+ }
125
+ })
126
+ }
127
+ }
128
+
129
+ func TestConstructingRootHasNoVersionCheckSideEffects(t *testing.T) {
130
+ cacheFile := setupRootUpdateCache(t)
131
+ if err := os.Remove(cacheFile); err != nil {
132
+ t.Fatal(err)
133
+ }
134
+ var stdout, stderr bytes.Buffer
135
+ _ = NewRootCommand(&stdout, &stderr)
136
+ if _, err := os.Stat(cacheFile); !os.IsNotExist(err) || stdout.Len() != 0 || stderr.Len() != 0 {
137
+ t.Fatal("constructing the root queried versions, wrote cache or produced output")
138
+ }
139
+ }
@@ -1,6 +1,6 @@
1
- 6f7daba79b3423b148e30373bb87e7e8998df3d76be2196e39f84af209f0ea50 pippit-tool-cli-1.0.41-darwin-amd64.tar.gz
2
- 9f03ae01c213a76f45b0dfa110272b45e5b5ecd54b188313c9d256ccbe512980 pippit-tool-cli-1.0.41-darwin-arm64.tar.gz
3
- ea603412d1c5b0baa42efd34bec92fcd0907333babd64a6e08514d4e2dcb86e5 pippit-tool-cli-1.0.41-linux-amd64.tar.gz
4
- cebd1f0f34157624c0fc8203eec306bd6c7e31a2db44089f52bca9c74c57ee03 pippit-tool-cli-1.0.41-linux-arm64.tar.gz
5
- 729f413373d185964a42c178a7e0eeafc3fca490670fb753dc36fb38bb47aaf7 pippit-tool-cli-1.0.41-windows-amd64.zip
6
- b49491ae751e72f994da9388777a43f1b6b5781399dc2da15ced6d74f48dad6d pippit-tool-cli-1.0.41-windows-arm64.zip
1
+ a27b2392e4a291329599d6ee02dd704fde6eb2bf34f3390b30a973bce0009259 pippit-tool-cli-1.0.42-darwin-amd64.tar.gz
2
+ 921fa4fff779f0c7fca7a6176d0aa47c23fe3debd78df29d3bca50ae1eff0e54 pippit-tool-cli-1.0.42-darwin-arm64.tar.gz
3
+ f786c2954c1d537638ef248c420bbf8d9fe8040f6ee6ce7a5467c6fe73689747 pippit-tool-cli-1.0.42-linux-amd64.tar.gz
4
+ a6adb356975c1724e553ec8fdc9aa602a259a081171f41c84680946c1459cea9 pippit-tool-cli-1.0.42-linux-arm64.tar.gz
5
+ 9d1385bec216144594b83c42c244ebd9cc6eafe7758f54a1dc636f7ee231aea7 pippit-tool-cli-1.0.42-windows-amd64.zip
6
+ 008d4f238b2fd66db3e4ede1425ec6919853889359dd60b379939b7f73452154 pippit-tool-cli-1.0.42-windows-arm64.zip
@@ -5,7 +5,6 @@ import (
5
5
  "encoding/base64"
6
6
  "encoding/json"
7
7
  "errors"
8
- "fmt"
9
8
  "io"
10
9
  "mime"
11
10
  "net"
@@ -14,6 +13,8 @@ import (
14
13
  "strings"
15
14
  "sync"
16
15
  "time"
16
+
17
+ "github.com/Pippit-dev/pippit-cli/internal/version"
17
18
  )
18
19
 
19
20
  const maxCallbackBodyBytes = 64 << 10
@@ -37,7 +38,6 @@ type browserFlow struct {
37
38
  secret string
38
39
  state string
39
40
  source string
40
- origin string
41
41
  listener net.Listener
42
42
  server *http.Server
43
43
  payload chan accessKeyPayload
@@ -93,6 +93,9 @@ func startBrowserFlow(authBaseURL *url.URL, randomReader io.Reader, deviceID, to
93
93
  query.Set("callback", callback.String())
94
94
  query.Set("random_secret_key", secret)
95
95
  query.Set("source", loginSource)
96
+ query.Set("cli_version", version.BuildVersion())
97
+ // Protocol 1 supports callbacks from both official login origins.
98
+ query.Set("auth_protocol", "1")
96
99
  query.Set("device_id", deviceID)
97
100
  if tokenID != "" {
98
101
  query.Set("token_id", tokenID)
@@ -111,7 +114,6 @@ func startBrowserFlow(authBaseURL *url.URL, randomReader io.Reader, deviceID, to
111
114
  secret: secret,
112
115
  state: state,
113
116
  source: loginSource,
114
- origin: originOf(authBaseURL),
115
117
  listener: listener,
116
118
  payload: make(chan accessKeyPayload, 1),
117
119
  serveErr: make(chan error, 1),
@@ -166,11 +168,12 @@ func (f *browserFlow) handleCallback(writer http.ResponseWriter, request *http.R
166
168
  http.Error(writer, "invalid callback target", http.StatusBadRequest)
167
169
  return
168
170
  }
169
- if !constantTimeEqual(request.Header.Get("Origin"), f.origin) {
171
+ origin := request.Header.Get("Origin")
172
+ if !isAllowedAuthOrigin(origin) {
170
173
  http.Error(writer, "origin not allowed", http.StatusForbidden)
171
174
  return
172
175
  }
173
- f.setCORSHeaders(writer.Header())
176
+ f.setCORSHeaders(writer.Header(), origin)
174
177
 
175
178
  if request.Method == http.MethodOptions {
176
179
  if !strings.EqualFold(strings.TrimSpace(request.Header.Get("Access-Control-Request-Method")), http.MethodPost) ||
@@ -288,8 +291,14 @@ func (f *browserFlow) callbackURLHost() string {
288
291
  return parsed.Host
289
292
  }
290
293
 
291
- func (f *browserFlow) setCORSHeaders(header http.Header) {
292
- header.Set("Access-Control-Allow-Origin", f.origin)
294
+ // Authentication trusts only these official HTTPS origins, independently of
295
+ // runtime business API configuration or browser-supplied query parameters.
296
+ func isAllowedAuthOrigin(origin string) bool {
297
+ return origin == "https://xyq.jianying.com" || origin == "https://www.xiaoyunque.com"
298
+ }
299
+
300
+ func (f *browserFlow) setCORSHeaders(header http.Header, origin string) {
301
+ header.Set("Access-Control-Allow-Origin", origin)
293
302
  header.Set("Access-Control-Allow-Methods", "POST")
294
303
  header.Set("Access-Control-Allow-Headers", "Content-Type")
295
304
  header.Set("Access-Control-Allow-Private-Network", "true")
@@ -318,7 +327,3 @@ func ensureJSONEOF(decoder *json.Decoder) error {
318
327
  }
319
328
  return err
320
329
  }
321
-
322
- func originOf(value *url.URL) string {
323
- return fmt.Sprintf("%s://%s", strings.ToLower(value.Scheme), value.Host)
324
- }
@@ -0,0 +1,152 @@
1
+ package auth
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "net/url"
9
+ "strings"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/Pippit-dev/pippit-cli/internal/config"
14
+ "github.com/Pippit-dev/pippit-cli/internal/version"
15
+ )
16
+
17
+ func newOriginTestFlow(t *testing.T) (*browserFlow, accessKeyPayload) {
18
+ t.Helper()
19
+ authURL, _ := url.Parse(config.DefaultBaseURL)
20
+ deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x21}, deviceIDBytes)), deviceIDBytes)
21
+ flow, err := startBrowserFlow(authURL, bytes.NewReader(bytes.Repeat([]byte{0x42}, 2*randomBindingBytes)), deviceID, "", "", false)
22
+ if err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ t.Cleanup(flow.close)
26
+ return flow, accessKeyPayload{
27
+ Type: "access_key", AccessKey: "local-test-ak", UID: "test-user", TokenID: "test-token",
28
+ ExpiredAt: time.Now().Add(time.Hour).Unix(), RandomSecretKey: flow.secret,
29
+ Source: loginSource, CallbackURL: flow.callbackURL,
30
+ }
31
+ }
32
+
33
+ func originTestRequest(t *testing.T, flow *browserFlow, method, origin string, payload accessKeyPayload, mutations ...func(*http.Request)) *httptest.ResponseRecorder {
34
+ t.Helper()
35
+ body, err := json.Marshal(payload)
36
+ if err != nil {
37
+ t.Fatal(err)
38
+ }
39
+ request := httptest.NewRequest(method, flow.callbackURL, bytes.NewReader(body))
40
+ if origin != "" {
41
+ request.Header.Set("Origin", origin)
42
+ }
43
+ request.Header.Set("Content-Type", "application/json")
44
+ request.Header.Set("Access-Control-Request-Method", "POST")
45
+ request.Header.Set("Access-Control-Request-Headers", "Content-Type")
46
+ for _, mutate := range mutations {
47
+ mutate(request)
48
+ }
49
+ response := httptest.NewRecorder()
50
+ flow.handleCallback(response, request)
51
+ return response
52
+ }
53
+
54
+ func TestBrowserCallbackAcceptsBothOfficialOriginsWithMatchingCORS(t *testing.T) {
55
+ for _, origin := range []string{"https://xyq.jianying.com", "https://www.xiaoyunque.com"} {
56
+ t.Run(origin, func(t *testing.T) {
57
+ flow, payload := newOriginTestFlow(t)
58
+ for _, method := range []string{http.MethodOptions, http.MethodPost} {
59
+ response := originTestRequest(t, flow, method, origin, payload)
60
+ wantStatus := http.StatusOK
61
+ if method == http.MethodOptions {
62
+ wantStatus = http.StatusNoContent
63
+ }
64
+ if response.Code != wantStatus || response.Header().Get("Access-Control-Allow-Origin") != origin {
65
+ t.Fatalf("%s status/CORS = %d/%q", method, response.Code, response.Header().Get("Access-Control-Allow-Origin"))
66
+ }
67
+ if response.Header().Get("Access-Control-Allow-Private-Network") != "true" ||
68
+ !strings.Contains(strings.Join(response.Header().Values("Vary"), ","), "Origin") {
69
+ t.Fatal("callback lost private-network or Vary headers")
70
+ }
71
+ }
72
+ // Switching between the two trusted sites must not reset the one-shot binding.
73
+ otherOrigin := "https://xyq.jianying.com"
74
+ if origin == otherOrigin {
75
+ otherOrigin = "https://www.xiaoyunque.com"
76
+ }
77
+ if response := originTestRequest(t, flow, http.MethodPost, otherOrigin, payload); response.Code != http.StatusConflict {
78
+ t.Fatalf("cross-origin replay status = %d", response.Code)
79
+ }
80
+ })
81
+ }
82
+ }
83
+
84
+ func TestBrowserCallbackRejectsUntrustedOriginsBeforeCORS(t *testing.T) {
85
+ for _, origin := range []string{
86
+ "", "null", "http://xyq.jianying.com", "http://www.xiaoyunque.com",
87
+ "https://www.xiaoyunque.com:443", "https://www.xiaoyunque.com/",
88
+ "https://www.xiaoyunque.com.evil.example", "https://evil.example",
89
+ "https://other.xiaoyunque.com", "https://www.xiaoyunque.com https://xyq.jianying.com",
90
+ } {
91
+ t.Run(origin, func(t *testing.T) {
92
+ flow, payload := newOriginTestFlow(t)
93
+ for _, method := range []string{http.MethodOptions, http.MethodPost} {
94
+ response := originTestRequest(t, flow, method, origin, payload)
95
+ if response.Code != http.StatusForbidden || response.Header().Get("Access-Control-Allow-Origin") != "" {
96
+ t.Fatalf("%s untrusted origin status/CORS = %d/%q", method, response.Code, response.Header().Get("Access-Control-Allow-Origin"))
97
+ }
98
+ }
99
+ if len(flow.payload) != 0 {
100
+ t.Fatal("untrusted origin delivered a credential")
101
+ }
102
+ })
103
+ }
104
+ }
105
+
106
+ func TestNewAuthOriginStillRequiresEveryCallbackBinding(t *testing.T) {
107
+ for _, binding := range []string{"secret", "source", "callback_url", "state", "host"} {
108
+ t.Run(binding, func(t *testing.T) {
109
+ flow, payload := newOriginTestFlow(t)
110
+ switch binding {
111
+ case "secret":
112
+ payload.RandomSecretKey = "wrong"
113
+ case "source":
114
+ payload.Source = "wrong"
115
+ case "callback_url":
116
+ payload.CallbackURL += "&extra=1"
117
+ case "state":
118
+ flow.state = "wrong"
119
+ }
120
+ response := originTestRequest(t, flow, http.MethodPost, "https://www.xiaoyunque.com", payload, func(request *http.Request) {
121
+ if binding == "host" {
122
+ request.Host = "evil.example"
123
+ }
124
+ })
125
+ if response.Code != http.StatusBadRequest || len(flow.payload) != 0 {
126
+ t.Fatalf("invalid %s status = %d, queued callbacks = %d", binding, response.Code, len(flow.payload))
127
+ }
128
+ })
129
+ }
130
+ }
131
+
132
+ func TestBrowserLoginMetadataUsesBinaryVersionAndProtocol(t *testing.T) {
133
+ previous := version.Version
134
+ t.Cleanup(func() { version.Version = previous })
135
+ for _, buildVersion := range []string{"1.0.42", "1.0.42-canary.1", "dev", ""} {
136
+ t.Run(buildVersion, func(t *testing.T) {
137
+ version.Version = buildVersion
138
+ flow, _ := newOriginTestFlow(t)
139
+ loginURL, err := url.Parse(flow.loginURL)
140
+ if err != nil {
141
+ t.Fatal(err)
142
+ }
143
+ wantVersion := buildVersion
144
+ if wantVersion == "" {
145
+ wantVersion = "dev"
146
+ }
147
+ if query := loginURL.Query(); query.Get("cli_version") != wantVersion || query.Get("auth_protocol") != "1" {
148
+ t.Fatalf("auth metadata = version %q, protocol %q", query.Get("cli_version"), query.Get("auth_protocol"))
149
+ }
150
+ })
151
+ }
152
+ }
@@ -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
+ }
@@ -0,0 +1,84 @@
1
+ package versioncheck
2
+
3
+ import (
4
+ "regexp"
5
+ "strconv"
6
+ "strings"
7
+ )
8
+
9
+ var semverPattern = regexp.MustCompile(`^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`)
10
+
11
+ type semver struct {
12
+ core [3]uint64
13
+ pre []string
14
+ }
15
+
16
+ func parseVersion(value string) (semver, bool) {
17
+ var parsed semver
18
+ if len(value) > 256 {
19
+ return parsed, false
20
+ }
21
+ parts := semverPattern.FindStringSubmatch(value)
22
+ if parts == nil {
23
+ return parsed, false
24
+ }
25
+ for index := range parsed.core {
26
+ part, err := strconv.ParseUint(parts[index+1], 10, 64)
27
+ if err != nil {
28
+ return parsed, false
29
+ }
30
+ parsed.core[index] = part
31
+ }
32
+ if parts[4] != "" {
33
+ parsed.pre = strings.Split(parts[4], ".")
34
+ for _, identifier := range parsed.pre {
35
+ if numeric(identifier) && len(identifier) > 1 && identifier[0] == '0' {
36
+ return parsed, false
37
+ }
38
+ }
39
+ }
40
+ return parsed, true
41
+ }
42
+
43
+ func isNewer(latest, current string) bool {
44
+ a, validA := parseVersion(latest)
45
+ b, validB := parseVersion(current)
46
+ if !validA || !validB {
47
+ return false
48
+ }
49
+ for index, part := range a.core {
50
+ if part != b.core[index] {
51
+ return part > b.core[index]
52
+ }
53
+ }
54
+ if len(a.pre) == 0 || len(b.pre) == 0 {
55
+ return len(a.pre) == 0 && len(b.pre) != 0
56
+ }
57
+ for index, part := range a.pre {
58
+ if index >= len(b.pre) {
59
+ return true
60
+ }
61
+ other := b.pre[index]
62
+ if part == other {
63
+ continue
64
+ }
65
+ numericA, numericB := numeric(part), numeric(other)
66
+ if numericA != numericB {
67
+ return !numericA
68
+ }
69
+ if numericA && len(part) != len(other) {
70
+ return len(part) > len(other)
71
+ }
72
+ return part > other
73
+ }
74
+ return false
75
+ }
76
+
77
+ func numeric(value string) bool {
78
+ for _, character := range value {
79
+ if character < '0' || character > '9' {
80
+ return false
81
+ }
82
+ }
83
+ return value != ""
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pippit-dev/cli",
3
- "version": "1.0.41",
3
+ "version": "1.0.42",
4
4
  "description": "Pippit CLI",
5
5
  "bin": {
6
6
  "pippit-tool-cli": "scripts/run.js"
@@ -17,6 +17,8 @@ const DEFAULT_SDK_MODULE = path.join(
17
17
  "xyq-canvas-command-runtime.cjs"
18
18
  );
19
19
  const MAX_INPUT_BYTES = 64 * 1024 * 1024;
20
+ const UPGRADE_NOTICE_PREFIX = "[pippit-tool-cli] New version available:";
21
+ const LOGIN_UPGRADE_NOTICE = "[pippit-tool-cli] If browser authorization fails, update the CLI and run pippit-tool-cli login again.";
20
22
  const PERSISTENCE_VERSION = 1;
21
23
  const OPTIONAL_SCHEMA = Symbol("optional canvas command schema");
22
24
  const CHECKPOINT_COMMANDS = new Set([
@@ -144,6 +146,7 @@ function spawnProcess(invocation, args, options = {}) {
144
146
  }
145
147
  const child = spawn(invocation.command, [...(invocation.prefixArgs || []), ...args], {
146
148
  cwd: options.cwd,
149
+ env: invocation.env || process.env,
147
150
  shell: false,
148
151
  stdio: options.interactive ? "inherit" : ["pipe", "pipe", "pipe"],
149
152
  windowsHide: true,
@@ -201,11 +204,17 @@ class NativeCanvasClient {
201
204
  input: input === undefined ? undefined : JSON.stringify(input),
202
205
  });
203
206
  if (result.exitCode !== 0) throw new NativeCommandError(args, result);
207
+ let output;
204
208
  try {
205
- return JSON.parse(result.stdout);
209
+ output = JSON.parse(result.stdout);
206
210
  } catch (error) {
207
211
  throw new Error(`原生 CLI 未返回有效 JSON(${args.join(" ")}):${error.message}`);
208
212
  }
213
+ const notices = result.stderr.split(/\r?\n/).filter((line) =>
214
+ line.startsWith(UPGRADE_NOTICE_PREFIX) || line === LOGIN_UPGRADE_NOTICE
215
+ );
216
+ if (notices.length) this.stderr.write(`${notices.join("\n")}\n`);
217
+ return output;
209
218
  }
210
219
 
211
220
  async ensureAuthenticated() {
package/scripts/run.js CHANGED
@@ -3,12 +3,14 @@
3
3
  const { execFileSync } = require('child_process');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
- const { isCanvasCommand, runCanvasCommand } = require('./canvas-command');
6
+ const { isCanvasCommand, parseCanvasCommandArgs, runCanvasCommand } = require('./canvas-command');
7
7
  const { maybeWarnNewVersion } = require('./version-check');
8
8
 
9
9
  const ext = process.platform === 'win32' ? '.exe' : '';
10
10
  const bin = path.join(__dirname, '..', 'bin', 'pippit-tool-cli' + ext);
11
11
  const args = process.argv.slice(2);
12
+ // The launcher owns the notice for this invocation, including native children.
13
+ const nativeEnv = { ...process.env, PIPPIT_CLI_UPDATE_CHECKED: '1' };
12
14
 
13
15
  const oldBin = bin + '.old';
14
16
  function restoreOldBinary() {
@@ -25,7 +27,7 @@ function restoreOldBinary() {
25
27
 
26
28
  function runCanvas() {
27
29
  runCanvasCommand(args, {
28
- nativeInvocation: { command: bin },
30
+ nativeInvocation: { command: bin, env: nativeEnv },
29
31
  })
30
32
  .then((exitCode) => {
31
33
  if (exitCode) process.exitCode = exitCode;
@@ -44,6 +46,15 @@ function main() {
44
46
 
45
47
  const helpRequested = args.includes('--help') || args.includes('-h') || args[0] === 'help';
46
48
  if (isCanvasCommand(args) && (args[2] !== 'run' || helpRequested)) {
49
+ // Discovery commands also need update notices. Keep help and invalid
50
+ // commands free of network checks and cache writes.
51
+ if (!helpRequested) {
52
+ try {
53
+ if (parseCanvasCommandArgs(args).action !== 'help') maybeWarnNewVersion(args);
54
+ } catch (_) {
55
+ // runCanvas reports argument errors through the existing path.
56
+ }
57
+ }
47
58
  return runCanvas();
48
59
  }
49
60
 
@@ -88,7 +99,7 @@ function main() {
88
99
  runCanvas();
89
100
  } else {
90
101
  try {
91
- execFileSync(bin, args, { stdio: 'inherit' });
102
+ execFileSync(bin, args, { stdio: 'inherit', env: nativeEnv });
92
103
  } catch (e) {
93
104
  process.exit(e.status || 1);
94
105
  }
@@ -5,6 +5,7 @@ const { runSilent } = require("./platform");
5
5
  const { DEFAULT_PKG } = require("./skills");
6
6
 
7
7
  const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
8
+ const RETRY_INTERVAL_MS = 5 * 60 * 1000;
8
9
 
9
10
  function defaultCacheFile() {
10
11
  return path.join(os.homedir(), ".pippit_tool_cli", "version-check.json");
@@ -49,7 +50,7 @@ function writeCache(cacheFile, data) {
49
50
  }
50
51
 
51
52
  function fetchLatestVersion(pkg = DEFAULT_PKG) {
52
- return runSilent("npm", ["view", pkg, "version"], { timeout: 3000 }).toString().trim();
53
+ return runSilent("npm", ["view", pkg, "version"], { timeout: 1000 }).toString().trim();
53
54
  }
54
55
 
55
56
  function shouldSkip(args, env) {
@@ -70,6 +71,7 @@ function maybeWarnNewVersion(args = [], opts = {}) {
70
71
  const cacheFile = opts.cacheFile || defaultCacheFile();
71
72
  const cache = readCache(cacheFile);
72
73
  const cacheFresh = cache && now - cache.checkedAt < CHECK_INTERVAL_MS;
74
+ if (cacheFresh && !cache.latest && now - cache.checkedAt < RETRY_INTERVAL_MS) return;
73
75
 
74
76
  let latest = cacheFresh ? cache.latest : "";
75
77
  if (!latest) {
@@ -77,6 +79,7 @@ function maybeWarnNewVersion(args = [], opts = {}) {
77
79
  latest = (opts.fetchLatestVersion || fetchLatestVersion)(opts.pkg || DEFAULT_PKG);
78
80
  writeCache(cacheFile, { latest, checkedAt: now });
79
81
  } catch (_) {
82
+ writeCache(cacheFile, { latest: "", checkedAt: now });
80
83
  return;
81
84
  }
82
85
  }
@@ -86,10 +89,14 @@ function maybeWarnNewVersion(args = [], opts = {}) {
86
89
 
87
90
  const warn = opts.warn || console.error;
88
91
  warn(`[pippit-tool-cli] New version available: ${current} -> ${latest}. Run: pippit-tool-cli update`);
92
+ if (args[0] === "login") {
93
+ warn("[pippit-tool-cli] If browser authorization fails, update the CLI and run pippit-tool-cli login again.");
94
+ }
89
95
  }
90
96
 
91
97
  module.exports = {
92
98
  CHECK_INTERVAL_MS,
99
+ RETRY_INTERVAL_MS,
93
100
  compareSemver,
94
101
  defaultCacheFile,
95
102
  maybeWarnNewVersion,
@@ -113,9 +113,17 @@ function invokeCLI(args, input, timeout, invocation = resolveCLI()) {
113
113
  clearTimeout(timer);
114
114
  if (failure) return reject(failure);
115
115
  if (code !== 0) return reject(new Error(`CLI 调用失败:${stderr.trim() || `退出码 ${code}`};请求未重试。若不支持 marketing 命令,请更新 CLI。`));
116
- if (args.includes('--help')) return resolve(stdout);
117
- try { resolve(JSON.parse(stdout)); }
118
- catch (_) { reject(new Error('CLI 未返回有效 JSON;提交结果可能不明确,请勿自动重提')); }
116
+ let result;
117
+ try { result = args.includes('--help') ? stdout : JSON.parse(stdout); }
118
+ catch (_) { return reject(new Error('CLI 未返回有效 JSON;提交结果可能不明确,请勿自动重提')); }
119
+ // Only successful CLI notices are safe to relay; backend logs stay buffered.
120
+ for (const notice of stderr.split(/\r?\n/)) {
121
+ if (notice.startsWith('[pippit-tool-cli] New version available:') ||
122
+ notice === '[pippit-tool-cli] If browser authorization fails, update the CLI and run pippit-tool-cli login again.') {
123
+ process.stderr.write(`${notice}\n`);
124
+ }
125
+ }
126
+ resolve(result);
119
127
  });
120
128
  child.stdin.end(input);
121
129
  });