@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 +6 -6
- package/cmd/root.go +4 -0
- package/cmd/root_update_check_test.go +139 -0
- package/dist/checksums.txt +6 -6
- package/internal/auth/loopback.go +16 -11
- package/internal/auth/loopback_origins_test.go +152 -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 +10 -1
- package/scripts/run.js +14 -3
- package/scripts/version-check.js +8 -1
- package/skills/xyq-marketing-skill/scripts/marketing.js +11 -3
package/checksums.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
+
}
|
package/dist/checksums.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
}
|
package/scripts/version-check.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
117
|
-
try {
|
|
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
|
});
|