@pippit-dev/cli 1.0.42 → 1.0.43

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 (52) hide show
  1. package/README.md +42 -11
  2. package/checksums.txt +6 -6
  3. package/cmd/auth/auth.go +19 -5
  4. package/cmd/auth/auth_test.go +23 -0
  5. package/cmd/canvas/canvas.go +7 -7
  6. package/cmd/download_result.go +3 -2
  7. package/cmd/marketing.go +19 -3
  8. package/cmd/marketing_test.go +11 -5
  9. package/cmd/model.go +5 -4
  10. package/cmd/root.go +13 -5
  11. package/cmd/short_drama_test.go +2 -0
  12. package/cmd/source_test.go +27 -4
  13. package/cmd/space.go +199 -0
  14. package/cmd/space_test.go +469 -0
  15. package/cmd/upload_file_test.go +3 -1
  16. package/dist/checksums.txt +6 -6
  17. package/internal/adaptercli/commands_test.go +9 -5
  18. package/internal/auth/space.go +26 -0
  19. package/internal/auth/space_test.go +42 -0
  20. package/internal/canvas/get.go +9 -2
  21. package/internal/commandnames/names.go +9 -4
  22. package/internal/common/client.go +23 -0
  23. package/internal/common/host_source.go +5 -0
  24. package/internal/common/host_source_test.go +9 -1
  25. package/internal/common/runner.go +5 -3
  26. package/internal/common/space.go +185 -0
  27. package/internal/common/space_test.go +132 -0
  28. package/internal/common/submit_run.go +2 -0
  29. package/internal/common/upload_file.go +2 -0
  30. package/internal/config/config.go +15 -12
  31. package/internal/generate_image/generate_image.go +3 -9
  32. package/internal/short_drama/submit_run.go +3 -9
  33. package/internal/space/session.go +153 -0
  34. package/internal/space/store.go +211 -0
  35. package/internal/space/store_test.go +246 -0
  36. package/package.json +1 -1
  37. package/scripts/canvas-command.js +37 -4
  38. package/scripts/install-wizard.js +1 -1
  39. package/scripts/run.js +2 -2
  40. package/scripts/telemetry.js +3 -1
  41. package/skills/short-drama/SKILL.md +12 -1
  42. package/skills/xyq-marketing-skill/SKILL.md +22 -3
  43. package/skills/xyq-marketing-skill/references/api.md +7 -1
  44. package/skills/xyq-marketing-skill/scripts/marketing.js +42 -18
  45. package/skills/xyq-nest-skill/SKILL.md +15 -4
  46. package/skills/xyq-nest-skill/commands/canvas-assets.md +1 -1
  47. package/skills/xyq-nest-skill/commands/canvas.md +5 -3
  48. package/skills/xyq-nest-skill/commands/get-credit-balance.md +2 -2
  49. package/skills/xyq-nest-skill/commands/model.md +1 -1
  50. package/skills/xyq-nest-skill/scripts/ensure-cli.js +8 -1
  51. package/skills/xyq-nest-skill/scripts/install.md +1 -1
  52. package/skills/xyq-nest-skill/workflows/canvas-edit.md +2 -2
package/cmd/space.go ADDED
@@ -0,0 +1,199 @@
1
+ package cmd
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "io"
7
+
8
+ "github.com/Pippit-dev/pippit-cli/internal/commandnames"
9
+ "github.com/Pippit-dev/pippit-cli/internal/common"
10
+ "github.com/Pippit-dev/pippit-cli/internal/config"
11
+ "github.com/Pippit-dev/pippit-cli/internal/space"
12
+ "github.com/spf13/cobra"
13
+ )
14
+
15
+ func prepareCommandSpace(cmd *cobra.Command, runner *common.Runner, stderr io.Writer) error {
16
+ if runner == nil {
17
+ return nil
18
+ }
19
+ teamFlag := cmd.Flags().Lookup("team-id")
20
+ var explicit *string
21
+ if teamFlag != nil && teamFlag.Changed {
22
+ id, err := space.ParseTeamID(teamFlag.Value.String())
23
+ if err != nil {
24
+ return err
25
+ }
26
+ explicit = &id
27
+ }
28
+ top := cmd
29
+ for top.Parent() != nil && top.Parent().Parent() != nil {
30
+ top = top.Parent()
31
+ }
32
+ switch top.Name() {
33
+ case commandnames.Space, commandnames.Login, commandnames.Logout, commandnames.Status, commandnames.Update, commandnames.Completion, commandnames.Help, commandnames.DownloadResult:
34
+ return nil
35
+ }
36
+ raw, _ := cmd.Flags().GetString("space-context")
37
+ store, err := common.SpaceStoreForRunner(cmd.Context(), runner)
38
+ if err != nil {
39
+ if explicit == nil || raw != "" {
40
+ return err
41
+ }
42
+ if stderr != nil {
43
+ fmt.Fprintln(stderr, "本地空间记录不可用,将使用显式 --team-id;本次不保存空间记录。")
44
+ }
45
+ }
46
+ if store == nil && explicit == nil && raw == "" {
47
+ return nil
48
+ }
49
+ session := &space.Session{Store: store, Explicit: explicit, Stderr: stderr, Identity: func(ctx context.Context) (string, error) { return common.SpaceAccount(ctx, runner) }}
50
+ if raw != "" {
51
+ session.Expected, err = space.DecodeBinding(raw)
52
+ if err != nil {
53
+ return err
54
+ }
55
+ }
56
+ ctx := common.WithSpace(cmd.Context(), session)
57
+ cmd.SetContext(ctx)
58
+ // Business requests resolve their space from the final request body.
59
+ return nil
60
+ }
61
+
62
+ type listedSpace struct {
63
+ space.Selection
64
+ CurrentRole *int32 `json:"current_role,omitempty"`
65
+ Current bool `json:"current"`
66
+ }
67
+
68
+ func loadSpaces(ctx context.Context, runner *common.Runner, store *space.Store) ([]listedSpace, error) {
69
+ if store != nil {
70
+ ctx = common.WithSpace(ctx, &space.Session{Store: store, Identity: func(ctx context.Context) (string, error) { return common.SpaceAccount(ctx, runner) }})
71
+ }
72
+ result := []listedSpace{{Selection: space.Personal()}}
73
+ seenTeams := map[string]bool{"0": true}
74
+ seenCursors := map[string]bool{}
75
+ cursor := "0"
76
+ for {
77
+ if seenCursors[cursor] {
78
+ return nil, fmt.Errorf("团队列表分页游标重复,未保存空间选择")
79
+ }
80
+ seenCursors[cursor] = true
81
+ var response struct {
82
+ Ret string `json:"ret"`
83
+ Errmsg string `json:"errmsg"`
84
+ LogID string `json:"log_id"`
85
+ Data *struct {
86
+ Teams []struct {
87
+ TeamID string `json:"team_id"`
88
+ Name string `json:"team_name"`
89
+ Role *int32 `json:"current_role"`
90
+ } `json:"team_info_list"`
91
+ NextCursor string `json:"next_cursor"`
92
+ HasMore bool `json:"has_more"`
93
+ } `json:"data"`
94
+ }
95
+ if err := runner.Client.SendRequest(ctx, config.GetLiteTeamListPath, map[string]any{"cursor": cursor, "count": 100}, &response); err != nil {
96
+ return nil, fmt.Errorf("获取团队列表失败,默认空间未改变: %w", err)
97
+ }
98
+ if response.Ret != "0" {
99
+ return nil, common.NewLogIDError("获取团队列表失败: "+response.Errmsg, response.LogID)
100
+ }
101
+ if response.Data == nil {
102
+ return nil, fmt.Errorf("团队列表响应缺少 data")
103
+ }
104
+ for _, team := range response.Data.Teams {
105
+ id, err := space.ParseTeamID(team.TeamID)
106
+ if err != nil || id == "0" {
107
+ return nil, fmt.Errorf("团队列表返回无效 team_id")
108
+ }
109
+ if !seenTeams[id] {
110
+ result = append(result, listedSpace{Selection: space.Selection{TeamID: id, Name: team.Name}, CurrentRole: team.Role})
111
+ seenTeams[id] = true
112
+ }
113
+ }
114
+ if !response.Data.HasMore {
115
+ return result, nil
116
+ }
117
+ if response.Data.NextCursor == "" {
118
+ return nil, fmt.Errorf("团队列表未返回下一页游标,未保存空间选择")
119
+ }
120
+ cursor = response.Data.NextCursor
121
+ }
122
+ }
123
+
124
+ func newSpaceCommand(stdout io.Writer, runner *common.Runner) *cobra.Command {
125
+ root := &cobra.Command{
126
+ Use: commandnames.Space,
127
+ Short: "查看和切换个人或团队默认空间",
128
+ Args: cobra.NoArgs,
129
+ RunE: func(cmd *cobra.Command, _ []string) error {
130
+ return cmd.Help()
131
+ },
132
+ }
133
+ list := &cobra.Command{Use: "list", Args: cobra.NoArgs, Short: "列出个人和团队空间,标记当前选择", RunE: func(cmd *cobra.Command, _ []string) error {
134
+ store, err := common.SpaceStoreForRunner(cmd.Context(), runner)
135
+ if err != nil {
136
+ return err
137
+ }
138
+ var selected *space.Selection
139
+ if store != nil {
140
+ current, err := store.Current()
141
+ if err != nil {
142
+ return err
143
+ }
144
+ selected = &current
145
+ }
146
+ spaces, err := loadSpaces(cmd.Context(), runner, store)
147
+ if err != nil {
148
+ return err
149
+ }
150
+ for i := range spaces {
151
+ spaces[i].Current = selected != nil && spaces[i].TeamID == selected.TeamID
152
+ }
153
+ return common.WriteJSON(stdout, map[string]any{"spaces": spaces, "current": selected})
154
+ }}
155
+ use := &cobra.Command{Use: "use <TEAM_ID>", Args: cobra.ExactArgs(1), Short: "保存默认空间;0 为个人,正整数为团队", RunE: func(cmd *cobra.Command, args []string) error {
156
+ id, err := space.ParseTeamID(args[0])
157
+ if err != nil {
158
+ return err
159
+ }
160
+ store, err := common.SpaceStoreForRunner(cmd.Context(), runner)
161
+ if err != nil {
162
+ return err
163
+ }
164
+ if store == nil {
165
+ return fmt.Errorf("无法识别当前账号,不能保存默认空间")
166
+ }
167
+ selected := space.Personal()
168
+ if id != "0" {
169
+ spaces, err := loadSpaces(cmd.Context(), runner, store)
170
+ if err != nil {
171
+ return err
172
+ }
173
+ found := false
174
+ for _, item := range spaces {
175
+ if item.TeamID == id {
176
+ selected = item.Selection
177
+ found = true
178
+ break
179
+ }
180
+ }
181
+ if !found {
182
+ return fmt.Errorf("当前账号的团队列表中没有 %s,默认空间未改变", id)
183
+ }
184
+ }
185
+ account, err := common.SpaceAccount(cmd.Context(), runner)
186
+ if err != nil {
187
+ return err
188
+ }
189
+ if account != store.Account {
190
+ return fmt.Errorf("账号已改变,默认空间未保存;请重新选择")
191
+ }
192
+ if err := store.SaveDefault(selected); err != nil {
193
+ return err
194
+ }
195
+ return common.WriteJSON(stdout, selected)
196
+ }}
197
+ root.AddCommand(list, use)
198
+ return root
199
+ }
@@ -0,0 +1,469 @@
1
+ package cmd
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "io"
9
+ "net/http"
10
+ "net/http/httptest"
11
+ "os"
12
+ "path/filepath"
13
+ "strings"
14
+ "testing"
15
+ "time"
16
+
17
+ "github.com/Pippit-dev/pippit-cli/internal/adapter"
18
+ "github.com/Pippit-dev/pippit-cli/internal/auth"
19
+ canvascore "github.com/Pippit-dev/pippit-cli/internal/canvas"
20
+ "github.com/Pippit-dev/pippit-cli/internal/common"
21
+ "github.com/Pippit-dev/pippit-cli/internal/config"
22
+ "github.com/Pippit-dev/pippit-cli/internal/space"
23
+ )
24
+
25
+ func TestSpaceCommandsAndBusinessTaskScope(t *testing.T) {
26
+ t.Setenv("HOME", t.TempDir())
27
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
28
+ t.Setenv("LocalAppData", t.TempDir())
29
+ var requests []string
30
+ var requestRef space.References
31
+ listFailure := false
32
+ listTeamID := ""
33
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34
+ requests = append(requests, r.URL.Path+":"+r.Header.Get("X-Team-Id"))
35
+ w.Header().Set("Content-Type", "application/json")
36
+ if r.URL.Path == config.GetLiteTeamListPath {
37
+ if r.Header.Get("X-Team-Id") != listTeamID {
38
+ t.Errorf("team list header = %q, want %q", r.Header.Get("X-Team-Id"), listTeamID)
39
+ }
40
+ var body struct {
41
+ Cursor string `json:"cursor"`
42
+ Count int `json:"count"`
43
+ }
44
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
45
+ t.Error(err)
46
+ }
47
+ if body.Count != 100 {
48
+ t.Error("unexpected page size")
49
+ }
50
+ if listFailure {
51
+ _, _ = w.Write([]byte(`{"ret":"101","errmsg":"denied"}`))
52
+ return
53
+ }
54
+ if body.Cursor == "0" {
55
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"team_info_list":[{"team_id":"12","team_name":"A","current_role":1}],"has_more":true,"next_cursor":"next"}}`))
56
+ } else {
57
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"team_info_list":[{"team_id":"34","team_name":"B"}],"has_more":false}}`))
58
+ }
59
+ return
60
+ }
61
+ if r.URL.Path == config.UploadFilePath {
62
+ _, _ = io.Copy(io.Discard, r.Body)
63
+ } else {
64
+ requestRef = space.References{}
65
+ if err := json.NewDecoder(r.Body).Decode(&requestRef); err != nil {
66
+ t.Error(err)
67
+ }
68
+ }
69
+ switch r.URL.Path {
70
+ case canvascore.QueryPath:
71
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"Assets":[{"PippitAssetID":"canvas"}]}}`))
72
+ case config.SubmitRunPath:
73
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"run":{"thread_id":"thread-a","run_id":"run-a"}}}`))
74
+ case config.GetThreadPath:
75
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"readable_text":"task result"}}`))
76
+ case config.ListThreadFilePath:
77
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"files":[],"total":0}}`))
78
+ case config.UploadFilePath:
79
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"pippit_asset_id":"asset-a"}}`))
80
+ case marketingPaths["generate"]:
81
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"run":{"thread_id":"marketing-thread","run_id":"marketing-run"}}}`))
82
+ case marketingPaths["query"]:
83
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"thread_id":"marketing-thread","run_id":"marketing-run","run_state":2}}`))
84
+ case config.GetCreditBalancePath:
85
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"total_remain_amount":"100"}}`))
86
+ case config.GetAvailableModelListPath:
87
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"scene":"web_turbo_video_generator","config_key":"key","config":{"models":[{"key":"MiniMax-H3","name":"MiniMax","kind":"video","supported_ratio_list":[0,2,3],"default_ratio":3,"audio_total_limit":0}]}}}`))
88
+ default:
89
+ t.Errorf("unexpected request %s", r.URL.Path)
90
+ }
91
+ }))
92
+ defer api.Close()
93
+ cfg := config.Load()
94
+ cfg.BaseURL, cfg.AccessKey = api.URL, ""
95
+ runner := newRootRunner(cfg)
96
+ credentials := &marketingCredentialStore{credential: &auth.Credential{UID: "space-user", AccessKey: "space-test-key", ExpiredAt: time.Now().Add(time.Hour).Unix()}}
97
+ runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(credentials))
98
+ runner.SpaceStore = &space.Store{Dir: t.TempDir()}
99
+ store, err := common.SpaceStoreForRunner(context.Background(), runner)
100
+ if err != nil {
101
+ t.Fatal(err)
102
+ }
103
+ execute := func(args ...string) (string, error) {
104
+ var out, warnings bytes.Buffer
105
+ root := newRootCommand(&out, &warnings, runner)
106
+ root.SetIn(strings.NewReader(""))
107
+ root.SetArgs(args)
108
+ err := root.Execute()
109
+ return out.String(), err
110
+ }
111
+ must := func(args ...string) string {
112
+ t.Helper()
113
+ out, err := execute(args...)
114
+ if err != nil {
115
+ t.Fatalf("%v: %v", args, err)
116
+ }
117
+ return out
118
+ }
119
+ checkList := func(teamID string) {
120
+ t.Helper()
121
+ var result struct {
122
+ Spaces []listedSpace `json:"spaces"`
123
+ Current *space.Selection `json:"current"`
124
+ }
125
+ out := must("space", "list")
126
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
127
+ t.Fatal(err)
128
+ }
129
+ if result.Current == nil || result.Current.TeamID != teamID || len(result.Spaces) != 3 {
130
+ t.Fatalf("unexpected space list: %s", out)
131
+ }
132
+ for _, item := range result.Spaces {
133
+ if item.Current != (item.TeamID == teamID) {
134
+ t.Fatalf("wrong current marker: %+v", item)
135
+ }
136
+ }
137
+ }
138
+ checkList("0")
139
+ must("space", "use", "12")
140
+ listTeamID = "12"
141
+ checkList("12")
142
+ file := filepath.Join(t.TempDir(), "image.png")
143
+ if err := os.WriteFile(file, []byte("media"), 0600); err != nil {
144
+ t.Fatal(err)
145
+ }
146
+ if out := must("upload-file", "--path", file); !strings.Contains(out, `"team_id":"12"`) {
147
+ t.Fatal(out)
148
+ }
149
+ if out := must("short-drama", "+submit-run", "--message", "create", "--asset-ids", "asset-a"); !strings.Contains(out, `"team_id":"12"`) {
150
+ t.Fatal(out)
151
+ }
152
+ must("space", "use", "34")
153
+ listTeamID = "34"
154
+ checkList("34")
155
+ must("model", "list", "--refresh")
156
+ if requests[len(requests)-1] != config.GetAvailableModelListPath+":34" {
157
+ t.Fatal("model query did not forward the selected team", requests)
158
+ }
159
+ must("model", "list", "--refresh", "--team-id", "0")
160
+ if requests[len(requests)-1] != config.GetAvailableModelListPath+":" {
161
+ t.Fatal("personal model query carried a team header", requests)
162
+ }
163
+ must("get-thread", "--thread-id", "thread-a", "--run-id", "run-a")
164
+ if requests[len(requests)-1] != config.GetThreadPath+":12" {
165
+ t.Fatal("old task used new default", requests)
166
+ }
167
+ for _, tc := range []struct {
168
+ args []string
169
+ path string
170
+ }{
171
+ {[]string{"get-thread", "--thread-id", " thread-a ", "--run-id", " run-a "}, config.GetThreadPath},
172
+ {[]string{"list-thread-file", "--thread-id", " thread-a "}, config.ListThreadFilePath},
173
+ {[]string{"short-drama", "+submit-run", "--thread-id", " thread-a ", "--message", "continue"}, config.SubmitRunPath},
174
+ } {
175
+ must(tc.args...)
176
+ if requests[len(requests)-1] != tc.path+":12" || requestRef.ThreadID != "thread-a" {
177
+ t.Fatalf("normalized task used wrong space: %v, %+v", requests, requestRef)
178
+ }
179
+ if tc.path == config.GetThreadPath && requestRef.RunID != "run-a" {
180
+ t.Fatalf("run ID was not normalized: %+v", requestRef)
181
+ }
182
+ }
183
+ must("get-credit-balance")
184
+ if requests[len(requests)-1] != config.GetCreditBalancePath+":34" {
185
+ t.Fatal(requests)
186
+ }
187
+ must("get-credit-balance", "--team-id", "0")
188
+ if requests[len(requests)-1] != config.GetCreditBalancePath+":" {
189
+ t.Fatal(requests)
190
+ }
191
+ // Canvas uses the same default, override and cross-process binding as other commands.
192
+ for _, tc := range []struct {
193
+ flags []string
194
+ teamID string
195
+ }{
196
+ {nil, "34"},
197
+ {[]string{"--team-id", "12"}, "12"},
198
+ {[]string{"--team-id", "0"}, "0"},
199
+ } {
200
+ var result canvascore.GetResult
201
+ out := must(append([]string{"canvas", "get", "--asset-id", "canvas"}, tc.flags...)...)
202
+ if err := json.Unmarshal([]byte(out), &result); err != nil {
203
+ t.Fatal(err)
204
+ }
205
+ header := tc.teamID
206
+ if header == "0" {
207
+ header = ""
208
+ }
209
+ if result.TeamID != tc.teamID || requests[len(requests)-1] != canvascore.QueryPath+":"+header {
210
+ t.Fatalf("canvas space = %s, requests = %v", out, requests)
211
+ }
212
+ binding, err := space.DecodeBinding(result.SpaceContext)
213
+ if err != nil || binding.TeamID != tc.teamID || binding.Account != store.Account || binding.Origin != store.Origin {
214
+ t.Fatalf("canvas binding = %+v, %v", binding, err)
215
+ }
216
+ must("canvas", "get", "--asset-id", "canvas", "--space-context", result.SpaceContext)
217
+ if requests[len(requests)-1] != canvascore.QueryPath+":"+header {
218
+ t.Fatal("canvas binding did not preserve the original space", requests)
219
+ }
220
+ }
221
+ before := len(requests)
222
+ for _, args := range [][]string{
223
+ {"get-thread", "--thread-id", "thread-a", "--team-id", "34"},
224
+ {"short-drama", "+submit-run", "--message", "wrong asset", "--asset-ids", "asset-a"},
225
+ {"get-credit-balance", "--team-id", "-1"},
226
+ {"space", "use"},
227
+ {"space", "use", "12", "34"},
228
+ {"space", "current"},
229
+ } {
230
+ if _, err := execute(args...); err == nil {
231
+ t.Errorf("accepted %v", args)
232
+ }
233
+ }
234
+ if len(requests) != before {
235
+ t.Fatal("invalid operations reached API")
236
+ }
237
+ if _, err := execute("space", "use", "99"); err == nil {
238
+ t.Fatal("nonmember selection saved")
239
+ }
240
+ listFailure = true
241
+ if _, err := execute("space", "use", "12"); err == nil {
242
+ t.Fatal("list failure ignored")
243
+ }
244
+ if selected, err := store.Current(); err != nil || selected.TeamID != "34" {
245
+ t.Fatalf("failed selection changed default: %+v, %v", selected, err)
246
+ }
247
+ before = len(requests)
248
+ must("space", "use", "0")
249
+ listTeamID = ""
250
+ if len(requests) != before {
251
+ t.Fatal("personal switch depends on team list")
252
+ }
253
+ if selected, err := store.Current(); err != nil || selected.TeamID != "0" {
254
+ t.Fatalf("personal selection not saved: %+v, %v", selected, err)
255
+ }
256
+ listFailure = false
257
+ checkList("0")
258
+ before = len(requests)
259
+ // Marketing preview resolves an existing thread before reading the new default.
260
+ if err := store.SaveDefault(space.Selection{TeamID: "34", Name: "B"}); err != nil {
261
+ t.Fatal(err)
262
+ }
263
+ requestFile := filepath.Join(t.TempDir(), "request.json")
264
+ if err := os.WriteFile(requestFile, []byte(`{"thread_id":"thread-a","message":"continue","general_agent_settings":{"video_model":"model"}}`), 0600); err != nil {
265
+ t.Fatal(err)
266
+ }
267
+ loadsBefore := credentials.loads
268
+ if out := must("marketing", "generate", "--request", requestFile); !strings.Contains(out, `"team_id":"12"`) {
269
+ t.Fatal(out)
270
+ }
271
+ if credentials.loads-loadsBefore != 2 {
272
+ t.Fatal("preview must read identity at initialization and check it once")
273
+ }
274
+ if len(requests) != before {
275
+ t.Fatal("preview contacted API")
276
+ }
277
+ if err := os.WriteFile(requestFile, []byte(`{"message":"new marketing task","general_agent_settings":{"video_model":"model"}}`), 0600); err != nil {
278
+ t.Fatal(err)
279
+ }
280
+ loadsBefore = credentials.loads
281
+ if out := must("marketing", "generate", "--request", requestFile, "--execute"); !strings.Contains(out, `"team_id":"34"`) || !strings.Contains(out, `"space_context"`) {
282
+ t.Fatal(out)
283
+ }
284
+ if credentials.loads-loadsBefore != 2 {
285
+ t.Fatal("submission must read identity at initialization and check it only at the HTTP boundary")
286
+ }
287
+ if requests[len(requests)-1] != marketingPaths["generate"]+":34" {
288
+ t.Fatal("independent marketing client lost scope", requests)
289
+ }
290
+ if err := store.SaveDefault(space.Selection{TeamID: "12", Name: "A"}); err != nil {
291
+ t.Fatal(err)
292
+ }
293
+ must("marketing", "query", "--thread-id", "marketing-thread", "--run-id", "marketing-run")
294
+ if requests[len(requests)-1] != marketingPaths["query"]+":34" {
295
+ t.Fatal("marketing task lost scope", requests)
296
+ }
297
+ }
298
+
299
+ type spaceExternalAuthAdapter struct{ adapter.Default }
300
+
301
+ func (spaceExternalAuthAdapter) CLIOptions() adapter.CLIOptions {
302
+ options := adapter.Default{}.CLIOptions()
303
+ options.LocalCredentialsEnabled = false
304
+ return options
305
+ }
306
+
307
+ func (spaceExternalAuthAdapter) PrepareAPIRequest(_ context.Context, req *http.Request) (adapter.AuthDecision, error) {
308
+ req.Header.Set("Authorization", "Bearer adapter-key")
309
+ return adapter.AuthProvided, nil
310
+ }
311
+
312
+ type spaceUnavailableAccount struct{ common.AuthManager }
313
+
314
+ func (spaceUnavailableAccount) SpaceAccount(context.Context) (string, error) {
315
+ return "", errors.New("credential metadata unavailable")
316
+ }
317
+
318
+ func TestSpaceUsesAvailableIdentityInsteadOfLocalLoginSwitch(t *testing.T) {
319
+ var requests []string
320
+ denied := false
321
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
322
+ if r.Header.Get("Authorization") != "Bearer adapter-key" {
323
+ t.Error("adapter authentication was not used")
324
+ }
325
+ requests = append(requests, r.URL.Path+":"+r.Header.Get("X-Team-Id"))
326
+ switch r.URL.Path {
327
+ case config.GetLiteTeamListPath:
328
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"team_info_list":[{"team_id":"12","team_name":"A"}],"has_more":false}}`))
329
+ case config.GetCreditBalancePath:
330
+ if denied {
331
+ _, _ = w.Write([]byte(`{"ret":"101","errmsg":"denied"}`))
332
+ return
333
+ }
334
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"total_remain_amount":"100"}}`))
335
+ case config.UploadFilePath:
336
+ _, _ = io.Copy(io.Discard, r.Body)
337
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"pippit_asset_id":"asset-a"}}`))
338
+ case config.SubmitRunPath:
339
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"run":{"thread_id":"thread-a","run_id":"run-a"}}}`))
340
+ case config.GetThreadPath:
341
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"readable_text":"task result"}}`))
342
+ case config.QueryGenerateVideoResultPath:
343
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"thread_id":"thread-a","run_id":"run-a","run_state":2}}`))
344
+ default:
345
+ t.Errorf("unexpected request: %s", r.URL.Path)
346
+ }
347
+ }))
348
+ defer api.Close()
349
+ cfg := config.Load()
350
+ // This AK is not used by the adapter and must not supply a false identity.
351
+ cfg.BaseURL, cfg.AccessKey = api.URL, "unrelated-environment-key"
352
+ policy := spaceExternalAuthAdapter{}
353
+ runner := newRootRunnerWithAdapter(cfg, policy)
354
+ dir := filepath.Join(t.TempDir(), "space-records")
355
+ runner.SpaceStore = &space.Store{Dir: dir}
356
+ execute := func(args ...string) (string, error) {
357
+ var out bytes.Buffer
358
+ root := newRootCommandWithAdapter(&out, io.Discard, runner, policy)
359
+ root.SetArgs(args)
360
+ err := root.Execute()
361
+ return out.String(), err
362
+ }
363
+ out, err := execute("space", "list")
364
+ if err != nil || !strings.Contains(out, `"team_id":"12"`) || !strings.Contains(out, `"current":null`) {
365
+ t.Fatalf("space list without local identity = %s, %v", out, err)
366
+ }
367
+ if requests[len(requests)-1] != config.GetLiteTeamListPath+":" {
368
+ t.Fatal("team list used a selected team", requests)
369
+ }
370
+ before := len(requests)
371
+ for _, args := range [][]string{
372
+ {"space", "use", "0"}, {"space", "use", "12"},
373
+ } {
374
+ if _, err := execute(args...); err == nil || !strings.Contains(err.Error(), "无法识别当前账号") {
375
+ t.Fatalf("%v: %v", args, err)
376
+ }
377
+ }
378
+ if len(requests) != before {
379
+ t.Fatal("unknown identity tried to persist a default space")
380
+ }
381
+ for _, teamID := range []string{"0", "12", "34"} {
382
+ if _, err := execute("get-credit-balance", "--team-id", teamID); err != nil {
383
+ t.Fatal(err)
384
+ }
385
+ wantHeader := teamID
386
+ if teamID == "0" {
387
+ wantHeader = ""
388
+ }
389
+ if requests[len(requests)-1] != config.GetCreditBalancePath+":"+wantHeader {
390
+ t.Fatal("explicit space was not forwarded", requests)
391
+ }
392
+ }
393
+ file := filepath.Join(t.TempDir(), "image.png")
394
+ if err := os.WriteFile(file, []byte("image"), 0600); err != nil {
395
+ t.Fatal(err)
396
+ }
397
+ for _, tc := range []struct {
398
+ args []string
399
+ path string
400
+ }{
401
+ {[]string{"upload-file", "--path", file}, config.UploadFilePath},
402
+ {[]string{"short-drama", "+submit-run", "--message", "test", "--asset-ids", "asset-a"}, config.SubmitRunPath},
403
+ {[]string{"get-thread", "--thread-id", "thread-a"}, config.GetThreadPath},
404
+ {[]string{"marketing", "query", "--thread-id", "thread-a", "--run-id", "run-a"}, config.QueryGenerateVideoResultPath},
405
+ } {
406
+ if _, err := execute(append(tc.args, "--team-id", "12")...); err != nil {
407
+ t.Fatalf("%v: %v", tc.args, err)
408
+ }
409
+ if requests[len(requests)-1] != tc.path+":12" {
410
+ t.Fatal("explicit task space was not forwarded", requests)
411
+ }
412
+ }
413
+ before = len(requests)
414
+ denied = true
415
+ if _, err := execute("get-credit-balance", "--team-id", "12"); err == nil || !strings.Contains(err.Error(), "denied") {
416
+ t.Fatalf("server denial was not returned: %v", err)
417
+ }
418
+ denied = false
419
+ if len(requests) != before+1 || requests[len(requests)-1] != config.GetCreditBalancePath+":12" {
420
+ t.Fatal("server denial retried or switched spaces", requests)
421
+ }
422
+ if _, err := os.Stat(dir); !os.IsNotExist(err) {
423
+ t.Fatalf("unknown identity created local space records: %v", err)
424
+ }
425
+ if _, err := execute("get-credit-balance"); err != nil {
426
+ t.Fatal("ordinary adapter requests must remain available", err)
427
+ }
428
+ // Metadata lookup failure must not block an explicitly scoped API request.
429
+ cfg.AccessKey = ""
430
+ runner.Auth = spaceUnavailableAccount{}
431
+ if _, err := execute("get-credit-balance", "--team-id", "12"); err != nil {
432
+ t.Fatal("local metadata failure blocked adapter authentication", err)
433
+ }
434
+ if requests[len(requests)-1] != config.GetCreditBalancePath+":12" {
435
+ t.Fatal("metadata failure dropped explicit space", requests)
436
+ }
437
+ if _, err := execute("get-credit-balance"); err == nil {
438
+ t.Fatal("implicit space silently ignored metadata failure")
439
+ }
440
+ // With a known matching account, the login-command switch must not suppress
441
+ // saved defaults or their use by business requests.
442
+ cfg.AccessKey = "adapter-key"
443
+ runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(&marketingCredentialStore{credential: &auth.Credential{UID: "adapter-user", AccessKey: "adapter-key"}}))
444
+ if _, err := execute("space", "use", "12"); err != nil {
445
+ t.Fatal(err)
446
+ }
447
+ out, err = execute("space", "list")
448
+ if err != nil {
449
+ t.Fatal(err)
450
+ }
451
+ var result struct {
452
+ Current *space.Selection `json:"current"`
453
+ }
454
+ if err := json.Unmarshal([]byte(out), &result); err != nil || result.Current == nil || result.Current.TeamID != "12" {
455
+ t.Fatalf("space list = %s, %v", out, err)
456
+ }
457
+ if _, err := execute("get-credit-balance"); err != nil {
458
+ t.Fatal(err)
459
+ }
460
+ if requests[len(requests)-1] != config.GetCreditBalancePath+":12" {
461
+ t.Fatal("saved default was ignored", requests)
462
+ }
463
+ if _, err := execute("get-credit-balance", "--team-id", "0"); err != nil {
464
+ t.Fatal(err)
465
+ }
466
+ if requests[len(requests)-1] != config.GetCreditBalancePath+":" {
467
+ t.Fatal("personal override was ignored", requests)
468
+ }
469
+ }
@@ -2,6 +2,7 @@ package cmd
2
2
 
3
3
  import (
4
4
  "bytes"
5
+ "encoding/json"
5
6
  "io"
6
7
  "net/http"
7
8
  "net/http/httptest"
@@ -77,7 +78,8 @@ func TestUploadFileMediaContract(t *testing.T) {
77
78
  if err := root.Execute(); err != nil {
78
79
  t.Fatal(err)
79
80
  }
80
- if stdout.String() != "{\"asset_id\":\"asset_original\"}\n" || calls != 1 {
81
+ var output map[string]string
82
+ if json.Unmarshal(stdout.Bytes(), &output) != nil || output["asset_id"] != "asset_original" || output["team_id"] != "0" || calls != 1 {
81
83
  t.Fatalf("stdout = %q, requests = %d", stdout.String(), calls)
82
84
  }
83
85
  })