@pippit-dev/cli 0.0.11 → 0.0.12

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
@@ -12,7 +12,7 @@ npx @pippit-dev/cli@latest install
12
12
  export XYQ_ACCESS_KEY="<access-key>"
13
13
  pippit-cli --version
14
14
  pippit-cli short-drama +submit-run --message "写一个赛博朋克短剧开头"
15
- pippit-cli short-drama +upload-file --path ./story.md
15
+ pippit-cli short-drama +upload-file --path ./reference.doc
16
16
  pippit-cli short-drama +get-thread --thread-id thread_123 --run-id run_456 --after-seq 0
17
17
  pippit-cli short-drama +download-result --output-path ./thread_123/results/result.mp4 --url URL
18
18
  ```
@@ -26,7 +26,7 @@ Submit a Run task for the short drama scene:
26
26
  export XYQ_ACCESS_KEY="<access-key>"
27
27
  go run . --version
28
28
  go run . short-drama +submit-run --message "写一个赛博朋克短剧开头"
29
- go run . short-drama +upload-file --path ./story.md
29
+ go run . short-drama +upload-file --path ./reference.doc
30
30
  go run . short-drama +get-thread --thread-id thread_123 --run-id run_456 --after-seq 0
31
31
  go run . short-drama +download-result --output-path ./thread_123/results/result.mp4 --url URL
32
32
  ```
@@ -35,9 +35,11 @@ go run . short-drama +download-result --output-path ./thread_123/results/result.
35
35
  `run_id`, and `web_thread_link`; `--message` is required.
36
36
  `+get-thread` calls
37
37
  `/api/biz/v1/skill/get_thread` and prints extracted `messages`.
38
+ `+upload-file` calls `/api/biz/v1/skill/upload_file` with
39
+ `multipart/form-data` and prints the returned `asset_id`.
40
+ Only `.doc` and `.txt` file extensions are supported.
38
41
  `+download-result` downloads the result URL to
39
- the `--output-path` file path. `+upload-file` is still mocked while
40
- its real service contract is wired.
42
+ the `--output-path` file path.
41
43
 
42
44
  ## HTTP Client
43
45
 
@@ -47,6 +49,6 @@ settings such as base URL, HTTP timeout, and API paths are loaded by
47
49
 
48
50
  ## Auth
49
51
 
50
- `short-drama +submit-run` and `short-drama +get-thread` authenticate with
52
+ `short-drama +submit-run`, `short-drama +get-thread`, and `short-drama +upload-file` authenticate with
51
53
  `Authorization: Bearer <XYQ_ACCESS_KEY>`. OAuth command code remains available,
52
54
  but runtime short drama requests do not use it.
package/checksums.txt CHANGED
@@ -1,6 +1,6 @@
1
- ebff380937bb2269a116272d4bb0c5aaef0a176c2ffcfb6f337733411db34ddc pippit-cli-0.0.11-darwin-amd64.tar.gz
2
- de27b844d0df2b112a2a78a24184d023cdf72b145d969497dd351924348161e4 pippit-cli-0.0.11-darwin-arm64.tar.gz
3
- 65da00716f772d3bbb823e10f36e87b73810c8d6f76d5f0c648274d43657f634 pippit-cli-0.0.11-linux-amd64.tar.gz
4
- 420dc63f6ea642c20b0a22f666c652d633d4ddad274b85096b51f74602e85a81 pippit-cli-0.0.11-linux-arm64.tar.gz
5
- 06350f1d74f4130bb420c237e5f804b4df1f358dfc195b659a70ddfd2d48832b pippit-cli-0.0.11-windows-amd64.zip
6
- 6919d1de3bec32499379bbcc820ccf3bafb3e843fdfa4cb9e826f90fae00feb2 pippit-cli-0.0.11-windows-arm64.zip
1
+ 5062d1d4950aa9e0d8b1770e62476d9bb2076f4492cd24a541ad93b05a254f49 pippit-cli-0.0.12-darwin-amd64.tar.gz
2
+ 5a0c8d044ebcb717d18954147433ac5f54f2dc89285e2e50f6be6755431ee99c pippit-cli-0.0.12-darwin-arm64.tar.gz
3
+ 456420a71cd9909e152907faa96f35abb93e62079786021d1bee28cfac61e8ff pippit-cli-0.0.12-linux-amd64.tar.gz
4
+ 1c6681fa8f8edaa19dac835dd9bdf4bc17c06936700316f7d74b68d55d572460 pippit-cli-0.0.12-linux-arm64.tar.gz
5
+ e7c156bb35fb373fa89d05c729252b20951a41c70f3bbd4e8794e4c5abe65e06 pippit-cli-0.0.12-windows-amd64.zip
6
+ 2832786f9c13069f3580407e24037e95cc9452534e9996366dc6751f841fcd26 pippit-cli-0.0.12-windows-arm64.zip
@@ -3,7 +3,6 @@ package short_drama
3
3
  import (
4
4
  "fmt"
5
5
  "io"
6
- "path/filepath"
7
6
  "strings"
8
7
 
9
8
  "github.com/Pippit-dev/pippit-cli/internal/common"
@@ -72,8 +71,6 @@ func newShortDramaUploadFileCommand(stdout, stderr io.Writer, runner *common.Run
72
71
  if opts.Path == "" {
73
72
  return fmt.Errorf("--path is required")
74
73
  }
75
- opts.FileName = filepath.Base(opts.Path)
76
- opts.Mock = true
77
74
 
78
75
  result, err := common.UploadFile(cmd.Context(), opts, runner)
79
76
  if err != nil {
@@ -176,23 +176,65 @@ func TestShortDramaSubmitRunRequiresAccessKey(t *testing.T) {
176
176
  }
177
177
 
178
178
  func TestShortDramaUploadFile(t *testing.T) {
179
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
180
+ if r.Method != http.MethodPost {
181
+ t.Fatalf("method = %s, want POST", r.Method)
182
+ }
183
+ if r.URL.Path != "/api/biz/v1/skill/upload_file" {
184
+ t.Fatalf("path = %s, want upload_file path", r.URL.Path)
185
+ }
186
+ if r.Header.Get("Authorization") != "Bearer test-token" {
187
+ t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
188
+ }
189
+ if err := r.ParseMultipartForm(1 << 20); err != nil {
190
+ t.Fatalf("ParseMultipartForm(): %v", err)
191
+ }
192
+ if got := r.FormValue("accessKey"); got != "" {
193
+ t.Fatalf("accessKey = %q, want empty (auth via header only)", got)
194
+ }
195
+ files := r.MultipartForm.File["file"]
196
+ if len(files) != 1 {
197
+ t.Fatalf("file parts = %d, want 1", len(files))
198
+ }
199
+ if files[0].Filename != "story.txt" {
200
+ t.Fatalf("filename = %q, want story.txt", files[0].Filename)
201
+ }
202
+ if got := files[0].Header.Get("Content-Type"); got != "text/plain; charset=utf-8" {
203
+ t.Fatalf("Content-Type = %q, want text/plain; charset=utf-8", got)
204
+ }
205
+ file, err := files[0].Open()
206
+ if err != nil {
207
+ t.Fatalf("Open multipart file: %v", err)
208
+ }
209
+ defer file.Close()
210
+ data, err := io.ReadAll(file)
211
+ if err != nil {
212
+ t.Fatalf("ReadAll multipart file: %v", err)
213
+ }
214
+ if string(data) != "txt-data" {
215
+ t.Fatalf("file content = %q, want txt-data", string(data))
216
+ }
217
+ _, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"pippit_asset_id":"asset_123"}}`))
218
+ }))
219
+ defer server.Close()
220
+
221
+ cwd := chdirTemp(t)
222
+ path := filepath.Join(cwd, "story.txt")
223
+ if err := os.WriteFile(path, []byte("txt-data"), 0o644); err != nil {
224
+ t.Fatalf("WriteFile(): %v", err)
225
+ }
226
+
179
227
  var stdout, stderr bytes.Buffer
180
- root := NewRootCommand(&stdout, &stderr)
181
- root.SetArgs([]string{"short-drama", "+upload-file", "--path", "/tmp/story.md"})
228
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
229
+ root.SetArgs([]string{"short-drama", "+upload-file", "--path", path})
182
230
 
183
231
  if err := root.Execute(); err != nil {
184
232
  t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
185
233
  }
186
234
 
187
235
  got := decodeJSON(t, stdout.Bytes())
188
- if got["scene"] != "short-drama" {
189
- t.Fatalf("scene = %v, want short-drama", got["scene"])
190
- }
191
- if got["status"] != "uploaded" {
192
- t.Fatalf("status = %v, want uploaded", got["status"])
193
- }
194
- if !strings.HasPrefix(got["file_id"].(string), "file_mock_") {
195
- t.Fatalf("file_id = %v, want mock file id", got["file_id"])
236
+ if got["asset_id"] != "asset_123" {
237
+ t.Fatalf("asset_id = %v, want asset_123", got["asset_id"])
196
238
  }
197
239
  }
198
240
 
@@ -210,6 +252,51 @@ func TestShortDramaUploadFileRequiresPath(t *testing.T) {
210
252
  }
211
253
  }
212
254
 
255
+ func TestShortDramaUploadFileRequiresAccessKey(t *testing.T) {
256
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
257
+ t.Fatal("server should not receive request without access key")
258
+ }))
259
+ defer server.Close()
260
+
261
+ cwd := chdirTemp(t)
262
+ path := filepath.Join(cwd, "story.txt")
263
+ if err := os.WriteFile(path, []byte("txt-data"), 0o644); err != nil {
264
+ t.Fatalf("WriteFile(): %v", err)
265
+ }
266
+
267
+ var stdout, stderr bytes.Buffer
268
+ root := newTestRootCommandWithAccessKey(t, &stdout, &stderr, server.URL, "")
269
+ root.SetArgs([]string{"short-drama", "+upload-file", "--path", path})
270
+
271
+ err := root.Execute()
272
+ if err == nil {
273
+ t.Fatal("Execute() error = nil, want access key error")
274
+ }
275
+ if !strings.Contains(err.Error(), "XYQ_ACCESS_KEY is required") {
276
+ t.Fatalf("error = %q, want access key guidance", err)
277
+ }
278
+ }
279
+
280
+ func TestShortDramaUploadFileRejectsUnsupportedFileType(t *testing.T) {
281
+ cwd := chdirTemp(t)
282
+ path := filepath.Join(cwd, "story.png")
283
+ if err := os.WriteFile(path, []byte("png-data"), 0o644); err != nil {
284
+ t.Fatalf("WriteFile(): %v", err)
285
+ }
286
+
287
+ var stdout, stderr bytes.Buffer
288
+ root := NewRootCommand(&stdout, &stderr)
289
+ root.SetArgs([]string{"short-drama", "+upload-file", "--path", path})
290
+
291
+ err := root.Execute()
292
+ if err == nil {
293
+ t.Fatal("Execute() error = nil, want file type validation error")
294
+ }
295
+ if !strings.Contains(err.Error(), "only .doc and .txt uploads are supported") {
296
+ t.Fatalf("error = %q, want unsupported type validation", err)
297
+ }
298
+ }
299
+
213
300
  func TestShortDramaDownloadResult(t *testing.T) {
214
301
  server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
215
302
  if r.Header.Get("User-Agent") != "Pippit-CLI/1.0" {
@@ -5,8 +5,12 @@ import (
5
5
  "context"
6
6
  "fmt"
7
7
  "io"
8
+ "mime/multipart"
8
9
  "net/http"
10
+ "net/textproto"
9
11
  "net/url"
12
+ "os"
13
+ "path/filepath"
10
14
  "reflect"
11
15
  "strings"
12
16
  "time"
@@ -16,12 +20,20 @@ import (
16
20
 
17
21
  type Client interface {
18
22
  SendRequest(ctx context.Context, path string, body any, out any) error
23
+ SendMultipartRequest(ctx context.Context, path string, fields map[string]string, file MultipartFile, out any) error
19
24
  }
20
25
 
21
26
  type RequestAuthorizer interface {
22
27
  Inject(ctx context.Context, req *http.Request) error
23
28
  }
24
29
 
30
+ type MultipartFile struct {
31
+ FieldName string
32
+ Path string
33
+ FileName string
34
+ ContentType string
35
+ }
36
+
25
37
  type httpClient struct {
26
38
  baseURL string
27
39
  httpClient *http.Client
@@ -100,6 +112,89 @@ func (c *httpClient) SendRequest(ctx context.Context, path string, body any, out
100
112
  return c.do(req, out)
101
113
  }
102
114
 
115
+ func (c *httpClient) SendMultipartRequest(ctx context.Context, path string, fields map[string]string, file MultipartFile, out any) error {
116
+ reqURL, err := c.resolveURL(path, nil)
117
+ if err != nil {
118
+ return err
119
+ }
120
+ if file.FieldName == "" {
121
+ return fmt.Errorf("multipart file field name is required")
122
+ }
123
+ if file.FileName == "" {
124
+ file.FileName = filepath.Base(file.Path)
125
+ }
126
+ if file.ContentType == "" {
127
+ file.ContentType = "application/octet-stream"
128
+ }
129
+
130
+ pr, pw := io.Pipe()
131
+ writer := multipart.NewWriter(pw)
132
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, pr)
133
+ if err != nil {
134
+ _ = pr.Close()
135
+ _ = pw.Close()
136
+ return fmt.Errorf("build POST request: %w", err)
137
+ }
138
+ req.Header.Set("Content-Type", writer.FormDataContentType())
139
+ req.Header.Set("Accept", "application/json")
140
+
141
+ if c.authorizer == nil {
142
+ _ = pr.Close()
143
+ _ = pw.Close()
144
+ return fmt.Errorf("authorized request requires authorizer")
145
+ }
146
+ if err := c.authorizer.Inject(ctx, req); err != nil {
147
+ _ = pr.Close()
148
+ _ = pw.Close()
149
+ return fmt.Errorf("inject auth headers: %w", err)
150
+ }
151
+ c.injectHeaders(req)
152
+
153
+ go func() {
154
+ err := writeMultipartBody(writer, fields, file)
155
+ closeErr := writer.Close()
156
+ if err != nil {
157
+ _ = pw.CloseWithError(err)
158
+ return
159
+ }
160
+ _ = pw.CloseWithError(closeErr)
161
+ }()
162
+
163
+ return c.do(req, out)
164
+ }
165
+
166
+ func writeMultipartBody(writer *multipart.Writer, fields map[string]string, file MultipartFile) error {
167
+ for key, value := range fields {
168
+ if err := writer.WriteField(key, value); err != nil {
169
+ return err
170
+ }
171
+ }
172
+
173
+ f, err := os.Open(file.Path)
174
+ if err != nil {
175
+ return fmt.Errorf("open upload file: %w", err)
176
+ }
177
+ defer f.Close()
178
+
179
+ header := make(textproto.MIMEHeader)
180
+ header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, escapeQuotes(file.FieldName), escapeQuotes(file.FileName)))
181
+ header.Set("Content-Type", file.ContentType)
182
+ part, err := writer.CreatePart(header)
183
+ if err != nil {
184
+ return err
185
+ }
186
+ if _, err := io.Copy(part, f); err != nil {
187
+ return fmt.Errorf("write upload file: %w", err)
188
+ }
189
+ return nil
190
+ }
191
+
192
+ var quotesReplacer = strings.NewReplacer("\\", "\\\\", `"`, `\"`)
193
+
194
+ func escapeQuotes(s string) string {
195
+ return quotesReplacer.Replace(s)
196
+ }
197
+
103
198
  func (c *httpClient) injectHeaders(req *http.Request) {
104
199
  for k, values := range c.headers {
105
200
  for _, v := range values {
@@ -2,48 +2,103 @@ package common
2
2
 
3
3
  import (
4
4
  "context"
5
- "crypto/sha1"
6
- "encoding/hex"
7
- "time"
5
+ "fmt"
6
+ "mime"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+
11
+ "github.com/Pippit-dev/pippit-cli/internal/config"
8
12
  )
9
13
 
10
14
  // UploadFileOptions is the stable command-facing request shape for file upload.
11
15
  type UploadFileOptions struct {
12
16
  Path string `json:"path"`
13
17
  FileName string `json:"file_name"`
14
- Purpose string `json:"purpose"`
15
- Mock bool `json:"mock"`
16
18
  }
17
19
 
18
20
  // UploadFileResult is the JSON envelope printed by `pippit-cli short-drama +upload-file`.
19
21
  type UploadFileResult struct {
20
- Scene string `json:"scene"`
21
- FileID string `json:"file_id"`
22
- Status string `json:"status"`
23
- Uploaded string `json:"uploaded_at"`
24
- Request UploadFileOptions `json:"request"`
22
+ AssetID string `json:"asset_id"`
23
+ }
24
+
25
+ type uploadFileResponse struct {
26
+ Ret string `json:"ret"`
27
+ Errmsg string `json:"errmsg"`
28
+ SvrTime int64 `json:"svr_time"`
29
+ LogID string `json:"log_id"`
30
+ Data struct {
31
+ PippitAssetID string `json:"pippit_asset_id"`
32
+ AssetID string `json:"asset_id"`
33
+ } `json:"data"`
34
+ }
35
+
36
+ const uploadFileFieldName = "file"
37
+
38
+ var allowedUploadExtensions = map[string]bool{
39
+ ".doc": true,
40
+ ".txt": true,
25
41
  }
26
42
 
27
- func UploadFile(ctx context.Context, opts UploadFileOptions, _ *Runner) (*UploadFileResult, error) {
43
+ func UploadFile(ctx context.Context, opts UploadFileOptions, runner *Runner) (*UploadFileResult, error) {
28
44
  if err := ctx.Err(); err != nil {
29
45
  return nil, err
30
46
  }
31
- key := opts.Purpose + "\x00" + opts.FileName + "\x00" + opts.Path
32
- id := stableID(key, 10)
47
+ if runner == nil || runner.Client == nil {
48
+ return nil, fmt.Errorf("upload_file runner client is required")
49
+ }
50
+
51
+ path := strings.TrimSpace(opts.Path)
52
+ if path == "" {
53
+ return nil, fmt.Errorf("upload file path is required")
54
+ }
55
+ info, err := os.Stat(path)
56
+ if err != nil {
57
+ return nil, fmt.Errorf("stat upload file: %w", err)
58
+ }
59
+ if info.IsDir() {
60
+ return nil, fmt.Errorf("upload path %q is a directory", path)
61
+ }
62
+
63
+ ext := strings.ToLower(filepath.Ext(path))
64
+ if !allowedUploadExtensions[ext] {
65
+ return nil, fmt.Errorf("unsupported file extension %q; only .doc and .txt uploads are supported", ext)
66
+ }
67
+ fileName := filepath.Base(path)
68
+ contentType := mime.TypeByExtension(ext)
69
+
70
+ var resp uploadFileResponse
71
+ if err := runner.Client.SendMultipartRequest(ctx, uploadFilePath(runner), nil, MultipartFile{
72
+ FieldName: uploadFileFieldName,
73
+ Path: path,
74
+ FileName: fileName,
75
+ ContentType: contentType,
76
+ }, &resp); err != nil {
77
+ return nil, fmt.Errorf("upload_file request failed: %w", err)
78
+ }
79
+ if resp.Ret != "0" {
80
+ if resp.Errmsg == "" {
81
+ resp.Errmsg = "unknown error"
82
+ }
83
+ return nil, fmt.Errorf("upload_file failed: ret=%s errmsg=%s", resp.Ret, resp.Errmsg)
84
+ }
85
+
86
+ assetID := strings.TrimSpace(resp.Data.PippitAssetID)
87
+ if assetID == "" {
88
+ assetID = strings.TrimSpace(resp.Data.AssetID)
89
+ }
90
+ if assetID == "" {
91
+ return nil, fmt.Errorf("upload_file response missing pippit_asset_id")
92
+ }
93
+
33
94
  return &UploadFileResult{
34
- Scene: "short-drama",
35
- FileID: "file_mock_" + id,
36
- Status: "uploaded",
37
- Uploaded: time.Now().UTC().Format(time.RFC3339),
38
- Request: opts,
95
+ AssetID: assetID,
39
96
  }, nil
40
97
  }
41
98
 
42
- func stableID(key string, n int) string {
43
- sum := sha1.Sum([]byte(key))
44
- id := hex.EncodeToString(sum[:])
45
- if n > len(id) {
46
- return id
99
+ func uploadFilePath(runner *Runner) string {
100
+ if runner != nil && runner.Config != nil && runner.Config.Paths != nil && runner.Config.Paths.UploadFile != "" {
101
+ return runner.Config.Paths.UploadFile
47
102
  }
48
- return id[:n]
103
+ return config.UploadFilePath
49
104
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pippit-dev/cli",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "description": "Pippit CLI",
5
5
  "bin": {
6
6
  "pippit-cli": "scripts/run.js"