@pippit-dev/cli 1.0.41 → 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 (61) 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 +17 -5
  11. package/cmd/root_update_check_test.go +139 -0
  12. package/cmd/short_drama_test.go +2 -0
  13. package/cmd/source_test.go +27 -4
  14. package/cmd/space.go +199 -0
  15. package/cmd/space_test.go +469 -0
  16. package/cmd/upload_file_test.go +3 -1
  17. package/dist/checksums.txt +6 -6
  18. package/internal/adaptercli/commands_test.go +9 -5
  19. package/internal/auth/loopback.go +16 -11
  20. package/internal/auth/loopback_origins_test.go +152 -0
  21. package/internal/auth/space.go +26 -0
  22. package/internal/auth/space_test.go +42 -0
  23. package/internal/canvas/get.go +9 -2
  24. package/internal/commandnames/names.go +9 -4
  25. package/internal/common/client.go +23 -0
  26. package/internal/common/host_source.go +5 -0
  27. package/internal/common/host_source_test.go +9 -1
  28. package/internal/common/runner.go +5 -3
  29. package/internal/common/space.go +185 -0
  30. package/internal/common/space_test.go +132 -0
  31. package/internal/common/submit_run.go +2 -0
  32. package/internal/common/upload_file.go +2 -0
  33. package/internal/config/config.go +15 -12
  34. package/internal/generate_image/generate_image.go +3 -9
  35. package/internal/short_drama/submit_run.go +3 -9
  36. package/internal/space/session.go +153 -0
  37. package/internal/space/store.go +211 -0
  38. package/internal/space/store_test.go +246 -0
  39. package/internal/version/version.go +13 -0
  40. package/internal/version/version_test.go +38 -0
  41. package/internal/versioncheck/check.go +147 -0
  42. package/internal/versioncheck/check_test.go +204 -0
  43. package/internal/versioncheck/semver.go +84 -0
  44. package/package.json +1 -1
  45. package/scripts/canvas-command.js +47 -5
  46. package/scripts/install-wizard.js +1 -1
  47. package/scripts/run.js +15 -4
  48. package/scripts/telemetry.js +3 -1
  49. package/scripts/version-check.js +8 -1
  50. package/skills/short-drama/SKILL.md +12 -1
  51. package/skills/xyq-marketing-skill/SKILL.md +22 -3
  52. package/skills/xyq-marketing-skill/references/api.md +7 -1
  53. package/skills/xyq-marketing-skill/scripts/marketing.js +51 -19
  54. package/skills/xyq-nest-skill/SKILL.md +15 -4
  55. package/skills/xyq-nest-skill/commands/canvas-assets.md +1 -1
  56. package/skills/xyq-nest-skill/commands/canvas.md +5 -3
  57. package/skills/xyq-nest-skill/commands/get-credit-balance.md +2 -2
  58. package/skills/xyq-nest-skill/commands/model.md +1 -1
  59. package/skills/xyq-nest-skill/scripts/ensure-cli.js +8 -1
  60. package/skills/xyq-nest-skill/scripts/install.md +1 -1
  61. package/skills/xyq-nest-skill/workflows/canvas-edit.md +2 -2
@@ -0,0 +1,132 @@
1
+ package common
2
+
3
+ import (
4
+ "context"
5
+ "io"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "os"
9
+ "path/filepath"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/Pippit-dev/pippit-cli/internal/adapter"
14
+ "github.com/Pippit-dev/pippit-cli/internal/config"
15
+ "github.com/Pippit-dev/pippit-cli/internal/space"
16
+ )
17
+
18
+ type spaceRewriteAdapter struct{ adapter.Default }
19
+
20
+ func (spaceRewriteAdapter) PrepareAPIRequest(_ context.Context, r *http.Request) (adapter.AuthDecision, error) {
21
+ r.URL.Path = "/rewritten" + r.URL.Path
22
+ return adapter.UseDefaultAuth, nil
23
+ }
24
+
25
+ func TestSpaceHeadersAndTaskRecoveryThroughHTTP(t *testing.T) {
26
+ var headers []string
27
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28
+ headers = append(headers, r.Header.Get("X-Team-Id"))
29
+ if r.Header.Get("Authorization") != "Bearer test-key" {
30
+ t.Error("missing authorization")
31
+ }
32
+ _, _ = io.Copy(io.Discard, r.Body)
33
+ switch r.URL.Path {
34
+ case config.UploadFilePath:
35
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"pippit_asset_id":"asset"}}`))
36
+ case config.SubmitRunPath:
37
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"run":{"thread_id":"thread","run_id":"run"}}}`))
38
+ default:
39
+ _, _ = w.Write([]byte(`{"ret":"0","data":{}}`))
40
+ }
41
+ }))
42
+ defer api.Close()
43
+ store := &space.Store{Dir: t.TempDir(), Account: "account", Origin: api.URL}
44
+ if err := store.SaveDefault(space.Selection{TeamID: "12", Name: "A"}); err != nil {
45
+ t.Fatal(err)
46
+ }
47
+ account, identityChecks := store.Account, 0
48
+ ctx := WithSpace(context.Background(), &space.Session{Store: store, Identity: func(context.Context) (string, error) {
49
+ identityChecks++
50
+ return account, nil
51
+ }})
52
+ client := NewHTTPClient(api.URL, time.Second, NewAccessKeyAuthorizer("test-key"))
53
+ file := filepath.Join(t.TempDir(), "image.png")
54
+ if err := os.WriteFile(file, []byte("image"), 0600); err != nil {
55
+ t.Fatal(err)
56
+ }
57
+ var result map[string]any
58
+ if err := client.SendMultipartRequest(ctx, config.UploadFilePath, nil, MultipartFile{FieldName: "file", Path: file}, &result); err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ if identityChecks != 1 {
62
+ t.Fatalf("identity checks per request = %d, want 1", identityChecks)
63
+ }
64
+ if err := store.SaveDefault(space.Selection{TeamID: "34", Name: "B"}); err != nil {
65
+ t.Fatal(err)
66
+ }
67
+ if err := client.SendRequest(ctx, config.SubmitRunPath, map[string]any{"asset_ids": []string{"asset"}}, &result); err != nil {
68
+ t.Fatal(err)
69
+ }
70
+ // A new command recovers the original task even after a saved-space switch.
71
+ queryCtx := WithSpace(context.Background(), &space.Session{Store: store})
72
+ if err := client.SendRequest(queryCtx, api.URL+config.GetThreadPath, space.References{ThreadID: "thread", RunID: "run"}, &result); err != nil {
73
+ t.Fatal(err)
74
+ }
75
+ if err := client.SendRequestWithHeaders(ctx, config.GetAvailableModelListPath, nil, map[string]string{"X-Team-Id": "999"}, &result); err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ zero := "0"
79
+ personal := WithSpace(context.Background(), &space.Session{Store: store, Explicit: &zero})
80
+ if err := client.SendRequest(personal, config.GetCreditBalancePath, struct{}{}, &result); err != nil {
81
+ t.Fatal(err)
82
+ }
83
+ for i, want := range []string{"12", "12", "12", "12", ""} {
84
+ if headers[i] != want {
85
+ t.Errorf("request %d team = %q, want %q", i, headers[i], want)
86
+ }
87
+ }
88
+ other := "34"
89
+ conflict := WithSpace(context.Background(), &space.Session{Store: store, Explicit: &other})
90
+ if err := client.SendRequest(conflict, config.GetThreadPath, space.References{ThreadID: "thread", RunID: "run"}, &result); err == nil {
91
+ t.Fatal("conflict sent")
92
+ }
93
+ if len(headers) != 5 {
94
+ t.Fatal("conflict reached API")
95
+ }
96
+ // A new API path still receives the selected space without a client allowlist.
97
+ unlisted := WithSpace(context.Background(), &space.Session{Store: store})
98
+ if err := client.SendRequest(unlisted, "/api/unlisted", struct{}{}, &result); err != nil {
99
+ t.Fatal(err)
100
+ }
101
+ if headers[len(headers)-1] != "34" {
102
+ t.Fatal("unlisted API did not receive the saved team")
103
+ }
104
+ foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105
+ if r.Header.Get("X-Team-Id") != "" || r.Header.Get("Authorization") != "" {
106
+ t.Error("foreign origin received sensitive headers")
107
+ }
108
+ _, _ = w.Write([]byte(`{}`))
109
+ }))
110
+ defer foreign.Close()
111
+ // A matching path on another origin must bypass both scope lookup and headers.
112
+ if err := client.SendRequestWithHeaders(conflict, foreign.URL+config.GetThreadPath, space.References{ThreadID: "thread", RunID: "run"}, map[string]string{"X-Team-Id": "999"}, &result); err != nil {
113
+ t.Fatal(err)
114
+ }
115
+ // Path rewriting by an adapter must preserve the original operation's scope.
116
+ rewritten := NewHTTPClientWithAdapter(api.URL, time.Second, NewAccessKeyAuthorizer("test-key"), spaceRewriteAdapter{})
117
+ if err := rewritten.SendRequest(ctx, config.GetCreditBalancePath, struct{}{}, &result); err != nil {
118
+ t.Fatal(err)
119
+ }
120
+ if headers[len(headers)-1] != "12" {
121
+ t.Fatal("adapter dropped space")
122
+ }
123
+ // Even with a frozen space, a changed account must stop before sending.
124
+ account = "other-account"
125
+ before := len(headers)
126
+ if err := client.SendRequest(ctx, config.GetCreditBalancePath, struct{}{}, &result); err == nil {
127
+ t.Fatal("account switch accepted")
128
+ }
129
+ if len(headers) != before {
130
+ t.Fatal("account switch reached API")
131
+ }
132
+ }
@@ -15,6 +15,7 @@ const AgentNameVideoPart = "pippit_video_part_agent"
15
15
 
16
16
  // SubmitRunResult is the shared successful submit_run output.
17
17
  type SubmitRunResult struct {
18
+ TeamID string `json:"team_id"`
18
19
  ThreadID string `json:"thread_id"`
19
20
  RunID string `json:"run_id"`
20
21
  WebThreadLink string `json:"web_thread_link"`
@@ -86,6 +87,7 @@ func submitRun(ctx context.Context, command, path string, body any, runner *Runn
86
87
  }
87
88
 
88
89
  return &SubmitRunResult{
90
+ TeamID: SpaceTeamID(ctx),
89
91
  ThreadID: resp.Data.Run.ThreadID,
90
92
  RunID: resp.Data.Run.RunID,
91
93
  WebThreadLink: resp.Data.WebThreadLink,
@@ -20,6 +20,7 @@ type UploadFileOptions struct {
20
20
 
21
21
  // UploadFileResult is the shared JSON output for media and short-drama uploads.
22
22
  type UploadFileResult struct {
23
+ TeamID string `json:"team_id"`
23
24
  AssetID string `json:"asset_id"`
24
25
  }
25
26
 
@@ -102,6 +103,7 @@ func UploadFile(ctx context.Context, opts UploadFileOptions, runner *Runner) (*U
102
103
  }
103
104
 
104
105
  return &UploadFileResult{
106
+ TeamID: SpaceTeamID(ctx),
105
107
  AssetID: assetID,
106
108
  }, nil
107
109
  }
@@ -12,18 +12,21 @@ import (
12
12
  )
13
13
 
14
14
  const (
15
- DefaultBaseURL = "https://xyq.jianying.com"
16
- DefaultHTTPTimeout = 30 * time.Minute
17
- DefaultAuthTTL = 30 * time.Second
18
- DefaultAuthStoreServiceName = "pippit-cli"
19
- SubmitRunPath = "/api/biz/v1/skill/submit_run"
20
- GetCreditBalancePath = "/api/biz/v1/skill/get_credit_balance"
21
- GetAvailableModelListPath = "/api/biz/v1/skill/get_available_model_list"
22
- GetThreadPath = "/api/biz/v1/skill/get_thread"
23
- UploadFilePath = "/api/biz/v1/skill/upload_file"
24
- ListThreadFilePath = "/api/biz/v1/skill/list_thread_file"
25
- EnvXYQAccessKey = "XYQ_ACCESS_KEY"
26
- FileName = "config.toml"
15
+ DefaultBaseURL = "https://xyq.jianying.com"
16
+ DefaultHTTPTimeout = 30 * time.Minute
17
+ DefaultAuthTTL = 30 * time.Second
18
+ DefaultAuthStoreServiceName = "pippit-cli"
19
+ SubmitRunPath = "/api/biz/v1/skill/submit_run"
20
+ SubmitMarketingRunPath = "/api/biz/v1/agent/submit_marketing_run"
21
+ QueryGenerateVideoResultPath = "/api/biz/v1/agent/query_generate_video_result"
22
+ GetCreditBalancePath = "/api/biz/v1/skill/get_credit_balance"
23
+ GetLiteTeamListPath = "/api/biz/v1/skill/get_lite_team_list"
24
+ GetAvailableModelListPath = "/api/biz/v1/skill/get_available_model_list"
25
+ GetThreadPath = "/api/biz/v1/skill/get_thread"
26
+ UploadFilePath = "/api/biz/v1/skill/upload_file"
27
+ ListThreadFilePath = "/api/biz/v1/skill/list_thread_file"
28
+ EnvXYQAccessKey = "XYQ_ACCESS_KEY"
29
+ FileName = "config.toml"
27
30
  )
28
31
 
29
32
  // Config holds runtime settings selected by the root command and passed down
@@ -42,14 +42,7 @@ type generalAgentSettings struct {
42
42
  GenerateImageCount *int `json:"generate_image_count,omitempty"`
43
43
  }
44
44
 
45
- // Result is the JSON envelope printed by `pippit-tool-cli generate-image`.
46
- type Result struct {
47
- ThreadID string `json:"thread_id"`
48
- RunID string `json:"run_id"`
49
- WebThreadLink string `json:"web_thread_link"`
50
- }
51
-
52
- func Run(ctx context.Context, opts *Options, runner *common.Runner) (*Result, error) {
45
+ func Run(ctx context.Context, opts *Options, runner *common.Runner) (*common.SubmitRunResult, error) {
53
46
  if runner == nil || runner.Client == nil {
54
47
  return nil, fmt.Errorf("generate-image 运行器客户端缺失")
55
48
  }
@@ -89,7 +82,8 @@ func Run(ctx context.Context, opts *Options, runner *common.Runner) (*Result, er
89
82
  return nil, fmt.Errorf("generate-image 响应缺少 data.run.run_id")
90
83
  }
91
84
 
92
- return &Result{
85
+ return &common.SubmitRunResult{
86
+ TeamID: common.SpaceTeamID(ctx),
93
87
  ThreadID: resp.Data.Run.ThreadID,
94
88
  RunID: resp.Data.Run.RunID,
95
89
  WebThreadLink: resp.Data.WebThreadLink,
@@ -15,14 +15,7 @@ type SubmitRunOptions struct {
15
15
  AssetIDs []string `json:"asset_ids,omitempty"`
16
16
  }
17
17
 
18
- // SubmitRunResult is the JSON envelope printed by `pippit-tool-cli short-drama +submit-run`.
19
- type SubmitRunResult struct {
20
- ThreadID string `json:"thread_id"`
21
- RunID string `json:"run_id"`
22
- WebThreadLink string `json:"web_thread_link"`
23
- }
24
-
25
- func SubmitRun(ctx context.Context, opts *SubmitRunOptions, runner *common.Runner) (*SubmitRunResult, error) {
18
+ func SubmitRun(ctx context.Context, opts *SubmitRunOptions, runner *common.Runner) (*common.SubmitRunResult, error) {
26
19
  if runner == nil || runner.Client == nil {
27
20
  return nil, fmt.Errorf("submit_run 运行器客户端缺失")
28
21
  }
@@ -55,7 +48,8 @@ func SubmitRun(ctx context.Context, opts *SubmitRunOptions, runner *common.Runne
55
48
  if resp.Data.Run.RunID == "" {
56
49
  return nil, fmt.Errorf("short_drama 响应缺少 data.run.run_id")
57
50
  }
58
- return &SubmitRunResult{
51
+ return &common.SubmitRunResult{
52
+ TeamID: common.SpaceTeamID(ctx),
59
53
  ThreadID: resp.Data.Run.ThreadID,
60
54
  RunID: resp.Data.Run.RunID,
61
55
  WebThreadLink: resp.Data.WebThreadLink,
@@ -0,0 +1,153 @@
1
+ package space
2
+
3
+ import (
4
+ "context"
5
+ "encoding/base64"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ )
10
+
11
+ // Session is one command's immutable account/origin/team choice. It is not an
12
+ // authorization grant: every request still uses the current credential.
13
+ // Store is optional when a command explicitly selects its space.
14
+ type Session struct {
15
+ Store *Store
16
+ Explicit *string
17
+ Expected *Binding
18
+ Identity func(context.Context) (string, error)
19
+ Stderr io.Writer
20
+ selected *Selection
21
+ }
22
+
23
+ func DecodeBinding(raw string) (*Binding, error) {
24
+ data, err := base64.RawURLEncoding.DecodeString(raw)
25
+ var binding Binding
26
+ if err != nil || json.Unmarshal(data, &binding) != nil {
27
+ return nil, fmt.Errorf("space-context 无效")
28
+ }
29
+ if _, err := ParseTeamID(binding.TeamID); err != nil {
30
+ return nil, err
31
+ }
32
+ return &binding, nil
33
+ }
34
+
35
+ func (s *Session) CheckIdentity(ctx context.Context) error {
36
+ if s.Store == nil || s.Identity == nil {
37
+ return nil
38
+ }
39
+ account, err := s.Identity(ctx)
40
+ if err != nil {
41
+ return err
42
+ }
43
+ if account != s.Store.Account {
44
+ return fmt.Errorf("当前账号已改变,已停止原空间的操作;请切回原账号后查询原任务")
45
+ }
46
+ return nil
47
+ }
48
+
49
+ // Resolve selects and freezes the command's space, checking each request's
50
+ // records even after freezing. CheckIdentity belongs at the request boundary.
51
+ func (s *Session) Resolve(ref References) (Selection, error) {
52
+ explicit := s.Explicit
53
+ fixed := s.selected
54
+ if s.Expected != nil {
55
+ if s.Store == nil {
56
+ return Selection{}, fmt.Errorf("无法获取当前账号的空间记录,不能校验 --space-context")
57
+ }
58
+ if s.Expected.Account != s.Store.Account || s.Expected.Origin != s.Store.Origin {
59
+ return Selection{}, fmt.Errorf("账号或 API origin 与原任务不一致,已停止操作")
60
+ }
61
+ if explicit != nil && *explicit != s.Expected.TeamID {
62
+ return Selection{}, fmt.Errorf("--team-id 与固定任务空间冲突")
63
+ }
64
+ if fixed == nil {
65
+ fixed = &s.Expected.Selection
66
+ }
67
+ }
68
+ var records []record
69
+ var unreadable error
70
+ if s.Store != nil {
71
+ records, unreadable = s.Store.loadReferences(ref)
72
+ }
73
+ var task *Selection
74
+ for i := range records {
75
+ r := &records[i]
76
+ if r.Kind == "asset" {
77
+ continue
78
+ }
79
+ if task != nil && task.TeamID != r.TeamID {
80
+ return Selection{}, fmt.Errorf("thread/run 空间记录冲突;请先核实并修复本地记录")
81
+ }
82
+ task = &r.Selection
83
+ }
84
+ // Only explicit input permits recovery; a frozen choice does not.
85
+ if unreadable != nil && explicit == nil {
86
+ return Selection{}, fmt.Errorf("%w;核实原空间后用 --team-id <ID> 查询恢复,不要重新提交生成", unreadable)
87
+ }
88
+
89
+ // Keep selection priority in one place, whether or not a Store is available.
90
+ selected := Personal()
91
+ if fixed != nil {
92
+ selected = *fixed
93
+ } else if explicit != nil {
94
+ teamID, err := ParseTeamID(*explicit)
95
+ if err != nil {
96
+ return Selection{}, err
97
+ }
98
+ if teamID != "0" {
99
+ selected = Selection{TeamID: teamID, Name: "团队空间"}
100
+ }
101
+ } else if task != nil {
102
+ selected = *task
103
+ } else if s.Store != nil {
104
+ var err error
105
+ selected, err = s.Store.Current()
106
+ if err != nil {
107
+ return Selection{}, err
108
+ }
109
+ }
110
+ if task != nil && task.TeamID != selected.TeamID {
111
+ return Selection{}, fmt.Errorf("任务属于空间 %s,与本次空间 %s 冲突;请使用 --team-id %s 查询", task.TeamID, selected.TeamID, task.TeamID)
112
+ }
113
+ for _, r := range records {
114
+ if r.Kind == "asset" && r.TeamID != selected.TeamID {
115
+ return Selection{}, fmt.Errorf("素材属于空间 %s,与本次空间 %s 冲突;请使用相同空间的素材", r.TeamID, selected.TeamID)
116
+ }
117
+ }
118
+ if s.selected == nil {
119
+ s.selected = &selected
120
+ if s.Stderr != nil {
121
+ account := "个人"
122
+ if selected.TeamID != "0" {
123
+ account = "团队"
124
+ }
125
+ fmt.Fprintf(s.Stderr, "本次空间:%s(team_id=%s);积分账户:%s\n", selected.Name, selected.TeamID, account)
126
+ }
127
+ }
128
+ return *s.selected, nil
129
+ }
130
+
131
+ func (s *Session) TeamID() string {
132
+ if s == nil || s.selected == nil {
133
+ return "0"
134
+ }
135
+ return s.selected.TeamID
136
+ }
137
+
138
+ func (s *Session) Token() string {
139
+ if s == nil || s.selected == nil || s.Store == nil {
140
+ return ""
141
+ }
142
+ data, _ := json.Marshal(Binding{Selection: Selection{TeamID: s.TeamID()}, Account: s.Store.Account, Origin: s.Store.Origin})
143
+ return base64.RawURLEncoding.EncodeToString(data)
144
+ }
145
+
146
+ func (s *Session) Remember(ref References) {
147
+ if s == nil || s.selected == nil || s.Store == nil || s.Store.Account == "" {
148
+ return
149
+ }
150
+ if err := s.Store.Remember(ref, *s.selected); err != nil && s.Stderr != nil {
151
+ fmt.Fprintf(s.Stderr, "请求已成功,但空间记录保存失败:%v;保留 thread_id=%s run_id=%s asset_ids=%v,后续使用 --team-id %s 查询,不要重复提交。\n", err, ref.ThreadID, ref.RunID, ref.AssetIDs, s.TeamID())
152
+ }
153
+ }
@@ -0,0 +1,211 @@
1
+ package space
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "encoding/hex"
6
+ "encoding/json"
7
+ "fmt"
8
+ "net/url"
9
+ "os"
10
+ "path/filepath"
11
+ "strconv"
12
+ "strings"
13
+ )
14
+
15
+ const Version = 1
16
+
17
+ type Selection struct {
18
+ TeamID string `json:"team_id"`
19
+ Name string `json:"name"`
20
+ }
21
+
22
+ type Binding struct {
23
+ Selection
24
+ Account string `json:"account_scope"`
25
+ Origin string `json:"api_origin"`
26
+ }
27
+
28
+ type References struct {
29
+ ThreadID string `json:"thread_id"`
30
+ RunID string `json:"run_id"`
31
+ AssetIDs []string `json:"asset_ids"`
32
+ }
33
+
34
+ type record struct {
35
+ Version int `json:"version"`
36
+ Binding
37
+ Kind string `json:"kind"`
38
+ ID string `json:"id"`
39
+ ThreadID string `json:"thread_id,omitempty"`
40
+ }
41
+
42
+ type Store struct {
43
+ Dir string
44
+ Account string
45
+ Origin string
46
+ }
47
+
48
+ func Digest(value string) string {
49
+ sum := sha256.Sum256([]byte(value))
50
+ return hex.EncodeToString(sum[:])
51
+ }
52
+
53
+ func ParseTeamID(raw string) (string, error) {
54
+ if raw == "" || strings.IndexFunc(raw, func(r rune) bool { return r < '0' || r > '9' }) >= 0 {
55
+ return "", fmt.Errorf("team-id 必须为 0 或正整数团队 ID")
56
+ }
57
+ n, err := strconv.ParseInt(raw, 10, 64)
58
+ if err != nil {
59
+ return "", fmt.Errorf("team-id 超出 int64 范围")
60
+ }
61
+ return strconv.FormatInt(n, 10), nil
62
+ }
63
+
64
+ func NormalizeOrigin(raw string) (string, error) {
65
+ u, err := url.Parse(raw)
66
+ if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil {
67
+ return "", fmt.Errorf("API origin 无效")
68
+ }
69
+ u.Scheme, u.Host = strings.ToLower(u.Scheme), strings.ToLower(u.Host)
70
+ if (u.Scheme == "https" && u.Port() == "443") || (u.Scheme == "http" && u.Port() == "80") {
71
+ u.Host = u.Hostname()
72
+ if strings.Contains(u.Host, ":") {
73
+ u.Host = "[" + u.Host + "]"
74
+ }
75
+ }
76
+ return u.Scheme + "://" + u.Host, nil
77
+ }
78
+
79
+ func NewStore(baseURL, account string) (*Store, error) {
80
+ origin, err := NormalizeOrigin(baseURL)
81
+ if err != nil {
82
+ return nil, err
83
+ }
84
+ dir, err := os.UserConfigDir()
85
+ if err != nil {
86
+ return nil, err
87
+ }
88
+ return &Store{Dir: filepath.Join(dir, "pippit-cli", "spaces", Digest(origin+"\n"+account)), Account: account, Origin: origin}, nil
89
+ }
90
+
91
+ func Personal() Selection { return Selection{TeamID: "0", Name: "个人空间"} }
92
+
93
+ func (s *Store) file(kind, id string) string {
94
+ return filepath.Join(s.Dir, kind+"-"+Digest(id)+".json")
95
+ }
96
+
97
+ func (s *Store) load(kind, id, thread string) (*record, error) {
98
+ data, err := os.ReadFile(s.file(kind, id))
99
+ if os.IsNotExist(err) {
100
+ return nil, nil
101
+ }
102
+ if err != nil {
103
+ return nil, fmt.Errorf("无法读取%s空间记录: %w", kind, err)
104
+ }
105
+ var r record
106
+ if json.Unmarshal(data, &r) != nil || r.Version != Version || r.Account != s.Account || r.Origin != s.Origin || r.Kind != kind || r.ID != id || r.ThreadID != thread {
107
+ return nil, fmt.Errorf("%s空间记录损坏、身份不符或版本不支持", kind)
108
+ }
109
+ teamID, err := ParseTeamID(r.TeamID)
110
+ if err != nil || teamID != r.TeamID {
111
+ return nil, fmt.Errorf("%s空间记录的 team_id 无效", kind)
112
+ }
113
+ return &r, nil
114
+ }
115
+
116
+ func (s *Store) Current() (Selection, error) {
117
+ r, err := s.load("default", "", "")
118
+ if err != nil {
119
+ return Selection{}, fmt.Errorf("%w;可用 --team-id 指定本次空间", err)
120
+ }
121
+ if r == nil {
122
+ return Personal(), nil
123
+ }
124
+ return r.Selection, nil
125
+ }
126
+
127
+ func (s *Store) SaveDefault(selection Selection) error {
128
+ if s.Account == "" {
129
+ return fmt.Errorf("请先登录,再保存默认空间")
130
+ }
131
+ return s.save("default", "", "", selection)
132
+ }
133
+
134
+ func (s *Store) save(kind, id, thread string, selection Selection) error {
135
+ teamID, err := ParseTeamID(selection.TeamID)
136
+ if err != nil {
137
+ return err
138
+ }
139
+ selection.TeamID = teamID
140
+ data, err := json.Marshal(record{Version: Version, Binding: Binding{Selection: selection, Account: s.Account, Origin: s.Origin}, Kind: kind, ID: id, ThreadID: thread})
141
+ if err != nil {
142
+ return err
143
+ }
144
+ if err := os.MkdirAll(s.Dir, 0700); err != nil {
145
+ return err
146
+ }
147
+ f, err := os.CreateTemp(s.Dir, ".space-*.tmp")
148
+ if err != nil {
149
+ return err
150
+ }
151
+ defer os.Remove(f.Name())
152
+ if _, err = f.Write(data); err != nil {
153
+ f.Close()
154
+ return err
155
+ }
156
+ if err = f.Sync(); err != nil {
157
+ f.Close()
158
+ return err
159
+ }
160
+ if err = f.Close(); err != nil {
161
+ return err
162
+ }
163
+ return os.Rename(f.Name(), s.file(kind, id))
164
+ }
165
+
166
+ // loadReferences returns readable records together with any read error, so a
167
+ // caller recovering an unreadable record can still check the remaining records.
168
+ func (s *Store) loadReferences(ref References) ([]record, error) {
169
+ var records []record
170
+ var unreadable error
171
+ for _, key := range referenceKeys(ref) {
172
+ r, err := s.load(key.kind, key.id, key.thread)
173
+ if err != nil {
174
+ unreadable = err
175
+ } else if r != nil {
176
+ records = append(records, *r)
177
+ }
178
+ }
179
+ return records, unreadable
180
+ }
181
+
182
+ type referenceKey struct{ kind, id, thread string }
183
+
184
+ func referenceKeys(ref References) []referenceKey {
185
+ var keys []referenceKey
186
+ if ref.ThreadID != "" {
187
+ keys = append(keys, referenceKey{"thread", ref.ThreadID, ""})
188
+ }
189
+ if ref.RunID != "" {
190
+ keys = append(keys, referenceKey{"run", ref.RunID, ref.ThreadID})
191
+ }
192
+ for _, id := range ref.AssetIDs {
193
+ if id != "" {
194
+ keys = append(keys, referenceKey{"asset", id, ""})
195
+ }
196
+ }
197
+ return keys
198
+ }
199
+
200
+ func (s *Store) Remember(ref References, selected Selection) error {
201
+ for _, key := range referenceKeys(ref) {
202
+ old, err := s.load(key.kind, key.id, key.thread)
203
+ if err == nil && old != nil && old.TeamID != selected.TeamID {
204
+ return fmt.Errorf("拒绝覆盖归属不同的%s记录", key.kind)
205
+ }
206
+ if err := s.save(key.kind, key.id, key.thread, selected); err != nil {
207
+ return err
208
+ }
209
+ }
210
+ return nil
211
+ }