@pippit-dev/cli 1.0.46 → 1.0.48

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
@@ -17,6 +17,22 @@ x-tt-env = ""
17
17
 
18
18
  `tests/` 中的验证脚本随源码仓库维护,不包含在 npm 发布包中,也不接入正式 CLI 二进制入口。
19
19
 
20
+ ## 上传素材
21
+
22
+ 通用 `upload-file` 支持本地文件或服务端 URL 导入,`--path` 与 `--url` 必须且只能提供一个:
23
+
24
+ ```bash
25
+ pippit-tool-cli upload-file --path ./reference.png
26
+ pippit-tool-cli upload-file --url 'https://example.com/reference?signature=...'
27
+ pippit-tool-cli upload-file --url 'https://example.com/reference' --team-id 123456
28
+ ```
29
+
30
+ URL 模式只向已有上传接口发送 `url`,CLI 不下载、读取或缓存源素材;类型和内部文件名由服务端识别,无需提供 `asset_type` 或 `file_name`。支持服务端可访问的 HTTPS 图片、视频或音频直链,包括有效的签名 URL;链接不必有文件后缀,服务端拒绝自动重定向。URL 模式依赖服务端部署对应能力,素材受服务端格式、大小(当前 200 MiB)和审核限制。
31
+
32
+ 成功输出保持 `{"team_id":"0","asset_id":"..."}`,其中 `asset_id` 是服务端返回的 `pippit_asset_id`,后续复用此 ID。`upload-file` 不额外等待查询素材;导入失败不会自动重试,也不会回退成本地下载再上传。未拿到资产 ID 的超时应先检查结果,避免重复导入。批量调用需控制并发,不代表支持几百上千路同时导入。
33
+
34
+ `canvas upload` 同样支持 `--path` 或 `--url` 二选一,并保留素材可查询等待及 `state`、`locator`、`pippit_asset_id` 输出;其旧 `asset_id` 是来源 ID,不等同于 `upload-file.asset_id`。两种 URL 入口都需要服务端部署对应能力。`short-drama +upload-file` 保持原有本地文件用法。
35
+
20
36
  ## 技能列表
21
37
 
22
38
  本仓库在 `skills/` 目录下包含三个智能体技能:
@@ -24,12 +40,12 @@ x-tt-env = ""
24
40
  | 技能 | 说明 | 路径 |
25
41
  |-------|-------------|------|
26
42
  | `xyq-short-drama-skill` | 短剧工作流技能,支持提交创作任务、上传参考文件、查询进度、列出会话文件和下载产物。 | `skills/short-drama/` |
27
- | `xyq-skill` | 图片生成与参考图编辑、视频生成、视频超分与擦字幕、异步结果交付、个人或团队 Canvas 编辑、积分查询及登录授权。 | `skills/xyq-nest-skill/` |
43
+ | `xyq-skill` | 素材上传与 URL 导入、图片生成与参考图编辑、视频生成、视频超分与擦字幕、异步结果交付、个人或团队 Canvas 编辑、积分查询及登录授权。 | `skills/xyq-nest-skill/` |
28
44
  | `xyq-marketing-skill` | 商品图文营销一键成片:剧情广告、品牌大片、达人带货,含素材上传、进度查询与媒体下载。 | `skills/xyq-marketing-skill/` |
29
45
 
30
46
  ### 技能路由
31
47
 
32
- - 图片生成与参考图编辑、视频生成(含首尾帧和参考素材)、视频超分、擦字幕、结果查询、个人或团队 Canvas 编辑、积分和授权由 `xyq-skill` 处理。
48
+ - 素材上传与 URL 导入、图片生成与参考图编辑、视频生成(含首尾帧和参考素材)、视频超分、擦字幕、结果查询、个人或团队 Canvas 编辑、积分和授权由 `xyq-skill` 处理。
33
49
  - 短剧生成、续写、改写、人物设定、分集创作和短剧会话文件处理使用 `xyq-short-drama-skill`。
34
50
  - 商品图文营销成片、剧情广告、品牌大片和达人带货视频使用 `xyq-marketing-skill`;由公开营销 API 完成创作编排。
35
51
 
@@ -90,6 +106,7 @@ node skills/xyq-marketing-skill/scripts/marketing.js query --thread-id THREAD_ID
90
106
  | 操作 | CLI | 文档 |
91
107
  | --- | --- | --- |
92
108
  | 登录授权 | `status` / `login` / `logout` | [授权](skills/xyq-nest-skill/commands/auth.md) |
109
+ | 上传本地素材或导入 HTTPS URL 素材 | `upload-file` | [上传与导入](skills/xyq-nest-skill/commands/upload-file.md) |
93
110
  | 个人或团队 Canvas 画布与节点编辑 | `canvas` | [画布](skills/xyq-nest-skill/commands/canvas.md) |
94
111
  | 生图、参考图编辑 | `generate-image` | [图片](skills/xyq-nest-skill/commands/generate-image.md) |
95
112
  | 查看可用图片/视频模型、参数配置 | `model list` / `model describe` | [模型发现](skills/xyq-nest-skill/commands/model.md) |
@@ -149,6 +166,12 @@ pippit-tool-cli --version
149
166
  4. 验证通过后,在 GitHub Actions 的 `Promote canary to latest` workflow 中点击 `Run workflow`,填写已验证的 `X.Y.Z`。该 workflow 须先合入默认分支才会显示手动入口,并复用仓库已有的 `NPM_TOKEN`。
150
167
  5. 提升流程要求输入版本与当前 `canary` 一致,只移动 npm `latest` 指针并回读确认,不重新构建或发布 npm 包。如果 canary 已更新,需要验证新的候选版本后再提升。
151
168
 
169
+ GitHub Release 已成功、npm 发布失败时的补发步骤:
170
+
171
+ 1. 在 GitHub Actions 选择 `Republish npm from Release`,点击 `Run workflow`,填写已有的 `vX.Y.Z` tag。该 workflow 须先合入默认分支,复用已有 `NPM_TOKEN`。
172
+ 2. 流程检出指定 tag,校验源码版本、已发布的六个平台产物及 `checksums.txt`,复用这些二进制,只准备并发布 npm canary,不运行 GoReleaser。
173
+ 3. 目标必须高于当前 latest 和 canary,且 npm 上尚无该版本;发布前查询异常会停止。npm 接收发布后可能暂时返回 404,流程会有上限地重试读取版本与渠道,确认 canary 指向目标版本、latest 保持原值。回读超时只表示结果尚未确认,先核实 npm 实际处理状态,不连续重复发布,也不删除已有 Release 或重打 tag。
174
+
152
175
  维护者回滚步骤:
153
176
 
154
177
  1. 在 [GitHub Actions](https://github.com/Pippit-dev/cli/actions) 选择 `Rollback CLI`,点击 `Run workflow`。该 workflow 须先合入默认分支;复用 `NPM_TOKEN` 修改 npm 标签,使用只读 `GITHUB_TOKEN` 检查 Release。
@@ -157,7 +180,7 @@ pippit-tool-cli --version
157
180
  4. 写入前再次核对当前渠道,随后只移动所选 npm 标签并回读确认。不会重新构建、发包、修改 Git tag 或改变另一渠道。Actions 日志和运行摘要会记录目标及确认结果;写入或回读失败时,先用 `npm view @pippit-dev/cli dist-tags --prefer-online` 核实实际状态,再决定是否重试。
158
181
  5. 已安装问题版本的用户需要主动执行 `pippit-tool-cli update --channel stable`(回滚 canary 时使用 `--channel canary`)并核对 `pippit-tool-cli --version`。旧 CLI 可执行默认 `update` 获取 `latest`,但须确保未设置 `PIPPIT_CLI_INSTALL_PACKAGE` 覆盖变量。已运行的进程及 Skill 独立缓存中的 CLI 不会被渠道回滚自动替换。
159
182
 
160
- 发布、提升和回滚共用并发组,避免这些 workflow 同时修改渠道;不取消正在运行的任务。GitHub 默认只保留一个等待中的同组任务,连续触发时请检查是否有等待任务被替换。并发组不能锁住维护者在终端执行的 npm 写入,操作期间应避免其它渠道修改。此阶段不提供百分比放量或自动回滚,普通客户端也不会因指针变化而自动安装;显式指定问题版本的安装仍然可用。
183
+ 发布、补发、提升和回滚共用并发组,避免这些 workflow 同时修改渠道;不取消正在运行的任务。GitHub 默认只保留一个等待中的同组任务,连续触发时请检查是否有等待任务被替换。并发组不能锁住维护者在终端执行的 npm 写入,操作期间应避免其它渠道修改。此阶段不提供百分比放量或自动回滚,普通客户端也不会因指针变化而自动安装;显式指定问题版本的安装仍然可用。
161
184
 
162
185
  ## 短剧工作流技能
163
186
 
@@ -199,9 +222,12 @@ pippit-tool-cli canvas create --title "CLI Canvas" --wait
199
222
  pippit-tool-cli canvas allocate --count 3
200
223
  pippit-tool-cli canvas get --asset-id PIPPIT_ASSET_ID
201
224
  pippit-tool-cli canvas upload --path ./reference.png
225
+ pippit-tool-cli canvas upload --url 'https://example.com/reference?signature=...' --team-id 123456
202
226
  pippit-tool-cli canvas apply --project-id PROJECT_ID --file ./patch.json
203
227
  ```
204
228
 
229
+ `canvas upload` 的 `--path` 与 `--url` 必须且只能提供一个。URL 模式不在本机下载或缓存素材;`--timeout` 仍只控制上传返回后的素材查询等待,查询超时保留资产 ID 和 warning,不自动再次导入。上传成功不代表已插入画布。
230
+
205
231
  五个命令复用 `space use TEAM_ID` 保存的默认空间,也支持单次 `--team-id TEAM_ID`,个人空间使用 `0`。操作已有画布时显式使用其原 `--team-id`,CLI 不根据画布 ID 自动选择团队。`canvas get` 返回实际 `team_id`,本地账号可识别时还返回 `space_context`;`canvas command run` 据此固定后续子进程的空间与账号/API origin,期间切换默认空间不影响当前操作。本地恢复记录和检查点按账号、团队与画布隔离,原有个人记录保持可读。
206
232
 
207
233
  五个命令均输出单行 JSON,资源 ID 保持字符串。`allocate` 只预留 ID,实际资产仍由后续 `apply` transaction 创建。`create` 的 `request_id` 用于追踪,不是跨服务崩溃窗口的严格幂等键;写请求结果不明确时不要盲目重放,应先使用 `canvas get` 回读确认。`apply` 当前只接受一个 transaction,但该 transaction 可以包含多个 patches;CLI 会严格检查 transaction ACK 和每个目标资产的新版本。
@@ -281,7 +307,7 @@ pippit-tool-cli canvas command run xyq.generation.update_prompt \
281
307
  --input '{"nodeId":"TARGET_IMAGE_NODE_ID","prompt":"参考 <pippit-asset-id label=\"参考图\">PIPPIT_ASSET_ID_IN_DRAFT</pippit-asset-id> 的人物"}'
282
308
  ```
283
309
 
284
- 当前版本只解析已有画布候选与目标草稿中的引用;找不到的普通标签返回 `UNRESOLVED_PROMPT_REFERENCE`,不写入文档。仅拿到 `canvas upload` 返回的 ID,还不会自动查询并加入草稿。新独立素材 ID 的自动解析属于后续能力。已有独立素材草稿可由前端上传或素材库流程产生,视频生成可使用其中的图片、视频和音频。同一媒体 ID 若同时匹配到画布源节点,则遵循前端现有的节点优先规则建立关联。`canvas get --asset-id PIPPIT_ASSET_ID` 可查询外部素材,但查询本身不添加引用。
310
+ 当前版本只解析已有画布候选与目标草稿中的引用;找不到的普通标签返回 `UNRESOLVED_PROMPT_REFERENCE`,不写入文档。仅拿到 `upload-file` 或 `canvas upload` 返回的素材 ID,还不会自动查询并加入草稿。需要画布参考节点时,按 [URL 导入与资产复用](skills/xyq-nest-skill/commands/canvas-assets.md#url-import) 先关联素材创建节点,再通过真实节点引用。新独立素材 ID 的自动解析属于后续能力。已有独立素材草稿可由前端上传或素材库流程产生,视频生成可使用其中的图片、视频和音频。同一媒体 ID 若同时匹配到画布源节点,则遵循前端现有的节点优先规则建立关联。`canvas get --asset-id PIPPIT_ASSET_ID` 可查询外部素材,但查询本身不添加引用。
285
311
 
286
312
  该命令先在隔离文档上按前端原有顺序执行 SDK commands,再把实际补丁一次性提交,保留模型参数、已有引用及 caption/title/name 等展示字段。`dryRun:true` 只执行隔离预演,原文档和撤销历史不变;清空 prompt 不移除引用。节点引用由现有生成流程转换成 `node_asset_refs`,独立图片进入 `pippit_asset_ids`,视频生成的独立素材按类型进入 `images`、`videos`、`audios`。本命令不触发生成、上传或远端素材查询。不要用浅合并的 `update_asset.contentPatch.generation` 更新提示词,否则可能覆盖其他生成参数。
287
313
 
package/checksums.txt CHANGED
@@ -1,6 +1,6 @@
1
- a22b8aaebe337a2617f238dfcdd425a4133a9c63bebf812f15b834955881233b pippit-tool-cli-1.0.46-darwin-amd64.tar.gz
2
- a28e74b130adb63620bd3bb5d382d621e7dfc8a826c7e4663b1fba7f4a623449 pippit-tool-cli-1.0.46-darwin-arm64.tar.gz
3
- 6c7e1a7998e48de298bb1c0263ff26e06a9acd55c8469edc6a7731914109aa3d pippit-tool-cli-1.0.46-linux-amd64.tar.gz
4
- 1641f4cc8161e4b1f012211c690db07a3fd100ff18845130f77f603e06298cd4 pippit-tool-cli-1.0.46-linux-arm64.tar.gz
5
- 938743186947edac0e27627891066c946d99d8facc80f27b6395eaab88fda0f9 pippit-tool-cli-1.0.46-windows-amd64.zip
6
- 1f8903e4c94214e21c5e86556243a8e95175e076f41261da6210fb51fdca72c2 pippit-tool-cli-1.0.46-windows-arm64.zip
1
+ 718067da81039c982cdcb9579f50a06a83bbc7c7dd599ab447b50e8b8e2b13ac pippit-tool-cli-1.0.48-darwin-amd64.tar.gz
2
+ d0aa4e2272c8369fb615b82bbb390a3e575e3b3df39dffbb5d38c6fc8e7d67c3 pippit-tool-cli-1.0.48-darwin-arm64.tar.gz
3
+ 838f221cef34481f042a2fec32da0f015d5c61151f43eeb3460ceb94c05601fd pippit-tool-cli-1.0.48-linux-amd64.tar.gz
4
+ 1ff3d84ffe6a04bc6c5ed92d9e784308d8dc16fffc88037faa21665d9a74106c pippit-tool-cli-1.0.48-linux-arm64.tar.gz
5
+ 04400b90e115ba0d9d8e0d6917e8b69cfe980fa5601bd5c9bdb2625135036d5e pippit-tool-cli-1.0.48-windows-amd64.zip
6
+ e6a2f024b93abcc546007fe1c5e2fcaebed11ab64123943c30dda9f47f8c4b63 pippit-tool-cli-1.0.48-windows-arm64.zip
@@ -162,12 +162,16 @@ func newUploadCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Co
162
162
  var opts canvascore.UploadOptions
163
163
  cmd := &cobra.Command{
164
164
  Use: "upload",
165
- Short: "Upload a file to the selected space and wait until it is queryable",
165
+ Short: "Upload a local file or import an HTTPS URL and wait until the asset is queryable",
166
166
  Args: cobra.NoArgs,
167
167
  RunE: func(cmd *cobra.Command, _ []string) error {
168
168
  result, err := canvascore.Upload(cmd.Context(), opts, runner)
169
169
  if err != nil {
170
- logCanvasError("canvas upload", err, map[string]string{"file_name": filepath.Base(strings.TrimSpace(opts.Path))})
170
+ fields := map[string]string{}
171
+ if path := strings.TrimSpace(opts.Path); path != "" {
172
+ fields["file_name"] = filepath.Base(path)
173
+ }
174
+ logCanvasError("canvas upload", err, fields)
171
175
  return err
172
176
  }
173
177
  return common.WriteJSON(stdout, result)
@@ -176,7 +180,8 @@ func newUploadCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Co
176
180
  cmd.SetOut(stdout)
177
181
  cmd.SetErr(stderr)
178
182
  flags := cmd.Flags()
179
- flags.StringVar(&opts.Path, "path", "", "local file path to upload")
183
+ flags.StringVar(&opts.Path, "path", "", "local file path to upload (choose exactly one of --path or --url)")
184
+ flags.StringVar(&opts.URL, "url", "", "HTTPS media URL to import on the server (choose exactly one of --path or --url)")
180
185
  flags.DurationVar(&opts.PollInterval, "poll-interval", time.Second, "asset visibility polling interval")
181
186
  flags.DurationVar(&opts.WaitTimeout, "timeout", 2*time.Minute, "maximum asset visibility wait time")
182
187
  return cmd
@@ -0,0 +1,128 @@
1
+ package cmd
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "strings"
9
+ "testing"
10
+
11
+ canvascore "github.com/Pippit-dev/pippit-cli/internal/canvas"
12
+ )
13
+
14
+ func TestCanvasUploadURLContract(t *testing.T) {
15
+ const sourceURL = "https://assets.example.invalid/no-extension?signature=keep%2Fexact&x=1"
16
+ for _, teamID := range []string{"0", "12", "34"} {
17
+ t.Run(teamID, func(t *testing.T) {
18
+ uploads, queries := 0, 0
19
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20
+ wantTeamHeader := teamID
21
+ if teamID == "0" {
22
+ wantTeamHeader = ""
23
+ }
24
+ if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" ||
25
+ r.Header.Get("Authorization") != "Bearer test-token" || r.Header.Get("X-Team-Id") != wantTeamHeader {
26
+ t.Error("upload and query must retain JSON transport, authentication and the selected space")
27
+ }
28
+ switch r.URL.Path {
29
+ case canvascore.UploadPath:
30
+ uploads++
31
+ var body map[string]string
32
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
33
+ t.Error(err)
34
+ }
35
+ if len(body) != 1 || body["url"] != sourceURL {
36
+ t.Errorf("upload body = %#v, want only the unchanged URL", body)
37
+ }
38
+ _, _ = w.Write([]byte(`{"ret":"0","log_id":"upload-log","data":{"asset_id":"source-id","pippit_asset_id":"asset-url"}}`))
39
+ case canvascore.QueryPath:
40
+ queries++
41
+ var body struct {
42
+ IDs []string `json:"pippit_asset_ids"`
43
+ }
44
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.IDs) != 1 || body.IDs[0] != "asset-url" {
45
+ t.Errorf("query body = %#v, err = %v", body, err)
46
+ }
47
+ _, _ = w.Write([]byte(`{"ret":"0","log_id":"query-log","data":{"Assets":[{"PippitAssetID":"asset-url","SourceID":"source-id","AssetType":1,"Image":{"asset_id":"media-id"}}]}}`))
48
+ default:
49
+ t.Errorf("unexpected request: %s", r.URL.Path)
50
+ http.Error(w, "unexpected request", http.StatusBadRequest)
51
+ }
52
+ }))
53
+ defer server.Close()
54
+ var stdout, stderr bytes.Buffer
55
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
56
+ // An unreachable origin ensures only the API receives the source URL.
57
+ root.SetArgs([]string{"canvas", "upload", "--url", " " + sourceURL + " ", "--team-id", teamID})
58
+ if err := root.Execute(); err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ var result canvascore.UploadResult
62
+ if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
63
+ t.Fatal(err)
64
+ }
65
+ if uploads != 1 || queries != 1 || result.State != canvascore.StateReady || result.PollAttempts != 1 ||
66
+ result.AssetID != "source-id" || result.SourceID != "source-id" || result.PippitAssetID != "asset-url" ||
67
+ result.MediaAssetID != "media-id" || result.Locator.PippitAssetID != "asset-url" || result.Locator.MediaAssetID != "media-id" ||
68
+ result.LogID != "upload-log" || result.QueryLogID != "query-log" {
69
+ t.Fatalf("uploads=%d queries=%d result=%+v", uploads, queries, result)
70
+ }
71
+ if strings.Contains(stdout.String()+stderr.String(), "keep%2Fexact") {
72
+ t.Error("output exposed the source signature")
73
+ }
74
+ })
75
+ }
76
+ }
77
+
78
+ func TestCanvasUploadRejectsInvalidSourcesBeforeRequest(t *testing.T) {
79
+ clearDailyErrorLog(t)
80
+ calls := 0
81
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
82
+ calls++
83
+ http.Error(w, "unexpected request", http.StatusBadRequest)
84
+ }))
85
+ defer server.Close()
86
+ for _, tc := range []struct {
87
+ args []string
88
+ want string
89
+ }{
90
+ {nil, "必须且只能提供一个"},
91
+ {[]string{"--url", " "}, "必须且只能提供一个"},
92
+ {[]string{"--path", "missing.png", "--url", "https://example.com/media?signature=secret"}, "必须且只能提供一个"},
93
+ {[]string{"--url", "http://example.com/media"}, "HTTPS"},
94
+ {[]string{"--url", "file:///tmp/media.png"}, "HTTPS"},
95
+ {[]string{"--url", "https://user:secret@example.com/media"}, "HTTPS"},
96
+ {[]string{"--url", "https://example.com/media#secret"}, "HTTPS"},
97
+ {[]string{"--url", "https://example.com/%zz?signature=secret"}, "HTTPS"},
98
+ } {
99
+ var stdout, stderr bytes.Buffer
100
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
101
+ root.SetArgs(append([]string{"canvas", "upload"}, tc.args...))
102
+ err := root.Execute()
103
+ if err == nil || !strings.Contains(err.Error(), tc.want) {
104
+ t.Fatalf("error=%v, want %s", err, tc.want)
105
+ }
106
+ if strings.Contains(err.Error()+stderr.String(), "secret") || stdout.Len() != 0 || calls != 0 {
107
+ t.Fatal("invalid input exposed the URL or produced output/requests")
108
+ }
109
+ }
110
+ logs, err := json.Marshal(readDailyErrorLog(t))
111
+ if err != nil || bytes.Contains(logs, []byte("secret")) {
112
+ t.Fatalf("validation log exposed the source URL, marshal error=%v", err)
113
+ }
114
+ }
115
+
116
+ func TestCanvasUploadHelpExposesBothSources(t *testing.T) {
117
+ var stdout, stderr bytes.Buffer
118
+ root := newRootCommand(&stdout, &stderr, nil)
119
+ root.SetArgs([]string{"canvas", "upload", "--help"})
120
+ if err := root.Execute(); err != nil {
121
+ t.Fatal(err)
122
+ }
123
+ for _, flag := range []string{"--path", "--url", "--poll-interval", "--timeout"} {
124
+ if !strings.Contains(stdout.String(), flag) {
125
+ t.Errorf("help missing %s", flag)
126
+ }
127
+ }
128
+ }
@@ -18,13 +18,19 @@ func newUploadFileCommand(stdout, stderr io.Writer, runner *common.Runner) *cobr
18
18
  var opts common.UploadFileOptions
19
19
  cmd := &cobra.Command{
20
20
  Use: "upload-file",
21
- Short: "Upload an image, video, or MP3/WAV audio file",
21
+ Short: "Upload a local media file or import media from an HTTPS URL",
22
22
  Args: cobra.NoArgs,
23
23
  RunE: withErrorLog("upload-file", nil, func(cmd *cobra.Command, _ []string) error {
24
24
  opts.Path = strings.TrimSpace(opts.Path)
25
- if err := validateMediaUpload(opts.Path); err != nil {
25
+ opts.URL = strings.TrimSpace(opts.URL)
26
+ if err := opts.ValidateSource(); err != nil {
26
27
  return err
27
28
  }
29
+ if opts.Path != "" {
30
+ if err := validateMediaUpload(opts.Path); err != nil {
31
+ return err
32
+ }
33
+ }
28
34
  result, err := common.UploadFile(cmd.Context(), opts, runner)
29
35
  if err != nil {
30
36
  return err
@@ -34,7 +40,8 @@ func newUploadFileCommand(stdout, stderr io.Writer, runner *common.Runner) *cobr
34
40
  }
35
41
  cmd.SetOut(stdout)
36
42
  cmd.SetErr(stderr)
37
- cmd.Flags().StringVar(&opts.Path, "path", "", "local media file to upload (required, less than 500 MB)")
43
+ cmd.Flags().StringVar(&opts.Path, "path", "", "local media file to upload (less than 500 MB; choose exactly one of --path or --url)")
44
+ cmd.Flags().StringVar(&opts.URL, "url", "", "HTTPS media URL to import on the server (choose exactly one of --path or --url)")
38
45
  return cmd
39
46
  }
40
47
 
@@ -86,6 +86,90 @@ func TestUploadFileMediaContract(t *testing.T) {
86
86
  }
87
87
  }
88
88
 
89
+ func TestUploadFileURLContract(t *testing.T) {
90
+ const sourceURL = "https://assets.example.invalid/no-extension?X-Amz-Signature=keep%2Fexact&x=1"
91
+ for _, teamID := range []string{"0", "12", "34"} {
92
+ t.Run(teamID, func(t *testing.T) {
93
+ calls := 0
94
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95
+ calls++
96
+ if r.Method != http.MethodPost || r.URL.Path != config.UploadFilePath {
97
+ t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
98
+ }
99
+ wantTeamHeader := teamID
100
+ if teamID == "0" {
101
+ wantTeamHeader = ""
102
+ }
103
+ if r.Header.Get("X-Team-Id") != wantTeamHeader || r.Header.Get("Authorization") != "Bearer test-token" {
104
+ t.Error("URL upload lost selected space or authentication")
105
+ }
106
+ if r.Header.Get("Content-Type") != "application/json" {
107
+ t.Errorf("unexpected Content-Type: %s", r.Header.Get("Content-Type"))
108
+ }
109
+ var body map[string]string
110
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
111
+ t.Error(err)
112
+ }
113
+ if len(body) != 1 || body["url"] != sourceURL {
114
+ t.Errorf("request must contain only the unchanged URL: %#v", body)
115
+ }
116
+ _, _ = w.Write([]byte(`{"ret":"0","data":{"asset_id":"source-id","pippit_asset_id":"asset-url"}}`))
117
+ }))
118
+ defer server.Close()
119
+ var stdout, stderr bytes.Buffer
120
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
121
+ // The origin is deliberately unreachable: only the API may fetch it.
122
+ root.SetArgs([]string{"upload-file", "--url", " " + sourceURL + " ", "--team-id", teamID})
123
+ if err := root.Execute(); err != nil {
124
+ t.Fatal(err)
125
+ }
126
+ var output map[string]string
127
+ if err := json.Unmarshal(stdout.Bytes(), &output); err != nil || len(output) != 2 || output["asset_id"] != "asset-url" || output["team_id"] != teamID || calls != 1 {
128
+ t.Fatalf("stdout = %q, requests = %d, err = %v", stdout.String(), calls, err)
129
+ }
130
+ if strings.Contains(stdout.String()+stderr.String(), "keep%2Fexact") {
131
+ t.Error("command output exposed the source signature")
132
+ }
133
+ })
134
+ }
135
+ }
136
+
137
+ func TestUploadFileRejectsInvalidSourcesBeforeRequest(t *testing.T) {
138
+ calls := 0
139
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
140
+ calls++
141
+ http.Error(w, "unexpected request", http.StatusInternalServerError)
142
+ }))
143
+ defer server.Close()
144
+ for _, tc := range []struct {
145
+ args []string
146
+ want string
147
+ }{
148
+ {nil, "必须且只能提供一个"},
149
+ {[]string{"--url", " "}, "必须且只能提供一个"},
150
+ {[]string{"--path", "missing.png", "--url", "https://example.com/media"}, "必须且只能提供一个"},
151
+ {[]string{"--url", "http://example.com/media"}, "HTTPS"},
152
+ {[]string{"--url", "file:///tmp/media.png"}, "HTTPS"},
153
+ {[]string{"--url", "https://user:secret@example.com/media"}, "HTTPS"},
154
+ {[]string{"--url", "https://example.com/media#secret"}, "HTTPS"},
155
+ {[]string{"--url", "https://example.com/%zz?signature=secret"}, "HTTPS"},
156
+ } {
157
+ var stdout, stderr bytes.Buffer
158
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
159
+ root.SetArgs(append([]string{"upload-file"}, tc.args...))
160
+ err := root.Execute()
161
+ if err == nil || !strings.Contains(err.Error(), tc.want) {
162
+ t.Errorf("args=%v: error=%v, want %s", tc.args, err, tc.want)
163
+ }
164
+ if err != nil && strings.Contains(err.Error(), "secret") {
165
+ t.Error("validation error exposed URL credentials")
166
+ }
167
+ if stdout.Len() != 0 || calls != 0 {
168
+ t.Fatalf("invalid sources produced output or a request: stdout=%s requests=%d", stdout.String(), calls)
169
+ }
170
+ }
171
+ }
172
+
89
173
  func TestValidateMediaUpload(t *testing.T) {
90
174
  dir := t.TempDir()
91
175
  for _, tc := range []struct {
@@ -123,7 +207,7 @@ func TestUploadFileCommandHelpAndArguments(t *testing.T) {
123
207
  var stdout, stderr bytes.Buffer
124
208
  root := newRootCommand(&stdout, &stderr, nil)
125
209
  root.SetArgs([]string{"upload-file", "--help"})
126
- if err := root.Execute(); err != nil || !strings.Contains(stdout.String(), "--path") {
210
+ if err := root.Execute(); err != nil || !strings.Contains(stdout.String(), "--path") || !strings.Contains(stdout.String(), "--url") {
127
211
  t.Fatalf("help must work without credentials: %v, %s", err, stdout.String())
128
212
  }
129
213
  for _, args := range [][]string{{"upload-file"}, {"upload-file", "--path", " "}, {"upload-file", "unexpected"}} {
@@ -140,18 +224,20 @@ func TestUploadFileRejectsInvalidResponse(t *testing.T) {
140
224
  if err := os.WriteFile(path, []byte("media"), 0o600); err != nil {
141
225
  t.Fatal(err)
142
226
  }
143
- for _, response := range []string{`{"ret":"1","errmsg":"rejected"}`, `{"ret":"0","data":{"asset_id":"wrong_field"}}`} {
227
+ for _, response := range []string{`{"ret":"1","errmsg":"rejected","log_id":"upload-log"}`, `{"ret":"0","data":{"asset_id":"wrong_field"},"log_id":"upload-log"}`} {
144
228
  server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
145
229
  _, _ = io.Copy(io.Discard, r.Body)
146
230
  _, _ = w.Write([]byte(response))
147
231
  }))
148
- var stdout, stderr bytes.Buffer
149
- root := newTestRootCommand(t, &stdout, &stderr, server.URL)
150
- root.SetArgs([]string{"upload-file", "--path", path})
151
- err := root.Execute()
152
- server.Close()
153
- if err == nil || stdout.Len() != 0 {
154
- t.Errorf("response %s: error = %v, stdout = %s", response, err, stdout.String())
232
+ for _, source := range [][]string{{"--path", path}, {"--url", "https://example.com/media"}} {
233
+ var stdout, stderr bytes.Buffer
234
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
235
+ root.SetArgs(append([]string{"upload-file"}, source...))
236
+ err := root.Execute()
237
+ if err == nil || !strings.Contains(err.Error(), "upload-log") || stdout.Len() != 0 {
238
+ t.Errorf("response %s: error = %v, stdout = %s", response, err, stdout.String())
239
+ }
155
240
  }
241
+ server.Close()
156
242
  }
157
243
  }
@@ -0,0 +1,6 @@
1
+ 718067da81039c982cdcb9579f50a06a83bbc7c7dd599ab447b50e8b8e2b13ac pippit-tool-cli-1.0.48-darwin-amd64.tar.gz
2
+ d0aa4e2272c8369fb615b82bbb390a3e575e3b3df39dffbb5d38c6fc8e7d67c3 pippit-tool-cli-1.0.48-darwin-arm64.tar.gz
3
+ 838f221cef34481f042a2fec32da0f015d5c61151f43eeb3460ceb94c05601fd pippit-tool-cli-1.0.48-linux-amd64.tar.gz
4
+ 1ff3d84ffe6a04bc6c5ed92d9e784308d8dc16fffc88037faa21665d9a74106c pippit-tool-cli-1.0.48-linux-arm64.tar.gz
5
+ 04400b90e115ba0d9d8e0d6917e8b69cfe980fa5601bd5c9bdb2625135036d5e pippit-tool-cli-1.0.48-windows-amd64.zip
6
+ e6a2f024b93abcc546007fe1c5e2fcaebed11ab64123943c30dda9f47f8c4b63 pippit-tool-cli-1.0.48-windows-arm64.zip