@pippit-dev/cli 1.0.16 → 1.0.18

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.
Files changed (41) hide show
  1. package/README.md +24 -3
  2. package/checksums.txt +6 -6
  3. package/cmd/auth/auth.go +136 -142
  4. package/cmd/auth/auth_test.go +134 -0
  5. package/cmd/canvas/canvas.go +235 -0
  6. package/cmd/canvas/canvas_test.go +126 -0
  7. package/cmd/generate_video/generate_video.go +1 -1
  8. package/cmd/root.go +29 -4
  9. package/cmd/root_test.go +60 -0
  10. package/cmd/short_drama_test.go +4 -7
  11. package/cmd/update/update.go +34 -5
  12. package/cmd/update/update_test.go +84 -0
  13. package/internal/auth/auth_test.go +813 -0
  14. package/internal/auth/browser_darwin.go +14 -0
  15. package/internal/auth/browser_env.go +35 -0
  16. package/internal/auth/browser_linux.go +14 -0
  17. package/internal/auth/browser_windows.go +14 -0
  18. package/internal/auth/identity.go +89 -0
  19. package/internal/auth/loopback.go +324 -0
  20. package/internal/auth/manager.go +338 -144
  21. package/internal/auth/store.go +303 -0
  22. package/internal/auth/store_file_unix.go +176 -0
  23. package/internal/auth/store_file_windows.go +11 -0
  24. package/internal/auth/types.go +72 -0
  25. package/internal/canvas/allocate.go +70 -0
  26. package/internal/canvas/apply.go +203 -0
  27. package/internal/canvas/canvas_test.go +446 -0
  28. package/internal/canvas/create.go +380 -0
  29. package/internal/canvas/get.go +156 -0
  30. package/internal/canvas/types.go +68 -0
  31. package/internal/canvas/upload.go +250 -0
  32. package/internal/common/access_key.go +42 -6
  33. package/internal/common/access_key_test.go +113 -0
  34. package/internal/common/client.go +119 -21
  35. package/internal/common/client_test.go +213 -0
  36. package/internal/common/runner.go +15 -0
  37. package/internal/config/config.go +0 -20
  38. package/internal/config/config_test.go +0 -18
  39. package/package.json +1 -1
  40. package/skills/short-drama/SKILL.md +4 -4
  41. package/skills/xyq-nest-skill/SKILL.md +17 -6
@@ -0,0 +1,235 @@
1
+ package canvas
2
+
3
+ import (
4
+ "crypto/rand"
5
+ "encoding/hex"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "time"
13
+
14
+ canvascore "github.com/Pippit-dev/pippit-cli/internal/canvas"
15
+ "github.com/Pippit-dev/pippit-cli/internal/common"
16
+ "github.com/spf13/cobra"
17
+ )
18
+
19
+ const maxApplyRequestBytes = 64 << 20
20
+
21
+ // NewCommand builds the provider-neutral personal Canvas command tree.
22
+ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
23
+ cmd := &cobra.Command{
24
+ Use: "canvas",
25
+ Short: "Create and operate personal novel canvases",
26
+ Args: cobra.NoArgs,
27
+ }
28
+ cmd.SetOut(stdout)
29
+ cmd.SetErr(stderr)
30
+ cmd.AddCommand(newCreateCommand(stdout, stderr, runner))
31
+ cmd.AddCommand(newGetCommand(stdout, stderr, runner))
32
+ cmd.AddCommand(newAllocateCommand(stdout, stderr, runner))
33
+ cmd.AddCommand(newApplyCommand(stdout, stderr, runner))
34
+ cmd.AddCommand(newUploadCommand(stdout, stderr, runner))
35
+ return cmd
36
+ }
37
+
38
+ func newAllocateCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
39
+ var count int
40
+ cmd := &cobra.Command{
41
+ Use: "allocate",
42
+ Short: "Allocate IDs for assets created by a later Canvas transaction",
43
+ Args: cobra.NoArgs,
44
+ RunE: func(cmd *cobra.Command, _ []string) error {
45
+ result, err := canvascore.Allocate(cmd.Context(), count, runner)
46
+ if err != nil {
47
+ logCanvasError("canvas allocate", err, map[string]string{"count": fmt.Sprint(count)})
48
+ return err
49
+ }
50
+ return common.WriteJSON(stdout, result)
51
+ },
52
+ }
53
+ cmd.SetOut(stdout)
54
+ cmd.SetErr(stderr)
55
+ cmd.Flags().IntVar(&count, "count", 0, "number of unique Pippit asset IDs to allocate")
56
+ return cmd
57
+ }
58
+
59
+ func newCreateCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
60
+ var opts canvascore.CreateOptions
61
+ cmd := &cobra.Command{
62
+ Use: "create",
63
+ Short: "Create a personal novel Canvas project",
64
+ Args: cobra.NoArgs,
65
+ RunE: func(cmd *cobra.Command, _ []string) error {
66
+ if strings.TrimSpace(opts.RequestID) == "" {
67
+ requestID, err := newRequestID()
68
+ if err != nil {
69
+ return fmt.Errorf("generate canvas request ID: %w", err)
70
+ }
71
+ opts.RequestID = requestID
72
+ }
73
+ result, err := canvascore.Create(cmd.Context(), opts, runner)
74
+ if result != nil {
75
+ if writeErr := common.WriteJSON(stdout, result); writeErr != nil {
76
+ return writeErr
77
+ }
78
+ }
79
+ if err != nil {
80
+ logCanvasError("canvas create", err, map[string]string{"request_id": opts.RequestID})
81
+ return err
82
+ }
83
+ return nil
84
+ },
85
+ }
86
+ cmd.SetOut(stdout)
87
+ cmd.SetErr(stderr)
88
+ flags := cmd.Flags()
89
+ flags.StringVar(&opts.Title, "title", "", "project title (maximum 50 characters)")
90
+ flags.StringVar(&opts.RequestID, "request-id", "", "caller request ID; generated when omitted")
91
+ flags.BoolVar(&opts.Wait, "wait", false, "wait for the novel overview artifact")
92
+ flags.DurationVar(&opts.PollInterval, "poll-interval", time.Second, "artifact polling interval")
93
+ flags.DurationVar(&opts.WaitTimeout, "timeout", 2*time.Minute, "maximum artifact wait time")
94
+ return cmd
95
+ }
96
+
97
+ func newGetCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
98
+ var assetIDs []string
99
+ cmd := &cobra.Command{
100
+ Use: "get",
101
+ Short: "Get personal Canvas assets by ID",
102
+ Args: cobra.NoArgs,
103
+ RunE: func(cmd *cobra.Command, _ []string) error {
104
+ result, err := canvascore.Get(cmd.Context(), canvascore.GetOptions{AssetIDs: assetIDs}, runner)
105
+ if err != nil {
106
+ logCanvasError("canvas get", err, map[string]string{"asset_count": fmt.Sprint(len(assetIDs))})
107
+ return err
108
+ }
109
+ return common.WriteJSON(stdout, result)
110
+ },
111
+ }
112
+ cmd.SetOut(stdout)
113
+ cmd.SetErr(stderr)
114
+ cmd.Flags().StringArrayVar(&assetIDs, "asset-id", nil, "Pippit asset ID; repeat for multiple assets")
115
+ return cmd
116
+ }
117
+
118
+ func newApplyCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
119
+ var filePath string
120
+ var projectID string
121
+ cmd := &cobra.Command{
122
+ Use: "apply",
123
+ Short: "Apply one Canvas patch transaction",
124
+ Args: cobra.NoArgs,
125
+ RunE: func(cmd *cobra.Command, _ []string) error {
126
+ request, err := readApplyRequest(cmd.InOrStdin(), filePath)
127
+ if err != nil {
128
+ return err
129
+ }
130
+ result, err := canvascore.Apply(cmd.Context(), canvascore.ApplyOptions{
131
+ ProjectID: projectID,
132
+ Request: request,
133
+ }, runner)
134
+ if err != nil {
135
+ logCanvasError("canvas apply", err, map[string]string{
136
+ "batch_id": request.BatchID,
137
+ "project_id": projectID,
138
+ "transactions": fmt.Sprint(len(request.Transactions)),
139
+ })
140
+ return err
141
+ }
142
+ return common.WriteJSON(stdout, result)
143
+ },
144
+ }
145
+ cmd.SetOut(stdout)
146
+ cmd.SetErr(stderr)
147
+ flags := cmd.Flags()
148
+ flags.StringVar(&filePath, "file", "-", "BatchPatch JSON request file, or - for stdin")
149
+ flags.StringVar(&projectID, "project-id", "", "personal novel project ID as a decimal string")
150
+ return cmd
151
+ }
152
+
153
+ func newUploadCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
154
+ var opts canvascore.UploadOptions
155
+ cmd := &cobra.Command{
156
+ Use: "upload",
157
+ Short: "Upload a file to personal assets and wait until it is queryable",
158
+ Args: cobra.NoArgs,
159
+ RunE: func(cmd *cobra.Command, _ []string) error {
160
+ result, err := canvascore.Upload(cmd.Context(), opts, runner)
161
+ if err != nil {
162
+ logCanvasError("canvas upload", err, map[string]string{"file_name": filepath.Base(strings.TrimSpace(opts.Path))})
163
+ return err
164
+ }
165
+ return common.WriteJSON(stdout, result)
166
+ },
167
+ }
168
+ cmd.SetOut(stdout)
169
+ cmd.SetErr(stderr)
170
+ flags := cmd.Flags()
171
+ flags.StringVar(&opts.Path, "path", "", "local file path to upload")
172
+ flags.DurationVar(&opts.PollInterval, "poll-interval", time.Second, "asset visibility polling interval")
173
+ flags.DurationVar(&opts.WaitTimeout, "timeout", 2*time.Minute, "maximum asset visibility wait time")
174
+ return cmd
175
+ }
176
+
177
+ func newRequestID() (string, error) {
178
+ random := make([]byte, 16)
179
+ if _, err := rand.Read(random); err != nil {
180
+ return "", err
181
+ }
182
+ return "pippit_cli_canvas_" + hex.EncodeToString(random), nil
183
+ }
184
+
185
+ func readApplyRequest(stdin io.Reader, filePath string) (canvascore.ApplyRequest, error) {
186
+ filePath = strings.TrimSpace(filePath)
187
+ if filePath == "" {
188
+ return canvascore.ApplyRequest{}, fmt.Errorf("canvas apply --file must not be empty")
189
+ }
190
+ var reader io.Reader
191
+ var file *os.File
192
+ if filePath == "-" {
193
+ reader = stdin
194
+ } else {
195
+ var err error
196
+ file, err = os.Open(filePath)
197
+ if err != nil {
198
+ return canvascore.ApplyRequest{}, fmt.Errorf("open canvas apply request: %w", err)
199
+ }
200
+ defer file.Close()
201
+ reader = file
202
+ }
203
+ limited := io.LimitReader(reader, maxApplyRequestBytes+1)
204
+ payload, err := io.ReadAll(limited)
205
+ if err != nil {
206
+ return canvascore.ApplyRequest{}, fmt.Errorf("read canvas apply request: %w", err)
207
+ }
208
+ if len(payload) > maxApplyRequestBytes {
209
+ return canvascore.ApplyRequest{}, fmt.Errorf("canvas apply request exceeds %d bytes", maxApplyRequestBytes)
210
+ }
211
+ decoder := json.NewDecoder(strings.NewReader(string(payload)))
212
+ decoder.DisallowUnknownFields()
213
+ var request canvascore.ApplyRequest
214
+ if err := decoder.Decode(&request); err != nil {
215
+ return canvascore.ApplyRequest{}, fmt.Errorf("decode canvas apply request: %w", err)
216
+ }
217
+ if err := ensureJSONEOF(decoder); err != nil {
218
+ return canvascore.ApplyRequest{}, err
219
+ }
220
+ return request, nil
221
+ }
222
+
223
+ func ensureJSONEOF(decoder *json.Decoder) error {
224
+ var trailing any
225
+ if err := decoder.Decode(&trailing); err == io.EOF {
226
+ return nil
227
+ } else if err != nil {
228
+ return fmt.Errorf("decode trailing canvas apply data: %w", err)
229
+ }
230
+ return fmt.Errorf("canvas apply request must contain exactly one JSON object")
231
+ }
232
+
233
+ func logCanvasError(command string, err error, fields map[string]string) {
234
+ _ = common.AppendDailyErrorLog(command, err, fields)
235
+ }
@@ -0,0 +1,126 @@
1
+ package canvas
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "strings"
8
+ "testing"
9
+
10
+ "github.com/Pippit-dev/pippit-cli/internal/common"
11
+ )
12
+
13
+ type commandFakeClient struct {
14
+ response string
15
+ request map[string]any
16
+ handler func(string, any, any) error
17
+ }
18
+
19
+ func (f *commandFakeClient) SendRequest(_ context.Context, path string, body any, out any) error {
20
+ payload, _ := json.Marshal(body)
21
+ _ = json.Unmarshal(payload, &f.request)
22
+ if f.handler != nil {
23
+ return f.handler(path, body, out)
24
+ }
25
+ return json.Unmarshal([]byte(f.response), out)
26
+ }
27
+
28
+ func (f *commandFakeClient) SendRequestWithHeaders(ctx context.Context, path string, body any, _ map[string]string, out any) error {
29
+ return f.SendRequest(ctx, path, body, out)
30
+ }
31
+
32
+ func (f *commandFakeClient) SendMultipartRequest(context.Context, string, map[string]string, common.MultipartFile, any) error {
33
+ return nil
34
+ }
35
+
36
+ func TestCommandExposesOnlyProviderNeutralPublicVerbs(t *testing.T) {
37
+ cmd := NewCommand(&bytes.Buffer{}, &bytes.Buffer{}, &common.Runner{Client: &commandFakeClient{}})
38
+ got := make([]string, 0, len(cmd.Commands()))
39
+ for _, child := range cmd.Commands() {
40
+ got = append(got, child.Name())
41
+ }
42
+ if strings.Join(got, ",") != "allocate,apply,create,get,upload" {
43
+ t.Fatalf("commands = %v, want allocate/apply/create/get/upload", got)
44
+ }
45
+ for _, forbidden := range []string{"import", "bind", "team"} {
46
+ if strings.Contains(strings.ToLower(cmd.CommandPath()+" "+cmd.Short+" "+strings.Join(got, " ")), forbidden) {
47
+ t.Fatalf("public command surface contains forbidden verb %q", forbidden)
48
+ }
49
+ }
50
+ }
51
+
52
+ func TestAllocateCommandPrintsOneMachineReadableJSONLine(t *testing.T) {
53
+ client := &commandFakeClient{response: `{"ret":"0","log_id":"log-1","data":{"ids":["10","11"]}}`}
54
+ var stdout, stderr bytes.Buffer
55
+ cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client})
56
+ cmd.SetArgs([]string{"allocate", "--count", "2"})
57
+ if err := cmd.Execute(); err != nil {
58
+ t.Fatalf("Execute() error = %v", err)
59
+ }
60
+ if strings.Count(stdout.String(), "\n") != 1 {
61
+ t.Fatalf("stdout = %q, want one JSON line", stdout.String())
62
+ }
63
+ var result map[string]any
64
+ if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil {
65
+ t.Fatalf("stdout is not JSON: %v", err)
66
+ }
67
+ if len(result["asset_ids"].([]any)) != 2 || client.request["count"] != float64(2) {
68
+ t.Fatalf("result/request = (%#v, %#v), want two allocated IDs", result, client.request)
69
+ }
70
+ }
71
+
72
+ func TestCreateCommandPrintsOneMachineReadableJSONLine(t *testing.T) {
73
+ client := &commandFakeClient{response: `{"ret":"0","log_id":"log-1","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`}
74
+ var stdout, stderr bytes.Buffer
75
+ cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client})
76
+ cmd.SetArgs([]string{"create", "--title", "Demo", "--request-id", "request-1"})
77
+ if err := cmd.Execute(); err != nil {
78
+ t.Fatalf("Execute() error = %v", err)
79
+ }
80
+ if strings.Count(stdout.String(), "\n") != 1 {
81
+ t.Fatalf("stdout = %q, want one JSON line", stdout.String())
82
+ }
83
+ var result map[string]any
84
+ if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil {
85
+ t.Fatalf("stdout is not JSON: %v", err)
86
+ }
87
+ if result["request_id"] != "request-1" || result["project_id"] != "100" || client.request["surface"] != "novel" {
88
+ t.Fatalf("result/request = (%#v, %#v), want personal novel create", result, client.request)
89
+ }
90
+ }
91
+
92
+ func TestCreateCommandWaitTimeoutPrintsAcceptedIDs(t *testing.T) {
93
+ client := &commandFakeClient{handler: func(path string, _ any, out any) error {
94
+ response := `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run-1","state":1}]}}}`
95
+ if path == "/api/biz/v1/skill/canvas/create" {
96
+ response = `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`
97
+ }
98
+ return json.Unmarshal([]byte(response), out)
99
+ }}
100
+ var stdout, stderr bytes.Buffer
101
+ cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client})
102
+ cmd.SetArgs([]string{
103
+ "create", "--request-id", "request-1", "--wait",
104
+ "--poll-interval", "50ms", "--timeout", "1ms",
105
+ })
106
+ if err := cmd.Execute(); err != nil {
107
+ t.Fatalf("Execute() error = %v, want accepted create outcome", err)
108
+ }
109
+ var result map[string]any
110
+ if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil {
111
+ t.Fatalf("stdout = %q, want JSON: %v", stdout.String(), err)
112
+ }
113
+ if result["project_id"] != "100" || result["state"] != "creating" || result["warning"] == "" {
114
+ t.Fatalf("result = %#v, want accepted IDs and wait warning", result)
115
+ }
116
+ }
117
+
118
+ func TestApplyRequestRejectsTeamOrImportExtensions(t *testing.T) {
119
+ for _, field := range []string{"team_id", "provider", "import_source"} {
120
+ payload := `{"batch_id":"b","client_id":"c","transactions":[],"` + field + `":"x"}`
121
+ _, err := readApplyRequest(strings.NewReader(payload), "-")
122
+ if err == nil || !strings.Contains(err.Error(), "unknown field") {
123
+ t.Fatalf("readApplyRequest(%s) error = %v, want unknown field rejection", field, err)
124
+ }
125
+ }
126
+ }
@@ -48,7 +48,7 @@ func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command
48
48
  flags.StringArrayVar(&opts.AudioPaths, "audio", nil, "local reference audio path; repeat for multiple audios, up to 3")
49
49
  flags.IntVar(&durationSec, "duration", 0, "video duration in seconds")
50
50
  flags.StringVar(&opts.Ratio, "ratio", "", "video ratio, such as 9:16, 16:9, 3:4, 4:3")
51
- flags.StringVar(&opts.Model, "model", "", "video model; normal users: Seedance_2.0_mini_lite; VIP-only: seedance2.0_vision, seedance2.0_fast_vision, Seedance_2.0_mini")
51
+ flags.StringVar(&opts.Model, "model", "", "video model; normal users: Seedance_2.0_mini_lite; VIP-only: seedance2.0_vision, seedance2.0_fast_vision, Seedance_2.0_mini, Seedance_2.5")
52
52
  flags.StringVar(&opts.Resolution, "resolution", "", "video resolution, such as 720p, 1080p")
53
53
  flags.Int64Var(&generateType, "generate-type", 0, "generation type passed to the service; set 1 for first-and-last-frame generation and provide two --image values in first-frame, last-frame order")
54
54
  return cmd
package/cmd/root.go CHANGED
@@ -1,17 +1,20 @@
1
1
  package cmd
2
2
 
3
3
  import (
4
+ "context"
4
5
  "fmt"
5
6
  "io"
6
7
  "os"
7
8
  "strings"
8
9
 
9
- // authcmd "github.com/Pippit-dev/pippit-cli/cmd/auth"
10
+ authcmd "github.com/Pippit-dev/pippit-cli/cmd/auth"
11
+ canvascmd "github.com/Pippit-dev/pippit-cli/cmd/canvas"
10
12
  "github.com/Pippit-dev/pippit-cli/cmd/generate_image"
11
13
  "github.com/Pippit-dev/pippit-cli/cmd/generate_video"
12
14
  "github.com/Pippit-dev/pippit-cli/cmd/short_drama"
13
15
  updatecmd "github.com/Pippit-dev/pippit-cli/cmd/update"
14
16
  "github.com/Pippit-dev/pippit-cli/cmd/video_tool"
17
+ internal_auth "github.com/Pippit-dev/pippit-cli/internal/auth"
15
18
  "github.com/Pippit-dev/pippit-cli/internal/common"
16
19
  "github.com/Pippit-dev/pippit-cli/internal/config"
17
20
  "github.com/Pippit-dev/pippit-cli/internal/version"
@@ -25,11 +28,30 @@ func Execute() error {
25
28
 
26
29
  func NewRootCommand(stdout, stderr io.Writer) *cobra.Command {
27
30
  cfg := config.Load()
28
- client := common.NewHTTPClient(cfg.BaseURL, cfg.HTTPTimeout, common.NewAccessKeyAuthorizer(cfg.AccessKey))
29
- runner := common.NewRunner(cfg, client)
31
+ runner := newRootRunner(cfg)
30
32
  return newRootCommand(stdout, stderr, runner)
31
33
  }
32
34
 
35
+ func newRootRunner(cfg *config.Config) *common.Runner {
36
+ runner := common.NewRunner(cfg, nil)
37
+ runner.Auth = internal_auth.NewManager(cfg)
38
+ runner.Client = common.NewHTTPClient(
39
+ cfg.BaseURL,
40
+ cfg.HTTPTimeout,
41
+ newRunnerAuthorizer(runner),
42
+ )
43
+ return runner
44
+ }
45
+
46
+ func newRunnerAuthorizer(runner *common.Runner) common.RequestAuthorizer {
47
+ return common.NewAccessKeyContextProviderAuthorizer(func(ctx context.Context) (string, error) {
48
+ if runner.Auth == nil {
49
+ return "", nil
50
+ }
51
+ return runner.Auth.ResolveAccessKey(ctx)
52
+ })
53
+ }
54
+
33
55
  func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
34
56
  root := &cobra.Command{
35
57
  Use: "pippit-tool-cli",
@@ -43,7 +65,10 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm
43
65
  root.SetVersionTemplate("{{.Version}}\n")
44
66
  root.SetOut(stdout)
45
67
  root.SetErr(stderr)
46
- // root.AddCommand(authcmd.NewCommand(stdout, stderr, runner)) // temporarily disabled; auth is via access key injection
68
+ root.AddCommand(authcmd.NewLoginCommand(stdout, stderr, runner))
69
+ root.AddCommand(authcmd.NewStatusCommand(stdout, stderr, runner))
70
+ root.AddCommand(authcmd.NewLogoutCommand(stdout, stderr, runner))
71
+ root.AddCommand(canvascmd.NewCommand(stdout, stderr, runner))
47
72
  root.AddCommand(newDownloadResultCommand(stdout, stderr, runner))
48
73
  root.AddCommand(newGetThreadCommand(stdout, stderr, runner))
49
74
  root.AddCommand(newListThreadFileCommand(stdout, stderr, runner))
@@ -0,0 +1,60 @@
1
+ package cmd
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "strings"
9
+ "testing"
10
+ "time"
11
+
12
+ "github.com/Pippit-dev/pippit-cli/internal/config"
13
+ )
14
+
15
+ func TestRootRunnerReadsUpdatedAccessKeyForEveryRequest(t *testing.T) {
16
+ received := make([]string, 0, 2)
17
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
18
+ received = append(received, request.Header.Get("Authorization"))
19
+ writer.Header().Set("Content-Type", "application/json")
20
+ _, _ = writer.Write([]byte(`{"ok":true}`))
21
+ }))
22
+ defer server.Close()
23
+
24
+ cfg := config.Load()
25
+ cfg.BaseURL = server.URL
26
+ cfg.HTTPTimeout = time.Second
27
+ cfg.AccessKey = "first-key"
28
+ runner := newRootRunner(cfg)
29
+
30
+ for _, accessKey := range []string{"first-key", "second-key"} {
31
+ runner.Config.AccessKey = accessKey
32
+ var response map[string]any
33
+ if err := runner.Client.SendRequest(context.Background(), "/probe", map[string]any{}, &response); err != nil {
34
+ t.Fatalf("SendRequest(%q) error = %v", accessKey, err)
35
+ }
36
+ }
37
+ if got, want := strings.Join(received, ","), "Bearer first-key,Bearer second-key"; got != want {
38
+ t.Fatalf("Authorization headers = %q, want %q", got, want)
39
+ }
40
+ }
41
+
42
+ func TestRootRegistersCanvas(t *testing.T) {
43
+ var stdout, stderr bytes.Buffer
44
+ root := NewRootCommand(&stdout, &stderr)
45
+ command, _, err := root.Find([]string{"canvas"})
46
+ if err != nil || command == nil || command.Name() != "canvas" {
47
+ t.Fatalf("root.Find(%q) = %#v, %v", "canvas", command, err)
48
+ }
49
+ }
50
+
51
+ func TestRootRegistersTopLevelBrowserAuthCommands(t *testing.T) {
52
+ var stdout, stderr bytes.Buffer
53
+ root := NewRootCommand(&stdout, &stderr)
54
+ for _, name := range []string{"login", "status", "logout"} {
55
+ command, _, err := root.Find([]string{name})
56
+ if err != nil || command == nil || command.Name() != name {
57
+ t.Fatalf("root.Find(%q) = %#v, %v", name, command, err)
58
+ }
59
+ }
60
+ }
@@ -140,7 +140,7 @@ func TestRootHelpListsSupportedCommands(t *testing.T) {
140
140
  t.Fatalf("help output = %q, want %q", got, want)
141
141
  }
142
142
  }
143
- for _, unwanted := range []string{"completion", "version "} {
143
+ for _, unwanted := range []string{"completion", "\n version "} {
144
144
  if strings.Contains(got, unwanted) {
145
145
  t.Fatalf("help output = %q, should not contain %q", got, unwanted)
146
146
  }
@@ -971,14 +971,11 @@ func assertAccessKeyGuidance(t *testing.T, err error) {
971
971
  t.Fatal("error = nil, want access key guidance")
972
972
  }
973
973
  msg := err.Error()
974
- if !strings.Contains(msg, "XYQ_ACCESS_KEY 缺失") {
974
+ if !strings.Contains(msg, "pippit-tool-cli login") {
975
975
  t.Fatalf("error = %q, want access key guidance", err)
976
976
  }
977
- if !strings.Contains(msg, "https://xyq.jianying.com/home?tab_name=home") {
978
- t.Fatalf("error = %q, want access key settings URL", err)
979
- }
980
- if !strings.Contains(msg, `export XYQ_ACCESS_KEY="<your-access-key>"`) {
981
- t.Fatalf("error = %q, want setup command guidance", err)
977
+ if !strings.Contains(msg, "XYQ_ACCESS_KEY") {
978
+ t.Fatalf("error = %q, want CI override guidance", err)
982
979
  }
983
980
  }
984
981
 
@@ -14,6 +14,7 @@ import (
14
14
  "sync"
15
15
  "time"
16
16
 
17
+ "github.com/Pippit-dev/pippit-cli/internal/auth"
17
18
  "github.com/Pippit-dev/pippit-cli/internal/version"
18
19
  "github.com/spf13/cobra"
19
20
  )
@@ -219,20 +220,48 @@ func runInherit(stderr io.Writer, name string, args ...string) error {
219
220
 
220
221
  func runInheritEnv(stderr io.Writer, env []string, name string, args ...string) error {
221
222
  cmd := command(name, args...)
222
- if len(env) > 0 {
223
- cmd.Env = append(os.Environ(), env...)
224
- }
223
+ cmd.Env = sanitizedUpdateEnv(os.Environ(), env)
225
224
  cmd.Stdout = stderr
226
225
  cmd.Stderr = stderr
227
226
  return cmd.Run()
228
227
  }
229
228
 
229
+ func sanitizedUpdateEnv(environment, overrides []string) []string {
230
+ merged := overlayEnvironment(environment, overrides)
231
+ return auth.SanitizedBrowserEnv(merged)
232
+ }
233
+
234
+ func overlayEnvironment(environment, overrides []string) []string {
235
+ result := make([]string, 0, len(environment)+len(overrides))
236
+ indexes := make(map[string]int, len(environment)+len(overrides))
237
+ for _, entry := range append(append([]string(nil), environment...), overrides...) {
238
+ name, _, found := strings.Cut(entry, "=")
239
+ if !found || strings.TrimSpace(name) == "" {
240
+ continue
241
+ }
242
+ key := name
243
+ if runtime.GOOS == "windows" {
244
+ key = strings.ToUpper(key)
245
+ }
246
+ if index, exists := indexes[key]; exists {
247
+ result[index] = entry
248
+ continue
249
+ }
250
+ indexes[key] = len(result)
251
+ result = append(result, entry)
252
+ }
253
+ return result
254
+ }
230
255
  func command(name string, args ...string) *exec.Cmd {
256
+ var cmd *exec.Cmd
231
257
  if runtime.GOOS == "windows" {
232
258
  cmdArgs := append([]string{"/c", name}, args...)
233
- return exec.Command("cmd.exe", cmdArgs...)
259
+ cmd = exec.Command("cmd.exe", cmdArgs...)
260
+ } else {
261
+ cmd = exec.Command(name, args...)
234
262
  }
235
- return exec.Command(name, args...)
263
+ cmd.Env = sanitizedUpdateEnv(os.Environ(), nil)
264
+ return cmd
236
265
  }
237
266
 
238
267
  func prepareSelfReplace() (func(), error) {
@@ -171,3 +171,87 @@ func TestStripPrereleaseVersion(t *testing.T) {
171
171
  }
172
172
  }
173
173
  }
174
+
175
+ func TestRunInheritEnvSanitizesPippitCredentials(t *testing.T) {
176
+ if runtime.GOOS == "windows" {
177
+ t.Skip("test uses a POSIX shell script to capture the child environment")
178
+ }
179
+
180
+ binDir := t.TempDir()
181
+ capturePath := filepath.Join(t.TempDir(), "environment.txt")
182
+ commandPath := filepath.Join(binDir, "capture-update-environment")
183
+ script := "#!/bin/sh\n/usr/bin/env > \"$CAPTURE_ENV\"\n"
184
+ if err := os.WriteFile(commandPath, []byte(script), 0o755); err != nil {
185
+ t.Fatal(err)
186
+ }
187
+
188
+ basePath := binDir + string(os.PathListSeparator) + "/base/path"
189
+ overriddenPath := binDir + string(os.PathListSeparator) + "/overridden/path"
190
+ t.Setenv("PATH", basePath)
191
+ t.Setenv("CAPTURE_ENV", capturePath)
192
+ t.Setenv("SAFE_INHERITED", "kept")
193
+ t.Setenv("XYQ_ACCESS_KEY", "xyq-secret")
194
+ t.Setenv("PIPPIT_ACCESS_KEY", "pippit-secret")
195
+ t.Setenv("PIPPIT_AK", "legacy-secret")
196
+ t.Setenv("PIPPIT_CLI_TOKEN", "pippit-token")
197
+ t.Setenv("XYQ_CLIENT_SECRET", "xyq-client-secret")
198
+ t.Setenv("NPM_TOKEN", "npm-secret")
199
+ t.Setenv("NODE_AUTH_TOKEN", "registry-secret")
200
+
201
+ var stderr bytes.Buffer
202
+ err := runInheritEnv(&stderr, []string{
203
+ "PATH=" + overriddenPath,
204
+ "SAFE_INHERITED=overridden",
205
+ "SAFE_EXPLICIT=kept",
206
+ "PIPPIT_CLI_SKIP_SKILLS=1",
207
+ "XYQ_ACCESS_KEY=override-must-not-leak",
208
+ "PIPPIT_OVERRIDE_SECRET=override-must-not-leak",
209
+ "REGISTRY_TOKEN=explicit-registry-secret",
210
+ }, "capture-update-environment")
211
+ if err != nil {
212
+ t.Fatalf("runInheritEnv() error = %v, stderr = %s", err, stderr.String())
213
+ }
214
+
215
+ captured, err := os.ReadFile(capturePath)
216
+ if err != nil {
217
+ t.Fatal(err)
218
+ }
219
+ got := parseEnvironment(string(captured))
220
+ for _, forbidden := range []string{
221
+ "XYQ_ACCESS_KEY",
222
+ "PIPPIT_ACCESS_KEY",
223
+ "PIPPIT_AK",
224
+ "PIPPIT_CLI_TOKEN",
225
+ "XYQ_CLIENT_SECRET",
226
+ "PIPPIT_OVERRIDE_SECRET",
227
+ } {
228
+ if value, exists := got[forbidden]; exists {
229
+ t.Fatalf("child environment retained %s=%q", forbidden, value)
230
+ }
231
+ }
232
+ for name, want := range map[string]string{
233
+ "PATH": overriddenPath,
234
+ "CAPTURE_ENV": capturePath,
235
+ "SAFE_INHERITED": "overridden",
236
+ "SAFE_EXPLICIT": "kept",
237
+ "PIPPIT_CLI_SKIP_SKILLS": "1",
238
+ "NPM_TOKEN": "npm-secret",
239
+ "NODE_AUTH_TOKEN": "registry-secret",
240
+ "REGISTRY_TOKEN": "explicit-registry-secret",
241
+ } {
242
+ if value := got[name]; value != want {
243
+ t.Fatalf("child environment %s = %q, want %q", name, value, want)
244
+ }
245
+ }
246
+ }
247
+
248
+ func parseEnvironment(environment string) map[string]string {
249
+ result := make(map[string]string)
250
+ for _, entry := range strings.Split(environment, "\n") {
251
+ name, value, found := strings.Cut(entry, "=")
252
+ if found {
253
+ result[name] = value
254
+ }
255
+ }
256
+ return result
257
+ }