@pippit-dev/cli 1.0.22 → 1.0.24

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
@@ -39,53 +39,58 @@
39
39
 
40
40
  - 生成:文生图、文生视频、图生视频、视频续写。
41
41
  - 编辑:局部修改、元素替换、镜头调整、风格迁移。
42
- - 复杂创作:一句话生成短剧、复刻视频/TVC/宣传片、音乐 MV 生成、产品展示片制作。
42
+ - 复杂创作:复刻视频/TVC/宣传片、音乐 MV 生成、产品展示片制作。
43
43
 
44
44
  ### 配置
45
45
 
46
- 所有 `xyq-skill` 脚本都使用 Bearer 令牌鉴权:
46
+ 单独上传 `xyq-skill` ZIP 时,保留 Skill 内的 `scripts/ensure-cli.js`。每次开始执行 Skill 任务先运行:
47
47
 
48
48
  ```bash
49
- export XYQ_ACCESS_KEY="<access-key>"
49
+ node /path/to/xyq-skill/scripts/ensure-cli.js
50
50
  ```
51
51
 
52
- 可选 API 地址:
52
+ 环境需要 Node.js 16+ 和 Python 3;首次安装或自动升级时需要 npm、`curl`、系统解压工具(macOS/Linux 的 `tar`,Windows 的 PowerShell)及访问 npm 源和 GitHub Release 的网络。脚本优先复用 PATH 或自身缓存中命令齐全的 CLI;均不存在或缺少必需命令时获取 `@pippit-dev/cli@latest`,安装到 `~/.cache/pippit-tool-cli/xyq-skill/<平台>-<架构>/current`,返回 `{ "cli_path": "CLI绝对路径", "version": "实际安装版本" }`。后续示例中的 `pippit-tool-cli` 替换为该绝对路径;同一任务内复用,不在轮询时重复安装。已有 CLI 缺少关键命令时自动升级;升级后的缓存可被后续任务复用,避免 PATH 旧版本触发重复下载。每次调用最多下载安装一次,升级失败保留原安装;最新版本仍缺少必需命令时报告阻塞。
53
+
54
+ 新入口 `node scripts/install-cli.js` 只安装 npm 包对应版本的 CLI 二进制,不安装或清理全局 Skill。ZIP 安装脚本先以 `--ignore-scripts` 获取 npm 包,再调用这个入口。发布包含新入口的 npm 包及对应 GitHub Release 后,ZIP 的最新版本安装流程才能完整使用。
55
+
56
+ `submit-run` 和 `upload-file` 使用原生 CLI 登录凭证(`pippit-tool-cli login`),也可通过 `XYQ_ACCESS_KEY` 显式覆盖。保留的查询脚本 `get_thread.py` 仍需配置同一用户的 Bearer 凭证:
53
57
 
54
58
  ```bash
55
- export XYQ_OPENAPI_BASE="https://xyq.jianying.com"
56
- # 或
57
- export XYQ_BASE_URL="https://xyq.jianying.com"
59
+ export XYQ_ACCESS_KEY="<access-key>"
58
60
  ```
59
61
 
62
+ CLI 和 Python 脚本携带用户密钥的 API 请求地址固定为 `https://xyq.jianying.com`,不接受 `XYQ_OPENAPI_BASE` / `XYQ_BASE_URL` 覆盖。CLI 拒绝 API 跨域重定向,Python API 脚本禁止自动重定向。上传只在 Authorization 请求头携带密钥。
63
+
60
64
  ### 创建会话 / 发送消息
61
65
 
62
66
  ```bash
63
67
  # 创建新会话
64
- python3 skills/xyq-nest-skill/scripts/submit_run.py --message "生一个动漫视频"
68
+ pippit-tool-cli submit-run --message "生一个动漫视频"
65
69
 
66
70
  # 向已有会话发送消息
67
- python3 skills/xyq-nest-skill/scripts/submit_run.py \
71
+ pippit-tool-cli submit-run \
68
72
  --message "再生成一个故事视频" \
69
73
  --thread-id THREAD_ID
70
74
 
71
75
  # 携带参考文件发送
72
- python3 skills/xyq-nest-skill/scripts/submit_run.py \
76
+ pippit-tool-cli submit-run \
73
77
  --message "参考这个视频做修改" \
74
- --asset-ids asset_id1 asset_id2
78
+ --asset-ids asset_id1 --asset-ids asset_id2
75
79
  ```
76
80
 
77
81
  | 参数 | 必填 | 说明 |
78
82
  |------|------|------|
79
- | `--message` | 是 | 创作指令内容。 |
83
+ | `--message` | 是 | 非空白的创作指令,原样发送。 |
80
84
  | `--thread-id` | 否 | 已有会话 ID,不传则创建新会话。 |
81
- | `--asset-ids` | 否 | 资产 ID 列表,支持多个。 |
85
+ | `--asset-ids` | 否 | 每次传一个资产 ID;多个素材重复该参数。 |
82
86
 
83
87
  返回示例:
84
88
 
85
89
  ```json
86
90
  {
87
91
  "thread_id": "90f05e0c-...",
88
- "run_id": "abc123-..."
92
+ "run_id": "abc123-...",
93
+ "web_thread_link": "https://xyq.jianying.com/..."
89
94
  }
90
95
  ```
91
96
 
@@ -108,18 +113,20 @@ python3 skills/xyq-nest-skill/scripts/get_thread.py \
108
113
 
109
114
  ### 上传文件
110
115
 
116
+ 使用顶层 `upload-file --path`,每次上传一个本地图片、视频或 MP3/WAV 音频文件,文件须小于 500 MB(500000000 字节)。成功返回 `{"asset_id":"..."}`,可直接传给 `submit-run --asset-ids`。
117
+
111
118
  ```bash
112
119
  # 上传图片
113
- python3 skills/xyq-nest-skill/scripts/upload_file.py /path/to/image.png
120
+ pippit-tool-cli upload-file --path /path/to/image.png
114
121
 
115
122
  # 上传视频
116
- python3 skills/xyq-nest-skill/scripts/upload_file.py /path/to/video.mp4
123
+ pippit-tool-cli upload-file --path /path/to/video.mp4
117
124
 
118
125
  # 上传音频
119
- python3 skills/xyq-nest-skill/scripts/upload_file.py /path/to/audio.mp3
126
+ pippit-tool-cli upload-file --path /path/to/audio.mp3
120
127
  ```
121
128
 
122
- 仅支持 `image/*`、`video/*` 和 `.mp3/.wav` 音频文件,单文件大小限制 200 MB。
129
+ 仅支持 `image/*`、`video/*` 和 `.mp3/.wav` 音频文件,单文件大小限制 500 MB。
123
130
 
124
131
  返回示例:
125
132
 
@@ -131,65 +138,71 @@ python3 skills/xyq-nest-skill/scripts/upload_file.py /path/to/audio.mp3
131
138
 
132
139
  ### 下载结果
133
140
 
141
+ 每个产物 URL 调用一次 CLI,`--output-path` 必须包含文件名:
142
+
134
143
  ```bash
135
- python3 skills/xyq-nest-skill/scripts/download_results.py \
136
- --urls URL1 URL2 URL3 \
137
- --output-dir ./xyq_output \
138
- --prefix "storyboard" \
139
- --workers 5
144
+ pippit-tool-cli download-result \
145
+ --url "URL1" \
146
+ --output-path "./xyq_output/storyboard_01.png"
140
147
  ```
141
148
 
142
149
  | 参数 | 必填 | 说明 |
143
150
  |------|------|------|
144
- | `--urls` | 是 | 要下载的 URL 列表。 |
145
- | `--output-dir` | | 输出目录,默认 `./xyq_output`。 |
146
- | `--prefix` | 否 | 文件名前缀,例如 `storyboard_01.png`。 |
147
- | `--workers` | 否 | 并行下载线程数,默认 `5`。 |
151
+ | `--url` | 是 | 单个产物的下载 URL |
152
+ | `--output-path` | | 包含文件名的本地目标路径。 |
153
+ | `--updated-at` | 否 | 远端文件真实更新时间(Unix 秒),用于判断是否覆盖已有文件。 |
154
+ | `--workers` | 否 | 下载 worker 数,默认 `5`;当前单 URL 调用实际只使用一个 worker。 |
148
155
 
149
- 返回示例:
156
+ Skill 沿用用户指定的输出目录,未指定时使用 `./xyq_output`,按 URL 列表顺序从 `01` 编号,组成 `前缀_01.ext`(无前缀时为 `01.ext`)。扩展名优先取 URL 查询参数 `filename`,其次取 URL 路径,无法取得时使用 `.bin`。多文件逐项调用,可最多并行执行 5 个命令;重试保持原目标路径。
157
+
158
+ 下载成功返回示例:
150
159
 
151
160
  ```json
152
161
  {
153
- "output_dir": "./xyq_output",
154
- "downloaded": ["./xyq_output/storyboard_01.png"],
155
- "total": 1
162
+ "output_path": "./xyq_output/storyboard_01.png",
163
+ "downloaded": ["./xyq_output/storyboard_01.png"]
156
164
  }
157
165
  ```
158
166
 
167
+ 未传 `--updated-at` 时,CLI 默认跳过已有文件,返回 `already_exist`,此时 `downloaded` 为 `null`;传入真实更新时间后,仅当本地文件修改时间早于该时间时覆盖更新。跳过不代表已校验本地内容与远端一致。
168
+
169
+ 下载失败时命令以非零退出码返回错误,不保证输出 JSON。Skill 汇总各次调用的下载成功、已存在跳过和失败项,只对失败项重试一次;仍有失败时明确报告未完整交付。
170
+
159
171
  ### 典型示例
160
172
 
161
173
  文生视频:
162
174
 
163
175
  ```text
164
- 1. submit_run.py --message "生成一个赛博朋克风格的城市夜景视频"
176
+ 1. pippit-tool-cli submit-run --message "生成一个赛博朋克风格的城市夜景视频"
165
177
  2. 每 10 秒轮询:
166
178
  get_thread.py --thread-id THREAD_ID --run-id RUN_ID --after-seq SEQUENCE
167
179
  3. 拿到产物 URL 后下载:
168
- download_results.py --urls URL1 URL2 --output-dir ./output --prefix "cyberpunk"
180
+ pippit-tool-cli download-result --url "URL1" --output-path "./output/cyberpunk_01.mp4"
181
+ pippit-tool-cli download-result --url "URL2" --output-path "./output/cyberpunk_02.mp4"
169
182
  ```
170
183
 
171
184
  编辑已有视频:
172
185
 
173
186
  ```text
174
- 1. upload_file.py /path/to/video.mp4
175
- 2. submit_run.py --message "把背景换成星空" --asset-ids asset_id
187
+ 1. pippit-tool-cli upload-file --path /path/to/video.mp4
188
+ 2. pippit-tool-cli submit-run --message "把背景换成星空" --asset-ids asset_id
176
189
  3. 按文生视频流程轮询和下载。
177
190
  ```
178
191
 
179
192
  多参考图/视频生成:
180
193
 
181
194
  ```text
182
- 1. upload_file.py /path/to/ref1.png
183
- 2. upload_file.py /path/to/ref2.png
184
- 3. upload_file.py /path/to/ref3.mp4
185
- 4. submit_run.py --message "根据参考图和视频生成科普故事视频" --asset-ids asset_id1 asset_id2 asset_id3
195
+ 1. pippit-tool-cli upload-file --path /path/to/ref1.png
196
+ 2. pippit-tool-cli upload-file --path /path/to/ref2.png
197
+ 3. pippit-tool-cli upload-file --path /path/to/ref3.mp4
198
+ 4. pippit-tool-cli submit-run --message "根据参考图和视频生成科普故事视频" --asset-ids asset_id1 --asset-ids asset_id2 --asset-ids asset_id3
186
199
  5. 按文生视频流程轮询和下载。
187
200
  ```
188
201
 
189
202
  在已有会话中追加需求:
190
203
 
191
204
  ```text
192
- 1. submit_run.py --message "把刚才的视频加个片头" --thread-id EXISTING_THREAD_ID
205
+ 1. pippit-tool-cli submit-run --message "把刚才的视频加个片头" --thread-id EXISTING_THREAD_ID
193
206
  2. 使用新的 run_id 轮询和下载。
194
207
  ```
195
208
 
package/checksums.txt CHANGED
@@ -1,6 +1,6 @@
1
- 0ff3b8e87e45b5c39546b74588f7c45ffe5d463cc3618807855f498ed8bfa733 pippit-tool-cli-1.0.22-darwin-amd64.tar.gz
2
- 1deea5a6c69b88146319eeca111cb121ffa24d28253a5d043b2985846ebeb602 pippit-tool-cli-1.0.22-darwin-arm64.tar.gz
3
- 823e08be7d8a2a36176b95dc01a1b12d2dd50edb80901467629507a9ac5ff245 pippit-tool-cli-1.0.22-linux-amd64.tar.gz
4
- c286697382d43023657bca553c2ad5e34181e95329c4c702323eef00f6d8b279 pippit-tool-cli-1.0.22-linux-arm64.tar.gz
5
- ec53eae18219ce6456eebc43ba607f646c323b7e7f852d99d7e596e04fbd5591 pippit-tool-cli-1.0.22-windows-amd64.zip
6
- a913cc47ef12eaa71ffb7de4d4e58286351491fa3109d87fa62fe84001dcc1a4 pippit-tool-cli-1.0.22-windows-arm64.zip
1
+ 08c56aac8cb2c0726316f6e98de37a180214a9fee697020180662c9b9c1c05b8 pippit-tool-cli-1.0.24-darwin-amd64.tar.gz
2
+ cf98d12598e7f092e907e51104504252d10de20550649d9696fbf2870cdfa146 pippit-tool-cli-1.0.24-darwin-arm64.tar.gz
3
+ f32995b36d916c900b11c846ff5654e60bfb2045bfb56475224f9c4838f05f8a pippit-tool-cli-1.0.24-linux-amd64.tar.gz
4
+ f58545b97f6f809daff8d64481f3292f11e188d9db66fc59ac6f7dcca0bdd5f9 pippit-tool-cli-1.0.24-linux-arm64.tar.gz
5
+ fb274b91bbbbc06e12f1db3bc8f3ba78e7f216ded7560f5f64fe21824a9c4046 pippit-tool-cli-1.0.24-windows-amd64.zip
6
+ 02884405f4f7add1b34b2e41048d0cc8bf7b96f072abe17826dfad737665127d pippit-tool-cli-1.0.24-windows-arm64.zip
package/cmd/root.go CHANGED
@@ -56,7 +56,7 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm
56
56
  root := &cobra.Command{
57
57
  Use: "pippit-tool-cli",
58
58
  Short: "Pippit CLI",
59
- Long: "Pippit CLI generates and processes videos and images, queries credit balances, submits short-drama workflows, downloads generated assets, and updates the installed CLI package.\n\n" +
59
+ Long: "Pippit CLI generates and processes videos and images, queries credit balances, submits creative conversations and short-drama workflows, downloads generated assets, and updates the installed CLI package.\n\n" +
60
60
  canvascmd.CommandDiscoveryHelp + "\n\nInstallation help (npm launcher): pippit-tool-cli install --help",
61
61
  Version: version.Current(),
62
62
  SilenceUsage: true,
@@ -73,6 +73,8 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm
73
73
  root.AddCommand(newDownloadResultCommand(stdout, stderr, runner))
74
74
  root.AddCommand(newGetCreditBalanceCommand(stdout, stderr, runner))
75
75
  root.AddCommand(newGetThreadCommand(stdout, stderr, runner))
76
+ root.AddCommand(newSubmitRunCommand(stdout, stderr, runner))
77
+ root.AddCommand(newUploadFileCommand(stdout, stderr, runner))
76
78
  root.AddCommand(newListThreadFileCommand(stdout, stderr, runner))
77
79
  root.AddCommand(generate_image.NewCommand(stdout, stderr, runner))
78
80
  root.AddCommand(generate_video.NewCommand(stdout, stderr, runner))
@@ -0,0 +1,40 @@
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "io"
6
+ "strings"
7
+
8
+ "github.com/Pippit-dev/pippit-cli/internal/common"
9
+ "github.com/spf13/cobra"
10
+ )
11
+
12
+ func newSubmitRunCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
13
+ var body struct {
14
+ Message string `json:"message"`
15
+ ThreadID string `json:"thread_id,omitempty"`
16
+ AssetIDs []string `json:"asset_ids,omitempty"`
17
+ }
18
+ cmd := &cobra.Command{
19
+ Use: "submit-run",
20
+ Short: "Create a creative conversation or send a message to an existing thread",
21
+ Args: cobra.NoArgs,
22
+ RunE: withErrorLog("submit-run", nil, func(cmd *cobra.Command, _ []string) error {
23
+ if strings.TrimSpace(body.Message) == "" {
24
+ return fmt.Errorf("缺少必填参数 --message")
25
+ }
26
+ // Preserve the original message and identifiers; the backend owns routing.
27
+ result, err := common.SubmitRun(cmd.Context(), "submit-run", body, runner)
28
+ if err != nil {
29
+ return err
30
+ }
31
+ return common.WriteJSON(stdout, result)
32
+ }),
33
+ }
34
+ cmd.SetOut(stdout)
35
+ cmd.SetErr(stderr)
36
+ cmd.Flags().StringVar(&body.Message, "message", "", "original message to send (required)")
37
+ cmd.Flags().StringVar(&body.ThreadID, "thread-id", "", "existing thread ID; omit to create a new thread")
38
+ cmd.Flags().StringArrayVar(&body.AssetIDs, "asset-ids", nil, "asset ID to attach; repeat for multiple assets")
39
+ return cmd
40
+ }
@@ -0,0 +1,103 @@
1
+ package cmd
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "reflect"
9
+ "strings"
10
+ "testing"
11
+
12
+ "github.com/Pippit-dev/pippit-cli/internal/config"
13
+ )
14
+
15
+ func TestSubmitRunCommandPreservesScriptContract(t *testing.T) {
16
+ for _, existing := range []bool{false, true} {
17
+ t.Run(map[bool]string{false: "new", true: "existing_with_assets"}[existing], func(t *testing.T) {
18
+ message := " 根据参考素材生成视频\n保留原文 "
19
+ want := map[string]any{"message": message}
20
+ args := []string{"submit-run", "--message", message}
21
+ if existing {
22
+ want["thread_id"] = "skill_original_thread"
23
+ want["asset_ids"] = []any{"asset_original_1", "asset_original_2"}
24
+ args = append(args, "--thread-id", "skill_original_thread", "--asset-ids", "asset_original_1", "--asset-ids", "asset_original_2")
25
+ }
26
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27
+ if r.Method != http.MethodPost || r.URL.Path != config.SubmitRunPath {
28
+ t.Errorf("request = %s %s", r.Method, r.URL.Path)
29
+ }
30
+ if r.Header.Get("Authorization") != "Bearer test-token" {
31
+ t.Error("missing bearer authorization")
32
+ }
33
+ var got map[string]any
34
+ if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
35
+ t.Error(err)
36
+ }
37
+ if !reflect.DeepEqual(got, want) {
38
+ t.Errorf("body = %#v, want %#v", got, want)
39
+ }
40
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"run":{"thread_id":"thread_1","run_id":"run_1"},"web_thread_link":"https://xyq.jianying.com/thread_1"}}`))
41
+ }))
42
+ defer server.Close()
43
+ var stdout, stderr bytes.Buffer
44
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
45
+ root.SetArgs(args)
46
+ if err := root.Execute(); err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ if got := stdout.String(); got != "{\"thread_id\":\"thread_1\",\"run_id\":\"run_1\",\"web_thread_link\":\"https://xyq.jianying.com/thread_1\"}\n" {
50
+ t.Fatalf("unexpected output: %s", got)
51
+ }
52
+ })
53
+ }
54
+ }
55
+
56
+ func TestSubmitRunCommandValidatesBeforeRequest(t *testing.T) {
57
+ for _, args := range [][]string{
58
+ {"submit-run"},
59
+ {"submit-run", "--message", " \n "},
60
+ {"submit-run", "--message", "hello", "unexpected"},
61
+ } {
62
+ var stdout, stderr bytes.Buffer
63
+ root := newRootCommand(&stdout, &stderr, nil)
64
+ root.SetArgs(args)
65
+ if err := root.Execute(); err == nil {
66
+ t.Fatalf("Execute(%v) should fail before accessing the runner", args)
67
+ }
68
+ }
69
+ }
70
+
71
+ func TestSubmitRunHelpDoesNotRequireCredentials(t *testing.T) {
72
+ var stdout, stderr bytes.Buffer
73
+ root := newRootCommand(&stdout, &stderr, nil)
74
+ root.SetArgs([]string{"submit-run", "--help"})
75
+ if err := root.Execute(); err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ for _, flag := range []string{"--message", "--thread-id", "--asset-ids"} {
79
+ if !strings.Contains(stdout.String(), flag) {
80
+ t.Errorf("help missing %s", flag)
81
+ }
82
+ }
83
+ }
84
+
85
+ func TestSubmitRunCommandRejectsInvalidResponse(t *testing.T) {
86
+ for _, response := range []string{
87
+ `{"ret":"1","errmsg":"rejected"}`,
88
+ `{"ret":"0","data":{"run":{"run_id":"run_1"}}}`,
89
+ `{"ret":"0","data":{"run":{"thread_id":"thread_1"}}}`,
90
+ } {
91
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
92
+ _, _ = w.Write([]byte(response))
93
+ }))
94
+ var stdout, stderr bytes.Buffer
95
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
96
+ root.SetArgs([]string{"submit-run", "--message", "hello"})
97
+ err := root.Execute()
98
+ server.Close()
99
+ if err == nil || stdout.Len() != 0 {
100
+ t.Fatalf("response %s: error = %v, stdout = %s", response, err, stdout.String())
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,64 @@
1
+ package cmd
2
+
3
+ import (
4
+ "fmt"
5
+ "io"
6
+ "mime"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+
11
+ "github.com/Pippit-dev/pippit-cli/internal/common"
12
+ "github.com/spf13/cobra"
13
+ )
14
+
15
+ const maxMediaUploadBytes int64 = 500 * 1000 * 1000
16
+
17
+ func newUploadFileCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
18
+ var opts common.UploadFileOptions
19
+ cmd := &cobra.Command{
20
+ Use: "upload-file",
21
+ Short: "Upload an image, video, or MP3/WAV audio file",
22
+ Args: cobra.NoArgs,
23
+ RunE: withErrorLog("upload-file", nil, func(cmd *cobra.Command, _ []string) error {
24
+ opts.Path = strings.TrimSpace(opts.Path)
25
+ if err := validateMediaUpload(opts.Path); err != nil {
26
+ return err
27
+ }
28
+ result, err := common.UploadFile(cmd.Context(), opts, runner)
29
+ if err != nil {
30
+ return err
31
+ }
32
+ return common.WriteJSON(stdout, result)
33
+ }),
34
+ }
35
+ cmd.SetOut(stdout)
36
+ cmd.SetErr(stderr)
37
+ cmd.Flags().StringVar(&opts.Path, "path", "", "local media file to upload (required, less than 500 MB)")
38
+ return cmd
39
+ }
40
+
41
+ func validateMediaUpload(path string) error {
42
+ if path == "" {
43
+ return fmt.Errorf("缺少必填参数 --path")
44
+ }
45
+ info, err := os.Stat(path)
46
+ if err != nil {
47
+ return fmt.Errorf("获取上传文件信息失败: %w", err)
48
+ }
49
+ if !info.Mode().IsRegular() {
50
+ return fmt.Errorf("上传路径必须是普通文件")
51
+ }
52
+ if info.Size() >= maxMediaUploadBytes {
53
+ return fmt.Errorf("上传文件必须小于 500 MB(500000000 字节)")
54
+ }
55
+ ext := strings.ToLower(filepath.Ext(path))
56
+ contentType := mime.TypeByExtension(ext)
57
+ if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
58
+ return nil
59
+ }
60
+ if ext == ".mp3" || ext == ".wav" {
61
+ return nil
62
+ }
63
+ return fmt.Errorf("不支持的文件类型 %q,仅支持图片、视频和 .mp3/.wav 音频", ext)
64
+ }
@@ -0,0 +1,155 @@
1
+ package cmd
2
+
3
+ import (
4
+ "bytes"
5
+ "io"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "testing"
12
+
13
+ "github.com/Pippit-dev/pippit-cli/internal/config"
14
+ )
15
+
16
+ func TestUploadFileMediaContract(t *testing.T) {
17
+ for _, name := range []string{"image.png", "image.WEBP", "video.mp4", "audio.mp3", "audio.WAV"} {
18
+ t.Run(name, func(t *testing.T) {
19
+ path := filepath.Join(t.TempDir(), name)
20
+ if err := os.WriteFile(path, []byte("media-content"), 0o600); err != nil {
21
+ t.Fatal(err)
22
+ }
23
+ calls := 0
24
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25
+ calls++
26
+ if r.Method != http.MethodPost || r.URL.Path != config.UploadFilePath {
27
+ t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
28
+ }
29
+ if r.Header.Get("Authorization") != "Bearer test-token" {
30
+ t.Error("missing bearer authorization")
31
+ }
32
+ body, err := io.ReadAll(r.Body)
33
+ if err != nil {
34
+ t.Error(err)
35
+ return
36
+ }
37
+ if bytes.Contains(body, []byte("test-token")) || bytes.Contains(body, []byte(`name="accessKey"`)) {
38
+ t.Error("credentials must not appear in multipart body")
39
+ }
40
+ r.Body = io.NopCloser(bytes.NewReader(body))
41
+ if err := r.ParseMultipartForm(1 << 20); err != nil {
42
+ t.Error(err)
43
+ return
44
+ }
45
+ defer r.MultipartForm.RemoveAll()
46
+ files := r.MultipartForm.File["file"]
47
+ if len(files) != 1 || files[0].Filename != name {
48
+ t.Errorf("unexpected file parts: %#v", files)
49
+ return
50
+ }
51
+ contentType := files[0].Header.Get("Content-Type")
52
+ prefix := "audio/"
53
+ if strings.HasPrefix(name, "image") {
54
+ prefix = "image/"
55
+ } else if strings.HasPrefix(name, "video") {
56
+ prefix = "video/"
57
+ }
58
+ if !strings.HasPrefix(contentType, prefix) {
59
+ t.Errorf("content type = %s, want %s", contentType, prefix)
60
+ }
61
+ file, err := files[0].Open()
62
+ if err != nil {
63
+ t.Error(err)
64
+ return
65
+ }
66
+ defer file.Close()
67
+ data, err := io.ReadAll(file)
68
+ if err != nil || string(data) != "media-content" {
69
+ t.Errorf("uploaded content = %q, err = %v", data, err)
70
+ }
71
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"pippit_asset_id":"asset_original"}}`))
72
+ }))
73
+ defer server.Close()
74
+ var stdout, stderr bytes.Buffer
75
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
76
+ root.SetArgs([]string{"upload-file", "--path", path})
77
+ if err := root.Execute(); err != nil {
78
+ t.Fatal(err)
79
+ }
80
+ if stdout.String() != "{\"asset_id\":\"asset_original\"}\n" || calls != 1 {
81
+ t.Fatalf("stdout = %q, requests = %d", stdout.String(), calls)
82
+ }
83
+ })
84
+ }
85
+ }
86
+
87
+ func TestValidateMediaUpload(t *testing.T) {
88
+ dir := t.TempDir()
89
+ for _, tc := range []struct {
90
+ name string
91
+ size int64
92
+ ok bool
93
+ }{
94
+ {"below-limit.png", maxMediaUploadBytes - 1, true},
95
+ {"at-limit.png", maxMediaUploadBytes, false},
96
+ {"over-limit.png", maxMediaUploadBytes + 1, false},
97
+ {"script.docx", 1, false},
98
+ {"audio.flac", 1, false},
99
+ {"file.unknown", 1, false},
100
+ } {
101
+ path := filepath.Join(dir, tc.name)
102
+ if err := os.WriteFile(path, nil, 0o600); err != nil {
103
+ t.Fatal(err)
104
+ }
105
+ // Sparse files exercise the exact size boundary without large allocations.
106
+ if err := os.Truncate(path, tc.size); err != nil {
107
+ t.Fatal(err)
108
+ }
109
+ if err := validateMediaUpload(path); (err == nil) != tc.ok {
110
+ t.Errorf("validateMediaUpload(%s) = %v, want accepted=%t", tc.name, err, tc.ok)
111
+ }
112
+ }
113
+ for _, path := range []string{"", dir, filepath.Join(dir, "missing.png")} {
114
+ if err := validateMediaUpload(path); err == nil {
115
+ t.Errorf("validateMediaUpload(%q) should fail", path)
116
+ }
117
+ }
118
+ }
119
+
120
+ func TestUploadFileCommandHelpAndArguments(t *testing.T) {
121
+ var stdout, stderr bytes.Buffer
122
+ root := newRootCommand(&stdout, &stderr, nil)
123
+ root.SetArgs([]string{"upload-file", "--help"})
124
+ if err := root.Execute(); err != nil || !strings.Contains(stdout.String(), "--path") {
125
+ t.Fatalf("help must work without credentials: %v, %s", err, stdout.String())
126
+ }
127
+ for _, args := range [][]string{{"upload-file"}, {"upload-file", "--path", " "}, {"upload-file", "unexpected"}} {
128
+ root := newRootCommand(&stdout, &stderr, nil)
129
+ root.SetArgs(args)
130
+ if err := root.Execute(); err == nil {
131
+ t.Errorf("Execute(%v) should fail", args)
132
+ }
133
+ }
134
+ }
135
+
136
+ func TestUploadFileRejectsInvalidResponse(t *testing.T) {
137
+ path := filepath.Join(t.TempDir(), "image.png")
138
+ if err := os.WriteFile(path, []byte("media"), 0o600); err != nil {
139
+ t.Fatal(err)
140
+ }
141
+ for _, response := range []string{`{"ret":"1","errmsg":"rejected"}`, `{"ret":"0","data":{"asset_id":"wrong_field"}}`} {
142
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
143
+ _, _ = io.Copy(io.Discard, r.Body)
144
+ _, _ = w.Write([]byte(response))
145
+ }))
146
+ var stdout, stderr bytes.Buffer
147
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
148
+ root.SetArgs([]string{"upload-file", "--path", path})
149
+ err := root.Execute()
150
+ server.Close()
151
+ if err == nil || stdout.Len() != 0 {
152
+ t.Errorf("response %s: error = %v, stdout = %s", response, err, stdout.String())
153
+ }
154
+ }
155
+ }
@@ -1,6 +1,6 @@
1
- 0ff3b8e87e45b5c39546b74588f7c45ffe5d463cc3618807855f498ed8bfa733 pippit-tool-cli-1.0.22-darwin-amd64.tar.gz
2
- 1deea5a6c69b88146319eeca111cb121ffa24d28253a5d043b2985846ebeb602 pippit-tool-cli-1.0.22-darwin-arm64.tar.gz
3
- 823e08be7d8a2a36176b95dc01a1b12d2dd50edb80901467629507a9ac5ff245 pippit-tool-cli-1.0.22-linux-amd64.tar.gz
4
- c286697382d43023657bca553c2ad5e34181e95329c4c702323eef00f6d8b279 pippit-tool-cli-1.0.22-linux-arm64.tar.gz
5
- ec53eae18219ce6456eebc43ba607f646c323b7e7f852d99d7e596e04fbd5591 pippit-tool-cli-1.0.22-windows-amd64.zip
6
- a913cc47ef12eaa71ffb7de4d4e58286351491fa3109d87fa62fe84001dcc1a4 pippit-tool-cli-1.0.22-windows-arm64.zip
1
+ 08c56aac8cb2c0726316f6e98de37a180214a9fee697020180662c9b9c1c05b8 pippit-tool-cli-1.0.24-darwin-amd64.tar.gz
2
+ cf98d12598e7f092e907e51104504252d10de20550649d9696fbf2870cdfa146 pippit-tool-cli-1.0.24-darwin-arm64.tar.gz
3
+ f32995b36d916c900b11c846ff5654e60bfb2045bfb56475224f9c4838f05f8a pippit-tool-cli-1.0.24-linux-amd64.tar.gz
4
+ f58545b97f6f809daff8d64481f3292f11e188d9db66fc59ac6f7dcca0bdd5f9 pippit-tool-cli-1.0.24-linux-arm64.tar.gz
5
+ fb274b91bbbbc06e12f1db3bc8f3ba78e7f216ded7560f5f64fe21824a9c4046 pippit-tool-cli-1.0.24-windows-amd64.zip
6
+ 02884405f4f7add1b34b2e41048d0cc8bf7b96f072abe17826dfad737665127d pippit-tool-cli-1.0.24-windows-arm64.zip
@@ -18,7 +18,7 @@ type UploadFileOptions struct {
18
18
  FileName string `json:"file_name"`
19
19
  }
20
20
 
21
- // UploadFileResult is the JSON envelope printed by `pippit-tool-cli short-drama +upload-file`.
21
+ // UploadFileResult is the shared JSON output for media and short-drama uploads.
22
22
  type UploadFileResult struct {
23
23
  AssetID string `json:"asset_id"`
24
24
  }
@@ -32,3 +32,13 @@ func TestLoadReadsAccessKey(t *testing.T) {
32
32
  t.Fatalf("AccessKey = %q, want trimmed token", cfg.AccessKey)
33
33
  }
34
34
  }
35
+
36
+ func TestLoadIgnoresUntrustedBaseURLEnvironment(t *testing.T) {
37
+ for _, name := range []string{"XYQ_OPENAPI_BASE", "XYQ_BASE_URL"} {
38
+ t.Setenv(name, "https://untrusted.example")
39
+ }
40
+ cfg := Load()
41
+ if cfg.BaseURL != "https://xyq.jianying.com" {
42
+ t.Fatalf("BaseURL must remain the production HTTPS origin")
43
+ }
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pippit-dev/cli",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
4
4
  "description": "Pippit CLI",
5
5
  "bin": {
6
6
  "pippit-tool-cli": "scripts/run.js"
@@ -9,7 +9,7 @@
9
9
  "postinstall": "node scripts/install.js",
10
10
  "prepack": "node scripts/prepare-canvas-runtime.js",
11
11
  "prepare:canvas-runtime": "node scripts/prepare-canvas-runtime.js",
12
- "test": "node scripts/version-check.test.js && node scripts/skills.test.js && node scripts/install-wizard.test.js && node scripts/run-help.test.js && node scripts/canvas-command.test.js && node scripts/canvas-command-schema.test.js && node scripts/canvas-command-guides.test.js && node scripts/prepare-canvas-runtime.test.js && node scripts/canvas-command-runtime.test.js && npm run test:canvas-operations && go test ./... && go vet ./...",
12
+ "test": "node scripts/version-check.test.js && node scripts/skills.test.js && node scripts/install-wizard.test.js && node scripts/install-cli.test.js && node scripts/run-help.test.js && node scripts/canvas-command.test.js && node scripts/canvas-command-schema.test.js && node scripts/canvas-command-guides.test.js && node scripts/prepare-canvas-runtime.test.js && node scripts/canvas-command-runtime.test.js && npm run test:canvas-operations && go test ./... && go vet ./...",
13
13
  "verify:canvas-runtime": "node scripts/prepare-canvas-runtime.js --check-only",
14
14
  "test:canvas-runtime": "node scripts/canvas-command-runtime.test.js",
15
15
  "test:canvas-operations": "node scripts/canvas-command-canvas-operations.test.js && node scripts/canvas-command-scene3d-operations.test.js && node scripts/canvas-command-timeline-operations.test.js"
@@ -36,6 +36,7 @@
36
36
  "internal",
37
37
  "skills",
38
38
  "scripts/install.js",
39
+ "scripts/install-cli.js",
39
40
  "scripts/install-wizard.js",
40
41
  "scripts/canvas-command.js",
41
42
  "scripts/canvas-command-discovery.js",