@pippit-dev/cli 1.0.53 → 1.0.55

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 CHANGED
@@ -77,6 +77,14 @@ pippit-tool-cli get-credit-balance --team-id 0 # 仅本次使用个人
77
77
 
78
78
  `space list/use TEAM_ID` 依赖 API 部署 `/api/biz/v1/skill/get_lite_team_list` 的 AK 适配入口,业务团队使用还依赖对应 API 团队权限实现;不直连 commerce Web 接口。宿主 Agent 的选择、任务恢复与不回退规则已写入三个 Skill 的 `Space Selection` 章节。
79
79
 
80
+ ## 查询出口 IP
81
+
82
+ ```bash
83
+ pippit-tool-cli ip
84
+ ```
85
+
86
+ 输出 `{"client_ip":"..."}`,返回服务端看到的出口 IP,无需登录或 AK;沿用 `config.toml` 的 API 地址与 PPE 配置。AK 的 IP 白名单仍在网页密钥管理页设置。
87
+
80
88
  ## 账号 AIGC 水印设置
81
89
 
82
90
  ```bash
package/checksums.txt CHANGED
@@ -1,6 +1,6 @@
1
- 08e0f157a05bdcbbb4c7a53eeeee3374d5be4bf86ec31139c5d42328c8cc781e pippit-tool-cli-1.0.53-darwin-amd64.tar.gz
2
- fa1b79ed862a997d7ec8117895158f1914d2fcaa30792b303fd924d2856a834f pippit-tool-cli-1.0.53-darwin-arm64.tar.gz
3
- e0d30c4edf201108587da2bd1ce97f9e7a30721e41700c50e7ddb57cc4c2474a pippit-tool-cli-1.0.53-linux-amd64.tar.gz
4
- b60c817d9b58659838baf9954e5cc110ddc92ed2736e5512047dc2b20a9dfde8 pippit-tool-cli-1.0.53-linux-arm64.tar.gz
5
- b890470552424b37fbd98d8a0e202d66526f7f575233cfb860599dadbc802be2 pippit-tool-cli-1.0.53-windows-amd64.zip
6
- cf549f5b6e7d929d7b8b48b0a1a3df467ad921c23b4a93361799d7723b023bde pippit-tool-cli-1.0.53-windows-arm64.zip
1
+ 99a7ec8c86f332f9a3a8e7538d9eb1c237e0d8eebdacdaeb262f77ea716d3bd7 pippit-tool-cli-1.0.55-darwin-amd64.tar.gz
2
+ 8cefadb8e6ac41a7c6ff2f1a1ab89448176734ff449238cbcd3f38bbc0acc8cf pippit-tool-cli-1.0.55-darwin-arm64.tar.gz
3
+ 69d0d83c74c95b46f1eb859ed5a9fe4aaf3b38f25465ff5a4d0c0bf33d815988 pippit-tool-cli-1.0.55-linux-amd64.tar.gz
4
+ 086db5207fbe14e15fb97f0a3c970c36de06e67d333cd68373be6a0e5af2626a pippit-tool-cli-1.0.55-linux-arm64.tar.gz
5
+ 14cda14f712ff2c09566fbeb68f39296b200e3d54cdc659e13baf1e9f9b1ed92 pippit-tool-cli-1.0.55-windows-amd64.zip
6
+ 86ae993c8b32c1a6a09c81882e21bac7e8368468fe28b1e7be90b68466d8fe23 pippit-tool-cli-1.0.55-windows-arm64.zip
package/cmd/ip.go ADDED
@@ -0,0 +1,28 @@
1
+ package cmd
2
+
3
+ import (
4
+ "io"
5
+
6
+ "github.com/Pippit-dev/pippit-cli/internal/adapter"
7
+ "github.com/Pippit-dev/pippit-cli/internal/commandnames"
8
+ "github.com/Pippit-dev/pippit-cli/internal/common"
9
+ "github.com/spf13/cobra"
10
+ )
11
+
12
+ func newIPCommand(stdout, stderr io.Writer, runner *common.Runner, policy adapter.HTTPAdapter) *cobra.Command {
13
+ cmd := &cobra.Command{
14
+ Use: commandnames.IP,
15
+ Short: "Query the outbound IP observed by the server",
16
+ Args: cobra.NoArgs,
17
+ RunE: withErrorLog(commandnames.IP, nil, func(cmd *cobra.Command, _ []string) error {
18
+ result, err := common.GetClientIP(cmd.Context(), runner, policy)
19
+ if err != nil {
20
+ return err
21
+ }
22
+ return common.WriteJSON(stdout, result)
23
+ }),
24
+ }
25
+ cmd.SetOut(stdout)
26
+ cmd.SetErr(stderr)
27
+ return cmd
28
+ }
package/cmd/ip_test.go ADDED
@@ -0,0 +1,101 @@
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
+ "strings"
12
+ "testing"
13
+ "time"
14
+
15
+ "github.com/Pippit-dev/pippit-cli/internal/adapter"
16
+ "github.com/Pippit-dev/pippit-cli/internal/auth"
17
+ "github.com/Pippit-dev/pippit-cli/internal/common"
18
+ "github.com/Pippit-dev/pippit-cli/internal/config"
19
+ )
20
+
21
+ type ipCredentialStore struct {
22
+ auth.CredentialStore
23
+ loads int
24
+ }
25
+
26
+ func (store *ipCredentialStore) Load(context.Context) (*auth.Credential, error) {
27
+ store.loads++
28
+ return nil, errors.New("corrupt credential fixture")
29
+ }
30
+
31
+ type ipCommandPolicy struct {
32
+ adapter.Default
33
+ options adapter.CLIOptions
34
+ }
35
+
36
+ func (policy ipCommandPolicy) CLIOptions() adapter.CLIOptions { return policy.options }
37
+
38
+ func TestIPCommandDoesNotReadCredentialsOrInitializeSpace(t *testing.T) {
39
+ for _, accessKey := range []string{"", "existing-key"} {
40
+ t.Run("configured_key="+accessKey, func(t *testing.T) {
41
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
42
+ for _, header := range []string{"Authorization", "Cookie", "X-Team-Id"} {
43
+ if r.Header.Get(header) != "" {
44
+ t.Errorf("anonymous command sent %s", header)
45
+ }
46
+ }
47
+ _, _ = io.WriteString(w, `{"ret":"0","data":{"client_ip":"8.8.8.8"}}`)
48
+ }))
49
+ defer server.Close()
50
+ cfg := config.Load()
51
+ cfg.BaseURL, cfg.AccessKey = server.URL, accessKey
52
+ cfg.HTTPTimeout = time.Second
53
+ policy := ipCommandPolicy{options: adapter.CLIOptions{
54
+ DefaultAllowRootCommands: true,
55
+ LocalCredentialsEnabled: true,
56
+ }}
57
+ runner := newRootRunnerWithAdapter(cfg, policy)
58
+ store := &ipCredentialStore{}
59
+ runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(store))
60
+ var stdout, stderr bytes.Buffer
61
+ root := newRootCommandWithAdapter(&stdout, &stderr, runner, policy)
62
+ root.SetArgs([]string{"--team-id", "42", "ip"})
63
+ if err := root.Execute(); err != nil {
64
+ t.Fatalf("Execute() = %v, stderr = %q", err, stderr.String())
65
+ }
66
+ if store.loads != 0 {
67
+ t.Fatalf("credential loads = %d, want 0 even with corrupt credentials", store.loads)
68
+ }
69
+ command, _, err := root.Find([]string{"ip"})
70
+ if err != nil || command == nil || common.SpaceSession(command.Context()) != nil {
71
+ t.Fatalf("ip initialized a space session: %v", err)
72
+ }
73
+ var output map[string]string
74
+ if err := json.Unmarshal(stdout.Bytes(), &output); err != nil || len(output) != 1 || output["client_ip"] != "8.8.8.8" {
75
+ t.Fatalf("stdout = %q, error = %v, want only client_ip", stdout.String(), err)
76
+ }
77
+ })
78
+ }
79
+ }
80
+
81
+ func TestIPCommandRespectsAdapterFiltering(t *testing.T) {
82
+ for _, allowed := range []bool{true, false} {
83
+ options := adapter.CLIOptions{}
84
+ if allowed {
85
+ options.AllowedRootCommands = []string{"ip"}
86
+ }
87
+ root := NewRootCommandWithAdapter(io.Discard, io.Discard, ipCommandPolicy{options: options})
88
+ command, _, _ := root.Find([]string{"ip"})
89
+ if got := command != nil && command.Name() == "ip"; got != allowed {
90
+ t.Fatalf("ip present = %v, want %v", got, allowed)
91
+ }
92
+ }
93
+ }
94
+
95
+ func TestIPCommandRejectsArguments(t *testing.T) {
96
+ command := newIPCommand(io.Discard, io.Discard, nil, adapter.Default{})
97
+ command.SetArgs([]string{"unexpected"})
98
+ if err := command.Execute(); err == nil || !strings.Contains(err.Error(), "unexpected") {
99
+ t.Fatalf("Execute() = %v, want positional argument rejection", err)
100
+ }
101
+ }
package/cmd/root.go CHANGED
@@ -120,6 +120,7 @@ func newRootCommandWithAdapter(stdout, stderr io.Writer, runner *common.Runner,
120
120
  root.AddCommand(newDownloadResultCommand(stdout, stderr, runner))
121
121
  root.AddCommand(newGetCreditBalanceCommand(stdout, stderr, runner))
122
122
  root.AddCommand(newWatermarkCommand(stdout, stderr, runner))
123
+ root.AddCommand(newIPCommand(stdout, stderr, runner, policy))
123
124
  root.AddCommand(newModelCommand(stdout, stderr, runner))
124
125
  root.AddCommand(newMarketingCommand(stdout, stderr, runner, policy))
125
126
  root.AddCommand(newGetThreadCommand(stdout, stderr, runner))
package/cmd/space.go CHANGED
@@ -30,7 +30,7 @@ func prepareCommandSpace(cmd *cobra.Command, runner *common.Runner, stderr io.Wr
30
30
  top = top.Parent()
31
31
  }
32
32
  switch top.Name() {
33
- case commandnames.Space, commandnames.Login, commandnames.Logout, commandnames.Status, commandnames.Update, commandnames.Completion, commandnames.Help, commandnames.DownloadResult, commandnames.Watermark:
33
+ case commandnames.Space, commandnames.Login, commandnames.Logout, commandnames.Status, commandnames.Update, commandnames.Completion, commandnames.Help, commandnames.DownloadResult, commandnames.Watermark, commandnames.IP:
34
34
  return nil
35
35
  }
36
36
  raw, _ := cmd.Flags().GetString("space-context")
@@ -1,6 +1,6 @@
1
- 08e0f157a05bdcbbb4c7a53eeeee3374d5be4bf86ec31139c5d42328c8cc781e pippit-tool-cli-1.0.53-darwin-amd64.tar.gz
2
- fa1b79ed862a997d7ec8117895158f1914d2fcaa30792b303fd924d2856a834f pippit-tool-cli-1.0.53-darwin-arm64.tar.gz
3
- e0d30c4edf201108587da2bd1ce97f9e7a30721e41700c50e7ddb57cc4c2474a pippit-tool-cli-1.0.53-linux-amd64.tar.gz
4
- b60c817d9b58659838baf9954e5cc110ddc92ed2736e5512047dc2b20a9dfde8 pippit-tool-cli-1.0.53-linux-arm64.tar.gz
5
- b890470552424b37fbd98d8a0e202d66526f7f575233cfb860599dadbc802be2 pippit-tool-cli-1.0.53-windows-amd64.zip
6
- cf549f5b6e7d929d7b8b48b0a1a3df467ad921c23b4a93361799d7723b023bde pippit-tool-cli-1.0.53-windows-arm64.zip
1
+ 99a7ec8c86f332f9a3a8e7538d9eb1c237e0d8eebdacdaeb262f77ea716d3bd7 pippit-tool-cli-1.0.55-darwin-amd64.tar.gz
2
+ 8cefadb8e6ac41a7c6ff2f1a1ab89448176734ff449238cbcd3f38bbc0acc8cf pippit-tool-cli-1.0.55-darwin-arm64.tar.gz
3
+ 69d0d83c74c95b46f1eb859ed5a9fe4aaf3b38f25465ff5a4d0c0bf33d815988 pippit-tool-cli-1.0.55-linux-amd64.tar.gz
4
+ 086db5207fbe14e15fb97f0a3c970c36de06e67d333cd68373be6a0e5af2626a pippit-tool-cli-1.0.55-linux-arm64.tar.gz
5
+ 14cda14f712ff2c09566fbeb68f39296b200e3d54cdc659e13baf1e9f9b1ed92 pippit-tool-cli-1.0.55-windows-amd64.zip
6
+ 86ae993c8b32c1a6a09c81882e21bac7e8368468fe28b1e7be90b68466d8fe23 pippit-tool-cli-1.0.55-windows-arm64.zip
@@ -11,5 +11,6 @@ const (
11
11
  Help = "help"
12
12
  Model = "model"
13
13
  Watermark = "watermark"
14
+ IP = "ip"
14
15
  DownloadResult = "download-result"
15
16
  )
@@ -0,0 +1,58 @@
1
+ package common
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+ "net/netip"
8
+
9
+ "github.com/Pippit-dev/pippit-cli/internal/adapter"
10
+ "github.com/Pippit-dev/pippit-cli/internal/config"
11
+ )
12
+
13
+ type ClientIPResult struct {
14
+ ClientIP string `json:"client_ip"`
15
+ }
16
+
17
+ type clientIPRequestPolicy struct {
18
+ adapter.HTTPAdapter
19
+ }
20
+
21
+ func (clientIPRequestPolicy) PrepareAPIRequest(context.Context, *http.Request) (adapter.AuthDecision, error) {
22
+ return adapter.UseDefaultAuth, nil
23
+ }
24
+
25
+ // GetClientIP keeps the host transport and routing, without resolving account credentials.
26
+ func GetClientIP(ctx context.Context, runner *Runner, policy adapter.HTTPAdapter) (*ClientIPResult, error) {
27
+ if runner == nil || runner.Config == nil {
28
+ return nil, fmt.Errorf("查询出口 IP 的运行配置缺失")
29
+ }
30
+ cfg := runner.Config
31
+ anonymousPolicy := clientIPRequestPolicy{HTTPAdapter: adapter.HTTPOrDefault(policy)}
32
+ client := NewHTTPClientWithAdapter(cfg.BaseURL, cfg.HTTPTimeout, NewAccessKeyAuthorizer(""), adapter.WithPPE(anonymousPolicy, cfg.PPEEnv))
33
+ // This query is anonymous even when the caller's context carries a selected team.
34
+ ctx = WithSpace(ctx, nil)
35
+ var resp struct {
36
+ Ret string `json:"ret"`
37
+ Errmsg string `json:"errmsg"`
38
+ LogID string `json:"log_id"`
39
+ Data *ClientIPResult `json:"data"`
40
+ }
41
+ if err := client.SendRequest(ctx, config.GetClientIPPath, nil, &resp); err != nil {
42
+ return nil, fmt.Errorf("查询出口 IP 请求失败: %w", err)
43
+ }
44
+ if resp.Ret != "0" {
45
+ if resp.Errmsg == "" {
46
+ resp.Errmsg = "未知错误"
47
+ }
48
+ return nil, NewLogIDError(fmt.Sprintf("查询出口 IP 请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID)
49
+ }
50
+ if resp.Data == nil || resp.Data.ClientIP == "" {
51
+ return nil, NewLogIDError("查询出口 IP 响应缺少 data.client_ip", resp.LogID)
52
+ }
53
+ ip, err := netip.ParseAddr(resp.Data.ClientIP)
54
+ if err != nil || ip.Zone() != "" {
55
+ return nil, NewLogIDError("查询出口 IP 响应的 data.client_ip 无效", resp.LogID)
56
+ }
57
+ return &ClientIPResult{ClientIP: ip.Unmap().String()}, nil
58
+ }
@@ -0,0 +1,135 @@
1
+ package common
2
+
3
+ import (
4
+ "context"
5
+ "io"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "strings"
9
+ "testing"
10
+ "time"
11
+
12
+ "github.com/Pippit-dev/pippit-cli/internal/adapter"
13
+ "github.com/Pippit-dev/pippit-cli/internal/config"
14
+ "github.com/Pippit-dev/pippit-cli/internal/space"
15
+ )
16
+
17
+ type clientIPPolicy struct {
18
+ adapter.Default
19
+ prepareCalls int
20
+ }
21
+
22
+ func (p *clientIPPolicy) PrepareAPIRequest(_ context.Context, req *http.Request) (adapter.AuthDecision, error) {
23
+ p.prepareCalls++
24
+ req.Header.Set("Authorization", "Bearer adapter-key")
25
+ req.Header.Set("Cookie", "sessionid=adapter-cookie")
26
+ req.Header.Set("X-Team-Id", "99")
27
+ return adapter.AuthProvided, nil
28
+ }
29
+
30
+ func (*clientIPPolicy) WrapTransport(next http.RoundTripper) http.RoundTripper {
31
+ if next == nil {
32
+ next = http.DefaultTransport
33
+ }
34
+ return clientIPTransport{next: next}
35
+ }
36
+
37
+ type clientIPTransport struct{ next http.RoundTripper }
38
+
39
+ func (transport clientIPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
40
+ request := req.Clone(req.Context())
41
+ request.Header.Set("X-Test-IP-Transport", "present")
42
+ return transport.next.RoundTrip(request)
43
+ }
44
+
45
+ func TestGetClientIPIsAnonymousAndPreservesTransportAndRouting(t *testing.T) {
46
+ for _, env := range []string{"", "ppe_first", "ppe_second"} {
47
+ t.Run("env="+env, func(t *testing.T) {
48
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49
+ if r.Method != http.MethodGet || r.URL.Path != "/api/biz/v1/skill/get_client_ip" {
50
+ t.Errorf("request = %s %s, want anonymous client IP GET", r.Method, r.URL.Path)
51
+ }
52
+ body, err := io.ReadAll(r.Body)
53
+ if err != nil || len(body) != 0 || r.Header.Get("Content-Type") != "" {
54
+ t.Errorf("GET body = %q, Content-Type = %q, error = %v", body, r.Header.Get("Content-Type"), err)
55
+ }
56
+ for _, header := range []string{"Authorization", "Cookie", "X-Team-Id"} {
57
+ if r.Header.Get(header) != "" {
58
+ t.Errorf("anonymous request retained %s", header)
59
+ }
60
+ }
61
+ if r.Header.Get("X-Test-IP-Transport") != "present" {
62
+ t.Error("anonymous request bypassed adapter transport")
63
+ }
64
+ wantUsePPE := ""
65
+ if env != "" {
66
+ wantUsePPE = "1"
67
+ }
68
+ if r.Header.Get("x-tt-env") != env || r.Header.Get("x-use-ppe") != wantUsePPE {
69
+ t.Error("anonymous request did not follow the configured PPE environment")
70
+ }
71
+ _, _ = io.WriteString(w, `{"ret":"0","data":{"client_ip":"8.8.8.8"}}`)
72
+ }))
73
+ defer server.Close()
74
+
75
+ cfg := config.Load()
76
+ cfg.BaseURL, cfg.PPEEnv, cfg.AccessKey = server.URL, env, "existing-key"
77
+ cfg.HTTPTimeout = time.Second
78
+ policy := &clientIPPolicy{}
79
+ runner := NewRunner(cfg, NewHTTPClientWithAdapter(server.URL, time.Second, NewAccessKeyAuthorizer(cfg.AccessKey), policy))
80
+ teamID := "42"
81
+ ctx := WithSpace(context.Background(), &space.Session{Explicit: &teamID})
82
+ result, err := GetClientIP(ctx, runner, policy)
83
+ if err != nil || result == nil || result.ClientIP != "8.8.8.8" {
84
+ t.Fatalf("GetClientIP() = %#v, %v", result, err)
85
+ }
86
+ if policy.prepareCalls != 0 {
87
+ t.Fatalf("adapter authentication calls = %d, want 0", policy.prepareCalls)
88
+ }
89
+ })
90
+ }
91
+ }
92
+
93
+ func TestGetClientIPRejectsUnusableResponses(t *testing.T) {
94
+ for _, tc := range []struct {
95
+ name, response, wantError string
96
+ status int
97
+ }{
98
+ {"unavailable_source", `{"ret":"53001","errmsg":"暂时无法识别当前来源 IP","log_id":"log_ip"}`, "53001", http.StatusOK},
99
+ {"unavailable_source_http503", `{"ret":"53001","errmsg":"暂时无法识别当前来源 IP","log_id":"log_ip"}`, "53001", http.StatusServiceUnavailable},
100
+ {"missing_data", `{"ret":"0","log_id":"log_ip"}`, "data", http.StatusOK},
101
+ {"missing_ip", `{"ret":"0","log_id":"log_ip","data":{}}`, "client_ip", http.StatusOK},
102
+ {"invalid_ip", `{"ret":"0","log_id":"log_ip","data":{"client_ip":"not-an-ip"}}`, "client_ip", http.StatusOK},
103
+ {"zoned_ip", `{"ret":"0","log_id":"log_ip","data":{"client_ip":"fe80::1%eth0"}}`, "client_ip", http.StatusOK},
104
+ } {
105
+ t.Run(tc.name, func(t *testing.T) {
106
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
107
+ w.WriteHeader(tc.status)
108
+ _, _ = io.WriteString(w, tc.response)
109
+ }))
110
+ defer server.Close()
111
+ cfg := config.Load()
112
+ cfg.BaseURL = server.URL
113
+ cfg.HTTPTimeout = time.Second
114
+ _, err := GetClientIP(context.Background(), NewRunner(cfg, nil), adapter.Default{})
115
+ if err == nil || !strings.Contains(err.Error(), tc.wantError) || !strings.Contains(err.Error(), "log_ip") {
116
+ t.Fatalf("GetClientIP() error = %v, want %q and log ID", err, tc.wantError)
117
+ }
118
+ })
119
+ }
120
+ }
121
+
122
+ func TestGetClientIPSupportsIPv6(t *testing.T) {
123
+ const ip = "2001:4860:4860::8888"
124
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
125
+ _, _ = io.WriteString(w, `{"ret":"0","data":{"client_ip":"`+ip+`"}}`)
126
+ }))
127
+ defer server.Close()
128
+ cfg := config.Load()
129
+ cfg.BaseURL = server.URL
130
+ cfg.HTTPTimeout = time.Second
131
+ result, err := GetClientIP(context.Background(), NewRunner(cfg, nil), adapter.Default{})
132
+ if err != nil || result == nil || result.ClientIP != ip {
133
+ t.Fatalf("GetClientIP() = %#v, %v, want IPv6 %s", result, err, ip)
134
+ }
135
+ }
@@ -21,6 +21,7 @@ const (
21
21
  SubmitMarketingRunPath = "/api/biz/v1/agent/submit_marketing_run"
22
22
  QueryGenerateVideoResultPath = "/api/biz/v1/agent/query_generate_video_result"
23
23
  GetCreditBalancePath = "/api/biz/v1/skill/get_credit_balance"
24
+ GetClientIPPath = "/api/biz/v1/skill/get_client_ip"
24
25
  GetWatermarkSettingPath = "/api/biz/v1/skill/mget_user_setting"
25
26
  UpdateWatermarkSettingPath = "/api/biz/v1/skill/update_user_setting"
26
27
  GetLiteTeamListPath = "/api/biz/v1/skill/get_lite_team_list"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pippit-dev/cli",
3
- "version": "1.0.53",
3
+ "version": "1.0.55",
4
4
  "description": "Pippit CLI",
5
5
  "bin": {
6
6
  "pippit-tool-cli": "scripts/run.js"
@@ -33,6 +33,22 @@ URL 导入前检查实际入口的 `--help` 含 `--url`;旧版缺少时按 [
33
33
 
34
34
  媒体节点优先通过语义 `create_biz_node` 创建,`initialData` 只传 `pippitAssetId`:CLI 查询素材并填写真实云媒体 `assetId`。用 `update_asset.contentPatch` 或 `update_node_data.patch` 替换素材时也可只传新的 `pippitAssetId`。显式填写的 `assetId` 必须与素材查询结果匹配,否则返回 `INVALID_MEDIA_IDENTITY` 并拒绝写入。
35
35
 
36
+ 普通 `biz/image` 的素材绑定在同层 `content.pippitAssetId` 与 `content.assetId`。`content.appearanceInfo`、`content.sourceInfos` 属于角色/场景结构,不能套用于普通图片;角色/场景继续按各自领域命令及 schema 操作。
37
+
38
+ 以下仅用于核对保存后的节点结构,ID 占位符须替换为同空间真实查询结果,不作为完整写入请求直接执行:
39
+
40
+ ```json
41
+ {"type":"biz/image","content":{"pippitAssetId":"PIPPIT_MATERIAL_ID","assetId":"CLOUD_IMAGE_ASSET_ID"}}
42
+ ```
43
+
44
+ 错误反例:同层绑定缺失,即使保存 ACK,也不是可用参考图:
45
+
46
+ ```json
47
+ {"type":"biz/image","content":{"appearanceInfo":{"pippitAssetId":"PIPPIT_MATERIAL_ID","assetId":"CLOUD_IMAGE_ASSET_ID"}}}
48
+ ```
49
+
50
+ 可先创建不含绑定的图片空草稿;不能用 `generation`、`sourceInfos` 或手改“已上传”状态把空草稿视为素材已就绪。已有错绑数据先回读、核对真实素材,再在用户授权的编辑范围内修正,不自动迁移用户画布。
51
+
36
52
  `canvas upload` 顶层旧字段 `asset_id` 是保留兼容的 workspace/source ID,不能当节点媒体 ID;它与 `upload-file.asset_id` 的含义不同。`media_asset_id` 来自查询结果对应的 `Image/Video/Audio.asset_id`;缺失时不能用 `source_id`、`pippit_asset_id` 或画布节点 ID 补位。`node_asset_refs[].pippit_asset_id` 使用画布节点资产 ID。
37
53
 
38
54
  批量 `apply_mutations` 默认非原子执行:必须检查 `data.results` 中每一项的 `committed` / `aborted` 状态,保留已成功项,不重跑整批。需要任一失败时整批回滚,可显式传 `atomic: true`。
@@ -12,6 +12,18 @@ const REQUIRED_COMMANDS = [
12
12
  "generate-image", "generate-video", "video-super-resolution",
13
13
  "erase-video-subtitle", "get-credit-balance", "model list", "model describe",
14
14
  ];
15
+ // v1.0.50 first ships ordinary-image binding guards in both Canvas entries.
16
+ const MIN_CANVAS_VERSION = [1, 0, 50];
17
+
18
+ function supportsCanvasBindings(version) {
19
+ const match = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
20
+ if (!match) return false;
21
+ const parts = match.slice(1, 4).map(Number);
22
+ for (let i = 0; i < MIN_CANVAS_VERSION.length; i++) {
23
+ if (parts[i] !== MIN_CANVAS_VERSION[i]) return parts[i] > MIN_CANVAS_VERSION[i];
24
+ }
25
+ return true;
26
+ }
15
27
 
16
28
  function npmCommand() {
17
29
  if (process.platform !== "win32") return ["npm", []];
@@ -80,6 +92,11 @@ function ensureCLI({ canvas = false } = {}) {
80
92
  if (expectedVersion && version !== expectedVersion) {
81
93
  throw new Error(`CLI 版本 ${version} 与 npm 包版本 ${expectedVersion} 不一致。`);
82
94
  }
95
+ if (canvas && !supportsCanvasBindings(version)) {
96
+ const failure = new Error(`Canvas 需要 CLI >=${MIN_CANVAS_VERSION.join(".")} 正式版以校验图片绑定,当前版本:${version}。`);
97
+ failure.missingCommand = "Canvas 图片绑定校验(CLI >=1.0.50 正式版)";
98
+ throw failure;
99
+ }
83
100
  const commands = REQUIRED_COMMANDS.map((command) => command.split(" "));
84
101
  if (canvas) {
85
102
  for (const command of ["create", "get", "allocate", "upload", "apply"]) commands.push(["canvas", command]);
@@ -165,7 +182,7 @@ function ensureCLI({ canvas = false } = {}) {
165
182
 
166
183
  if (require.main === module) {
167
184
  if (process.argv.length === 3 && process.argv[2] === "--help") {
168
- console.log("Usage: node ensure-cli.js [--canvas]\n优先复用 PATH 或缓存中命令齐全的 CLI,不存在或缺少必需命令时安装 npm latest,成功输出 {cli_path, version} JSON。--canvas 额外验证画布原生命令和 npm 运行时,并返回 canvas_entry。");
185
+ console.log("Usage: node ensure-cli.js [--canvas]\n优先复用 PATH 或缓存中命令齐全的 CLI,不存在或缺少必需命令时安装 npm latest,成功输出 {cli_path, version} JSON。--canvas 要求 CLI >=1.0.50 正式版,额外验证画布原生命令和 npm 运行时,并返回 canvas_entry。");
169
186
  } else if (process.argv.length !== 2 && !(process.argv.length === 3 && process.argv[2] === "--canvas")) {
170
187
  console.error("不支持的参数。用法:node ensure-cli.js [--canvas]");
171
188
  process.exitCode = 1;
@@ -16,7 +16,7 @@ node "{baseDir}/scripts/ensure-cli.js"
16
16
 
17
17
  可选参数按实际任务检查帮助:URL 导入前核对所选入口 `upload-file --help` 或 `canvas upload --help` 含 `--url`,缺少时先更新到支持版本。安装脚本只检查必需命令,不因未使用的 URL 参数自动升级,也不证明服务端已部署 URL 导入能力。
18
18
 
19
- 命令齐全则直接复用,不检查最新版本;不存在或缺少必需命令时,获取 `@pippit-dev/cli@latest`。PATH 旧版本缺少命令但缓存完整时复用缓存,避免每次升级。版本命令不能运行或检查超时则报告运行错误。
19
+ 命令齐全则直接复用,不检查最新版本;Canvas 另有下述最低版本要求。不存在、缺少必需命令或不满足 Canvas 最低版本时,获取 `@pippit-dev/cli@latest`。PATH 旧版本不兼容但缓存完整时复用缓存,避免每次升级。版本命令不能运行或检查超时则报告运行错误。
20
20
 
21
21
  需要安装时,脚本跳过 npm 生命周期脚本获取包,调用包内 `scripts/install-cli.js` 只安装 CLI。成功后缓存到 `~/.cache/pippit-tool-cli/xyq-skill/<平台>-<架构>/current`。不要求全局 npm 写入权限,也不安装或清理全局 Skill。
22
22
 
@@ -36,7 +36,9 @@ node "{baseDir}/scripts/ensure-cli.js"
36
36
 
37
37
  ## Canvas 运行时检查
38
38
 
39
- 画布任务改用 `node "{baseDir}/scripts/ensure-cli.js" --canvas`。除原有检查外,再验证五个原生资产子命令的帮助,并通过同一个 npm 包的 Node 入口真实执行离线 `canvas command list`,确认运行时可加载且含基本查询与节点创建能力。该检查不登录、不访问画布、不写入远端。
39
+ 画布任务改用 `node "{baseDir}/scripts/ensure-cli.js" --canvas`。要求 CLI 至少为 `1.0.50` 正式版,该版本起原生 `canvas apply` 和 npm 语义运行时均包含普通图片绑定校验;仅有同名命令不能证明旧版包含该修复。旧版、预发布版或无法识别的版本按不兼容处理,先查缓存,再至多安装一次;最新包仍不兼容则报错。普通媒体任务不受此最低版本限制。
40
+
41
+ 除原有检查外,再验证五个原生资产子命令的帮助,并通过同一个 npm 包的 Node 入口真实执行离线 `canvas command list`,确认运行时可加载且含基本查询与节点创建能力。该检查不登录、不访问画布、不写入远端。保留当前 Skill 的实际路径及返回的 `version`、`cli_path`、`canvas_entry`,便于故障定位;`Pippit-CLI/1.0` 是 User-Agent,不能作为 CLI 发行版本。
40
42
 
41
43
  成功额外返回 `canvas_entry`,供 `node "CANVAS_ENTRY" canvas command ...` 使用;`cli_path` 仍用于原生命令。语义操作的实际支持范围以当前目录为准,检查通过不代表所有业务操作或服务端权限都可用。
42
44
 
@@ -7,7 +7,7 @@
7
7
  3. **发现与查询**:`list → describe/schema` 定位操作。用 `get_snapshot` 查节点,用 `get_asset` 查具体资产;3D/多轨再查询其独立文档。`run` 是统一执行入口,其中 query/get 类操作是读取,不等于所有 run 都是写入。
8
8
  4. **准备输入**:使用查询所得的节点、子对象和资产 ID。外链素材通过 `canvas upload --url` 导入并保存 `pippit_asset_id`、`state` 与可能的 `warning`;也可复用 `upload-file --url` 返回的稳定 `asset_id`,再按 [URL 导入与资产复用](../commands/canvas-assets.md#url-import) 创建所需节点;已有资产直接复用,不重复导入。多轨 `expectedRevision` 来自最新查询。复杂输入写入 JSON 文件后传 `--file`;不要同时传 `--file` 和 `--input`。只操作用户指定范围。
9
9
  5. **预演与写入**:当前 schema 支持 `dryRun` 时可先预演,再在已有授权范围内执行真实修改。不能给所有命令强加此字段:领域命令的 dryRun 验证具体编辑,通用 `apply_mutations.dryRun` 不能替代领域预演。删除、恢复等操作仅按用户明确要求执行。
10
- 6. **验证结果**:检查退出码与 JSON 的 `ok`、错误信息,`ok=false` 即失败。`dryRun=true` 成功不代表落盘;实际写入成功后,用对应查询回读目标字段及关联关系,确认没有误改其他对象。
10
+ 6. **验证结果**:检查退出码与 JSON 的 `ok`、错误信息,`ok=false` 即失败。`dryRun=true` 成功不代表落盘;实际写入成功后,用对应查询回读目标字段及关联关系,确认没有误改其他对象。素材绑定还须按[图片绑定契约](../commands/canvas-assets.md#低层事务的使用边界)核对节点类型、字段位置和素材身份:普通 `biz/image` 的 `content.pippitAssetId` 必须是所选图片素材,`content.assetId` 必须匹配该素材查询的 `Image.asset_id`。保存 ACK 只证明事务被接受,不证明参考素材可用。
11
11
  7. **反馈交付**:说明已验证的画布变化,保留已有/返回的画布链接及目标 ID。只完成文档编辑时不要声称图片/视频已生成;确有媒体文件待交付时遵循 [媒体交付标准](async-delivery.md)。
12
12
 
13
13
  语义执行骨架(占位符替换为当前目录中的命令、真实 ID 和已准备的输入文件):
@@ -17,3 +17,5 @@ node "CANVAS_ENTRY" canvas command run COMMAND_NAME --canvas-id CANVAS_ASSET_ID
17
17
  ```
18
18
 
19
19
  写请求失败或返回“未确认事务已隔离”时保留错误上下文,先回读状态,不自动重跑、不删除本地恢复记录。多轨版本冲突需重新查询并基于新状态准备操作;检查点恢复会改变画布,不能作为所有失败的自动回滚方式。
20
+
21
+ 后续任务要把节点用于生视频参考时,先在原空间回读并完成上述核对。绑定缺失、错层或素材未就绪时停止提交,明确指出节点和缺失字段;保留原始错误与 LogID,不反复重试或手改上传状态。`node asset resource not ready` 表示素材预处理未就绪;即使外层显示“服务器繁忙”,也不能据此把重复生成当作恢复方式。