@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.
- package/README.md +42 -11
- package/checksums.txt +6 -6
- package/cmd/auth/auth.go +19 -5
- package/cmd/auth/auth_test.go +23 -0
- package/cmd/canvas/canvas.go +7 -7
- package/cmd/download_result.go +3 -2
- package/cmd/marketing.go +19 -3
- package/cmd/marketing_test.go +11 -5
- package/cmd/model.go +5 -4
- package/cmd/root.go +13 -5
- package/cmd/short_drama_test.go +2 -0
- package/cmd/source_test.go +27 -4
- package/cmd/space.go +199 -0
- package/cmd/space_test.go +469 -0
- package/cmd/upload_file_test.go +3 -1
- package/dist/checksums.txt +6 -6
- package/internal/adaptercli/commands_test.go +9 -5
- package/internal/auth/space.go +26 -0
- package/internal/auth/space_test.go +42 -0
- package/internal/canvas/get.go +9 -2
- package/internal/commandnames/names.go +9 -4
- package/internal/common/client.go +23 -0
- package/internal/common/host_source.go +5 -0
- package/internal/common/host_source_test.go +9 -1
- package/internal/common/runner.go +5 -3
- package/internal/common/space.go +185 -0
- package/internal/common/space_test.go +132 -0
- package/internal/common/submit_run.go +2 -0
- package/internal/common/upload_file.go +2 -0
- package/internal/config/config.go +15 -12
- package/internal/generate_image/generate_image.go +3 -9
- package/internal/short_drama/submit_run.go +3 -9
- package/internal/space/session.go +153 -0
- package/internal/space/store.go +211 -0
- package/internal/space/store_test.go +246 -0
- package/package.json +1 -1
- package/scripts/canvas-command.js +37 -4
- package/scripts/install-wizard.js +1 -1
- package/scripts/run.js +2 -2
- package/scripts/telemetry.js +3 -1
- package/skills/short-drama/SKILL.md +12 -1
- package/skills/xyq-marketing-skill/SKILL.md +22 -3
- package/skills/xyq-marketing-skill/references/api.md +7 -1
- package/skills/xyq-marketing-skill/scripts/marketing.js +42 -18
- package/skills/xyq-nest-skill/SKILL.md +15 -4
- package/skills/xyq-nest-skill/commands/canvas-assets.md +1 -1
- package/skills/xyq-nest-skill/commands/canvas.md +5 -3
- package/skills/xyq-nest-skill/commands/get-credit-balance.md +2 -2
- package/skills/xyq-nest-skill/commands/model.md +1 -1
- package/skills/xyq-nest-skill/scripts/ensure-cli.js +8 -1
- package/skills/xyq-nest-skill/scripts/install.md +1 -1
- package/skills/xyq-nest-skill/workflows/canvas-edit.md +2 -2
|
@@ -42,14 +42,7 @@ type generalAgentSettings struct {
|
|
|
42
42
|
GenerateImageCount *int `json:"generate_image_count,omitempty"`
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
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 &
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
package space
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func testStore(t *testing.T) *Store {
|
|
13
|
+
t.Helper()
|
|
14
|
+
return &Store{Dir: t.TempDir(), Account: "account-a", Origin: "https://example.test"}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func TestTaskScopeSurvivesDefaultSwitchAndRejectsConflicts(t *testing.T) {
|
|
18
|
+
s := testStore(t)
|
|
19
|
+
a := Selection{TeamID: "12", Name: "A"}
|
|
20
|
+
b := Selection{TeamID: "34", Name: "B"}
|
|
21
|
+
ref := References{ThreadID: "thread", RunID: "run", AssetIDs: []string{"asset"}}
|
|
22
|
+
if err := s.Remember(ref, a); err != nil {
|
|
23
|
+
t.Fatal(err)
|
|
24
|
+
}
|
|
25
|
+
if err := s.SaveDefault(b); err != nil {
|
|
26
|
+
t.Fatal(err)
|
|
27
|
+
}
|
|
28
|
+
got, err := (&Session{Store: s}).Resolve(ref)
|
|
29
|
+
if err != nil || got.TeamID != "12" {
|
|
30
|
+
t.Fatalf("old task = %+v, %v", got, err)
|
|
31
|
+
}
|
|
32
|
+
got, err = (&Session{Store: s}).Resolve(References{})
|
|
33
|
+
if err != nil || got.TeamID != "34" {
|
|
34
|
+
t.Fatalf("new task = %+v, %v", got, err)
|
|
35
|
+
}
|
|
36
|
+
if _, err := (&Session{Store: s, Explicit: &b.TeamID}).Resolve(ref); err == nil {
|
|
37
|
+
t.Fatal("explicit conflicting team accepted")
|
|
38
|
+
}
|
|
39
|
+
if _, err := (&Session{Store: s}).Resolve(References{AssetIDs: []string{"asset"}}); err == nil {
|
|
40
|
+
t.Fatal("cross-team asset accepted")
|
|
41
|
+
}
|
|
42
|
+
zero := "0"
|
|
43
|
+
got, err = (&Session{Store: s, Explicit: &zero}).Resolve(References{})
|
|
44
|
+
if err != nil || got.TeamID != "0" {
|
|
45
|
+
t.Fatalf("personal override = %+v, %v", got, err)
|
|
46
|
+
}
|
|
47
|
+
current, _ := s.Current()
|
|
48
|
+
if current.TeamID != "34" {
|
|
49
|
+
t.Fatal("one-shot override changed saved default")
|
|
50
|
+
}
|
|
51
|
+
if err := os.Remove(s.file("thread", "thread")); err != nil {
|
|
52
|
+
t.Fatal(err)
|
|
53
|
+
}
|
|
54
|
+
got, err = (&Session{Store: s}).Resolve(ref)
|
|
55
|
+
if err != nil || got.TeamID != "12" {
|
|
56
|
+
t.Fatalf("partial task record = %+v, %v", got, err)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func TestCorruptRecordRequiresExplicitRecovery(t *testing.T) {
|
|
61
|
+
s := testStore(t)
|
|
62
|
+
ref := References{ThreadID: "thread", RunID: "run"}
|
|
63
|
+
a := Selection{TeamID: "12", Name: "A"}
|
|
64
|
+
if err := s.Remember(ref, a); err != nil {
|
|
65
|
+
t.Fatal(err)
|
|
66
|
+
}
|
|
67
|
+
if err := os.WriteFile(s.file("run", "run"), []byte("broken"), 0600); err != nil {
|
|
68
|
+
t.Fatal(err)
|
|
69
|
+
}
|
|
70
|
+
if _, err := (&Session{Store: s}).Resolve(ref); err == nil {
|
|
71
|
+
t.Fatal("corrupt record treated as missing")
|
|
72
|
+
}
|
|
73
|
+
other := "34"
|
|
74
|
+
if _, err := (&Session{Store: s, Explicit: &other}).Resolve(ref); err == nil {
|
|
75
|
+
t.Fatal("corrupt run overrode valid thread")
|
|
76
|
+
}
|
|
77
|
+
if _, err := (&Session{Store: s, Explicit: &a.TeamID}).Resolve(ref); err != nil {
|
|
78
|
+
t.Fatal(err)
|
|
79
|
+
}
|
|
80
|
+
if err := s.Remember(ref, a); err != nil {
|
|
81
|
+
t.Fatal(err)
|
|
82
|
+
}
|
|
83
|
+
if _, err := (&Session{Store: s}).Resolve(ref); err != nil {
|
|
84
|
+
t.Fatal(err)
|
|
85
|
+
}
|
|
86
|
+
if err := os.WriteFile(s.file("default", ""), []byte("broken"), 0600); err != nil {
|
|
87
|
+
t.Fatal(err)
|
|
88
|
+
}
|
|
89
|
+
if _, err := (&Session{Store: s}).Resolve(References{}); err == nil {
|
|
90
|
+
t.Fatal("corrupt default fell back to personal")
|
|
91
|
+
}
|
|
92
|
+
zero := "0"
|
|
93
|
+
if _, err := (&Session{Store: s, Explicit: &zero}).Resolve(References{}); err != nil {
|
|
94
|
+
t.Fatal(err)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func TestSpaceIdentityAndInt64Boundary(t *testing.T) {
|
|
99
|
+
for _, raw := range []string{"", "-1", "1.5", " 12", "+12", "9223372036854775808"} {
|
|
100
|
+
if _, err := ParseTeamID(raw); err == nil {
|
|
101
|
+
t.Errorf("accepted %q", raw)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
s := testStore(t)
|
|
105
|
+
for raw, want := range map[string]string{"0": "0", "000": "0", "00012": "12", "9223372036854775807": "9223372036854775807"} {
|
|
106
|
+
if got, err := ParseTeamID(raw); err != nil || got != want {
|
|
107
|
+
t.Errorf("%q = %q, %v", raw, got, err)
|
|
108
|
+
}
|
|
109
|
+
if err := s.SaveDefault(Selection{TeamID: raw}); err != nil {
|
|
110
|
+
t.Fatalf("save %q: %v", raw, err)
|
|
111
|
+
}
|
|
112
|
+
if got, err := s.Current(); err != nil || got.TeamID != want {
|
|
113
|
+
t.Errorf("read saved %q = %+v, %v; want team_id %q", raw, got, err, want)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
a, err := NewStore("https://EXAMPLE.test:443/path", "account-a")
|
|
117
|
+
if err != nil {
|
|
118
|
+
t.Fatal(err)
|
|
119
|
+
}
|
|
120
|
+
b, _ := NewStore("https://example.test", "account-a")
|
|
121
|
+
c, _ := NewStore("https://example.test", "account-b")
|
|
122
|
+
d, _ := NewStore("https://other.test", "account-a")
|
|
123
|
+
if a.Dir != b.Dir || a.Dir == c.Dir || a.Dir == d.Dir {
|
|
124
|
+
t.Fatal("account/origin isolation failed")
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func TestFixedSessionRequiresExplicitRecordRecovery(t *testing.T) {
|
|
129
|
+
for _, teamID := range []string{"0", "12"} {
|
|
130
|
+
for _, bindingOnly := range []bool{false, true} {
|
|
131
|
+
s := testStore(t)
|
|
132
|
+
selected := Selection{TeamID: teamID}
|
|
133
|
+
if err := s.SaveDefault(selected); err != nil {
|
|
134
|
+
t.Fatal(err)
|
|
135
|
+
}
|
|
136
|
+
session := &Session{Store: s}
|
|
137
|
+
if _, err := session.Resolve(References{}); err != nil {
|
|
138
|
+
t.Fatal(err)
|
|
139
|
+
}
|
|
140
|
+
if bindingOnly {
|
|
141
|
+
binding, err := DecodeBinding(session.Token())
|
|
142
|
+
if err != nil {
|
|
143
|
+
t.Fatal(err)
|
|
144
|
+
}
|
|
145
|
+
session = &Session{Store: s, Expected: binding}
|
|
146
|
+
}
|
|
147
|
+
if err := s.SaveDefault(Selection{TeamID: "34"}); err != nil {
|
|
148
|
+
t.Fatal(err)
|
|
149
|
+
}
|
|
150
|
+
ref := References{ThreadID: "thread", RunID: "run"}
|
|
151
|
+
if err := s.Remember(ref, selected); err != nil {
|
|
152
|
+
t.Fatal(err)
|
|
153
|
+
}
|
|
154
|
+
if err := os.WriteFile(s.file("run", "run"), []byte("broken"), 0600); err != nil {
|
|
155
|
+
t.Fatal(err)
|
|
156
|
+
}
|
|
157
|
+
if _, err := session.Resolve(ref); err == nil {
|
|
158
|
+
t.Fatalf("team %s, binding=%v: fixed space bypassed corrupt record", teamID, bindingOnly)
|
|
159
|
+
}
|
|
160
|
+
session.Explicit = &teamID
|
|
161
|
+
got, err := session.Resolve(ref)
|
|
162
|
+
if err != nil || got.TeamID != teamID {
|
|
163
|
+
t.Fatalf("explicit recovery = %+v, %v", got, err)
|
|
164
|
+
}
|
|
165
|
+
if err := s.Remember(References{ThreadID: "other"}, Selection{TeamID: "34"}); err != nil {
|
|
166
|
+
t.Fatal(err)
|
|
167
|
+
}
|
|
168
|
+
if _, err := session.Resolve(References{ThreadID: "other"}); err == nil {
|
|
169
|
+
t.Fatal("fixed space accepted a conflicting task")
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func TestSessionWithoutStoreKeepsExplicitSpace(t *testing.T) {
|
|
176
|
+
for _, teamID := range []string{"0", "12", "34"} {
|
|
177
|
+
explicit := teamID
|
|
178
|
+
session := &Session{Explicit: &explicit}
|
|
179
|
+
selected, err := session.Resolve(References{})
|
|
180
|
+
if err != nil || selected.TeamID != teamID {
|
|
181
|
+
t.Fatalf("space = %+v, %v", selected, err)
|
|
182
|
+
}
|
|
183
|
+
explicit = "56"
|
|
184
|
+
selected, err = session.Resolve(References{ThreadID: "thread", RunID: "run"})
|
|
185
|
+
if err != nil || selected.TeamID != teamID {
|
|
186
|
+
t.Fatalf("command changed its frozen space: %+v, %v", selected, err)
|
|
187
|
+
}
|
|
188
|
+
session.Remember(References{ThreadID: "thread", RunID: "run"})
|
|
189
|
+
if session.Token() != "" {
|
|
190
|
+
t.Fatal("unknown account must not produce an identity binding")
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
session := &Session{Expected: &Binding{Selection: Personal(), Account: "account", Origin: "https://example.test"}}
|
|
194
|
+
if _, err := session.Resolve(References{}); err == nil {
|
|
195
|
+
t.Fatal("identity binding accepted without an identifiable account")
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
func TestCommandSessionFreezesScopeAndChecksIdentity(t *testing.T) {
|
|
200
|
+
s := testStore(t)
|
|
201
|
+
if err := s.SaveDefault(Selection{TeamID: "12", Name: "A"}); err != nil {
|
|
202
|
+
t.Fatal(err)
|
|
203
|
+
}
|
|
204
|
+
account := s.Account
|
|
205
|
+
var warning bytes.Buffer
|
|
206
|
+
session := &Session{Store: s, Stderr: &warning, Identity: func(context.Context) (string, error) { return account, nil }}
|
|
207
|
+
ctx := context.Background()
|
|
208
|
+
if _, err := session.Resolve(References{}); err != nil {
|
|
209
|
+
t.Fatal(err)
|
|
210
|
+
}
|
|
211
|
+
token := session.Token()
|
|
212
|
+
if err := s.SaveDefault(Selection{TeamID: "34", Name: "B"}); err != nil {
|
|
213
|
+
t.Fatal(err)
|
|
214
|
+
}
|
|
215
|
+
got, err := session.Resolve(References{})
|
|
216
|
+
if err != nil || got.TeamID != "12" {
|
|
217
|
+
t.Fatalf("session changed = %+v, %v", got, err)
|
|
218
|
+
}
|
|
219
|
+
binding, err := DecodeBinding(token)
|
|
220
|
+
if err != nil {
|
|
221
|
+
t.Fatal(err)
|
|
222
|
+
}
|
|
223
|
+
next := &Session{Store: s, Expected: binding}
|
|
224
|
+
got, err = next.Resolve(References{})
|
|
225
|
+
if err != nil || got.TeamID != "12" || next.Token() != token {
|
|
226
|
+
t.Fatalf("cross-process binding = %+v, %v", got, err)
|
|
227
|
+
}
|
|
228
|
+
account = "account-b"
|
|
229
|
+
if err := session.CheckIdentity(ctx); err == nil {
|
|
230
|
+
t.Fatal("account switch accepted")
|
|
231
|
+
}
|
|
232
|
+
next.Store = &Store{Dir: s.Dir, Account: account, Origin: s.Origin}
|
|
233
|
+
if _, err := next.Resolve(References{}); err == nil {
|
|
234
|
+
t.Fatal("binding allowed different account")
|
|
235
|
+
}
|
|
236
|
+
// A successful request stays successful even if the record directory fails.
|
|
237
|
+
blocked := filepath.Join(t.TempDir(), "file")
|
|
238
|
+
if err := os.WriteFile(blocked, []byte("file"), 0600); err != nil {
|
|
239
|
+
t.Fatal(err)
|
|
240
|
+
}
|
|
241
|
+
s.Dir = blocked
|
|
242
|
+
session.Remember(References{ThreadID: "created-thread", RunID: "created-run"})
|
|
243
|
+
if !strings.Contains(warning.String(), "created-run") || !strings.Contains(warning.String(), "不要重复提交") {
|
|
244
|
+
t.Fatal(warning.String())
|
|
245
|
+
}
|
|
246
|
+
}
|