@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 +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/short-drama/SKILL.md +2 -2
- package/skills/short-drama/examples/create-and-deliver.md +4 -0
- package/skills/short-drama/workflows/creation.md +15 -4
- 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,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: xyq-short-drama-skill
|
|
3
|
-
description: 通过小云雀 CLI 调用短剧 Agent
|
|
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
|
-
|
|
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
|
});
|