@pippit-dev/cli 1.0.40 → 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
- 14eec459ef3d526057582d47ec5611a48146b825181f06652f31c76e203a7f9c pippit-tool-cli-1.0.40-darwin-amd64.tar.gz
2
- 81d6967ef44953e91d9a2504bba20979570de7c0554aae4d7b55660da9fb32e8 pippit-tool-cli-1.0.40-darwin-arm64.tar.gz
3
- 8811832e3c840cb0418654953f354b254f3889346966c161ab65279f0cf03662 pippit-tool-cli-1.0.40-linux-amd64.tar.gz
4
- 28ce9f374f34fed4fa3e7b44a2b1b1fcc59c8cd7d83ad7621e8c694dd35194ad pippit-tool-cli-1.0.40-linux-arm64.tar.gz
5
- ffd11a5de082b50b54d8b9f701890186c28785bca4cfd6e8e05254ce6eed965c pippit-tool-cli-1.0.40-windows-amd64.zip
6
- 88ed4320bbef1146355890753226ec6a99b0f0653556e1a4665ad7f23dd17c30 pippit-tool-cli-1.0.40-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
- 14eec459ef3d526057582d47ec5611a48146b825181f06652f31c76e203a7f9c pippit-tool-cli-1.0.40-darwin-amd64.tar.gz
2
- 81d6967ef44953e91d9a2504bba20979570de7c0554aae4d7b55660da9fb32e8 pippit-tool-cli-1.0.40-darwin-arm64.tar.gz
3
- 8811832e3c840cb0418654953f354b254f3889346966c161ab65279f0cf03662 pippit-tool-cli-1.0.40-linux-amd64.tar.gz
4
- 28ce9f374f34fed4fa3e7b44a2b1b1fcc59c8cd7d83ad7621e8c694dd35194ad pippit-tool-cli-1.0.40-linux-arm64.tar.gz
5
- ffd11a5de082b50b54d8b9f701890186c28785bca4cfd6e8e05254ce6eed965c pippit-tool-cli-1.0.40-windows-amd64.zip
6
- 88ed4320bbef1146355890753226ec6a99b0f0653556e1a4665ad7f23dd17c30 pippit-tool-cli-1.0.40-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.40",
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,
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: xyq-short-drama-skill
3
- description: 通过小云雀 CLI 调用短剧 Agent,完成从剧本到视频产物的短剧创作流程。支持原创剧本、续写改写、剧情扩展与分集创作,上传和解析参考剧本,确认当前剧本并分析,规划短剧风格,生成故事版、片段视频并合成完整视频。支持多轮创作确认、会话续接、进度查询,以及 AI 剧本大纲、AI 分集正文、jpg 图片和 mp4 视频保留产物的获取与交付。用户要求创作短剧、解析剧本、设计故事版或分镜、制作短剧视频,或查询和获取小云雀短剧任务产物时使用。用户要求独立图片、视频、音频或画布素材创作时,需在当前短剧会话完成剧本解析后再由服务端处理,不强制继续进入后续主流程阶段。
3
+ description: 通过小云雀 CLI 调用短剧 Agent,完成从剧本到视频产物的短剧创作流程。支持原创剧本、续写改写、剧情扩展与分集创作,上传和解析参考剧本,确认当前剧本并分析,规划短剧风格,生成角色与场景图片,再生成故事版、片段视频并合成完整视频。支持多轮创作确认、会话续接、进度查询,以及 AI 剧本大纲、AI 分集正文、jpg 图片和 mp4 视频保留产物的获取与交付。用户要求创作短剧、解析剧本、设计故事版或分镜、制作短剧视频,或查询和获取小云雀短剧任务产物时使用。用户要求独立图片、视频、音频或画布素材创作时,需在当前短剧会话完成剧本解析后再由服务端处理,不强制继续进入后续主流程阶段。
4
4
  user-invocable: true
5
5
  metadata:
6
6
  {"openclaw": {"emoji": "📖", "requires": {"bins": ["pippit-tool-cli"]}}}
@@ -33,7 +33,7 @@ metadata:
33
33
  ## 执行总则
34
34
 
35
35
  - 原样传递用户需求,不扩写、润色、翻译或先自行编写剧情再提交,不把用户一次需求擅自拆成多次提交,也不把自己创作的内容混入后端结果。
36
- - 保留原有短剧阶段顺序和流程选项清洗规则,详见创作流程;不替用户选择剧情、风格、人物或镜头方案。
36
+ - 遵守[创作流程](workflows/creation.md)中的阶段顺序、图片前置检查和流程选项清洗规则;不替用户选择剧情、风格、人物或镜头方案。
37
37
  - 用户要求直接创建或编辑独立图片、视频、音频、画布节点或画布素材时,先确认当前会话已完成剧本解析/分析;未完成时先推进剧本准备、确认当前剧本并分析。完成剧本解析后,可按用户原始需求提交给服务端处理,不要求继续完成风格确认、故事版或片段视频;产物不会因为生成成功就自动绑定、替换或推进短剧项目。用户明确要求把产物应用到短剧项目时,再回到对应业务流程和授权规则。
38
38
  - 进入消耗 credits 的图片、视频生成或编辑阶段时,若用户本轮尚未明确确认执行,先征求确认;已有明确授权不重复询问。纯文本规划、查询无需额外确认,不默认同意或超时后继续。
39
39
  - 参考剧本只支持本地 `.doc` / `.docx` / `.txt`;同一会话只绑定一个剧本文件。后续续写只传会话 ID,不重复追加剧本。
@@ -22,3 +22,7 @@ pippit-tool-cli download-result --url "DOWNLOAD_URL" --output-path "FILE_PATH" -
22
22
  ```
23
23
 
24
24
  没有 updated_at 时省略该参数。确认实际 output_path 后通过宿主发送大纲/分集正文附件;产生图片/视频时也逐项展示。非保留范围的内部文件默认不下载、不交付。后端仅返回文本且没有文件时,展示真实文本,不编造附件。查询时限、错误恢复和最终完成判断按共用流程执行。
25
+
26
+ ## 从剧本继续制作视频
27
+
28
+ 用户明确要求“按默认推荐做完第1集视频”时,按授权范围依次推进:确认当前剧本并分析 → 确认风格 → 角色与场景图片 → 故事版 → 片段视频 → 整集合成。图片阶段按[图片前置检查](../workflows/creation.md#角色与场景图片前置检查)处理。只要求剧本开头时,到剧本交付即停止,不自动执行本节。
@@ -27,12 +27,23 @@
27
27
  1. 剧本准备:上传剧本、AI 生成剧本、编辑、改编或续写。
28
28
  2. 确认当前剧本并分析:向用户确认使用最新剧本后进入分析;角色、场景分析由该分析流程自动衔接,不作为用户侧独立阶段。
29
29
  3. 风格确认:确认短剧风格推荐。
30
- 4. 故事版:生成或修改故事版、分镜和相关素材。
31
- 5. 片段视频:生成或重生成片段视频。
32
- 6. 完整视频:合成完整视频。
30
+ 4. 角色与场景图片:按已确认风格生成本次范围所需的角色与场景图片,详见下方图片前置检查。
31
+ 5. 故事版:生成或修改故事版、分镜和相关素材。
32
+ 6. 片段视频:生成或重生成片段视频。
33
+ 7. 完整视频:合成完整视频。
33
34
 
34
35
  剧本标准化是风格确认后的可选操作,只在用户明确希望标准化整理、旁白改编或统一表达时执行。主流程是能力范围,不是每次必须从头执行的清单;按用户意图和当前真实资产进入对应步骤。
35
36
 
37
+ ## 角色与场景图片前置检查
38
+
39
+ 在短剧主流程中,生成故事版和片段视频前,先完成本次范围所需的角色与场景图片生成。角色、场景分析或文字设定完成,不等于图片已生成完成。这里的完成确认是核对服务端结果,不新增用户确认步骤;授权和创作偏好确认仍按既有规则执行。
40
+
41
+ - 在原会话内按已确认风格和用户授权范围推进图片生成;已有图片可复用时不重复生成,只补所需的缺失图片。不自行补写角色外观、场景设定或图片提示词。
42
+ - 由服务端核全本次制作范围所需的全部角色与场景图片,不只核对主角或主要场景;用户明确要求“画布所有图片”时保留该范围,不自行缩小为部分角色或场景。未核全或存在状态不明的图片时,不宣称全部完成,也不推进依赖这些图片的故事版或片段视频生成。
43
+ - 图片是否生成完成以服务端回答为准;状态不明确时,按业务内容查询规则核对,不从文件名、文件数量或内部文件推断。图片仍在生成时等待原任务,失败或提交结果不确定时按轮询流程处理,暂不推进短剧主流程的故事版和片段视频生成。
44
+ - 对用户说明已确认完成的部分、尚未完成或无法确认的部分,以及下一步;不把推测写成结果,不用内部错误堆砌提示。例如:“部分角色和场景图已完成,其余图片状态暂时无法核实,需要先核实后再继续视频制作。”仅在确实需要用户补充信息时询问,明确需要的信息,不把已授权的生成操作反复交给用户确认。
45
+ - 用户只要求剧本、解析、查询、取件或独立素材时,保留原有任务范围和独立素材边界,不额外推进整剧制作。图片生成沿用既有授权规则,已获授权不重复确认。
46
+
36
47
  ## 独立素材创作边界
37
48
 
38
49
  用户要求直接生成或编辑图片、视频、音频、画布节点或画布素材时,先确认当前会话已完成剧本解析/分析。未完成时,先推进剧本准备、确认当前剧本并分析;不要绕过剧本解析直接进入素材生成。
@@ -44,7 +55,7 @@
44
55
  后端 Agent 通过 `readable_text` 发出表单、问卷、选项、按钮或询问用户时,用户侧 Agent 不要机械原样转述所有选项。先结合短剧主流程顺序清洗选项,再把合理、必要、当前可执行的流程项呈现给用户。
45
56
 
46
57
  - 保留当前阶段的确认项,以及不会跳过必要阶段的下一步流程项。
47
- - 剔除跳过必要阶段的选项。例如未确认当前最新剧本前,不应让用户直接进入“剧本分析”;未完成风格确认前,不应让用户直接进入故事版;未确认故事版和片段视频范围前,不应进入完整视频合成。
58
+ - 剔除跳过必要阶段的选项。例如未确认当前最新剧本前,不应让用户直接进入“剧本分析”;未完成风格确认前,不应让用户直接进入故事版;不满足图片前置检查时,不应进入故事版或片段视频生成;未确认故事版和片段视频范围前,不应进入完整视频合成。
48
59
  - 剔除倒退到无关阶段的选项。只有用户明确要求返工、修改或重新生成时,才展示回退选项。
49
60
  - `剧本标准化` 是可选阶段,只能出现在“风格确认”之后、“故事版”之前。不要把它包装成任意阶段都可以跳过或补做的通用选项。
50
61
  - 不替用户决定创意内容,例如风格、剧情方向、角色设定、镜头方案。只能清洗流程选项,不能代替用户选择创作偏好。
@@ -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
  });