@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 +30 -4
- package/checksums.txt +6 -6
- package/cmd/canvas/canvas.go +8 -3
- package/cmd/canvas_upload_test.go +128 -0
- package/cmd/upload_file.go +10 -3
- package/cmd/upload_file_test.go +95 -9
- package/dist/checksums.txt +6 -0
- package/dist/xyq-canvas-command-runtime.cjs +13 -13
- package/dist/xyq-canvas-command-runtime.cjs.sha256 +1 -1
- package/internal/canvas/upload.go +32 -25
- package/internal/canvas/upload_url_test.go +72 -0
- package/internal/common/upload_file.go +53 -29
- package/package.json +2 -2
- package/scripts/canvas-command-guides.js +1 -1
- package/skills/xyq-nest-skill/SKILL.md +5 -4
- package/skills/xyq-nest-skill/agents/openai.yaml +1 -1
- package/skills/xyq-nest-skill/commands/canvas-assets.md +13 -2
- package/skills/xyq-nest-skill/commands/canvas.md +2 -1
- package/skills/xyq-nest-skill/commands/upload-file.md +40 -0
- package/skills/xyq-nest-skill/scripts/ensure-cli.js +1 -1
- package/skills/xyq-nest-skill/scripts/install.md +3 -1
- package/skills/xyq-nest-skill/workflows/canvas-edit.md +1 -1
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` |
|
|
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
|
-
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
package/cmd/canvas/canvas.go
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|
package/cmd/upload_file.go
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
package/cmd/upload_file_test.go
CHANGED
|
@@ -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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|