@pippit-dev/cli 1.0.17 → 1.0.20
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 +41 -2
- package/checksums.txt +6 -6
- package/cmd/auth/auth.go +136 -142
- package/cmd/auth/auth_test.go +134 -0
- package/cmd/canvas/canvas.go +241 -0
- package/cmd/canvas/canvas_test.go +182 -0
- package/cmd/get_credit_balance.go +37 -0
- package/cmd/get_credit_balance_test.go +73 -0
- package/cmd/root.go +31 -5
- package/cmd/root_test.go +69 -0
- package/cmd/short_drama_test.go +5 -7
- package/cmd/update/update.go +34 -5
- package/cmd/update/update_test.go +84 -0
- package/dist/checksums.txt +6 -0
- package/dist/xyq-canvas-command-runtime.cjs +16 -0
- package/dist/xyq-canvas-command-runtime.cjs.LEGAL.txt +599 -0
- package/dist/xyq-canvas-command-runtime.cjs.sha256 +2 -0
- package/internal/auth/auth_test.go +813 -0
- package/internal/auth/browser_darwin.go +14 -0
- package/internal/auth/browser_env.go +35 -0
- package/internal/auth/browser_linux.go +14 -0
- package/internal/auth/browser_windows.go +14 -0
- package/internal/auth/identity.go +89 -0
- package/internal/auth/loopback.go +324 -0
- package/internal/auth/manager.go +338 -144
- package/internal/auth/store.go +303 -0
- package/internal/auth/store_file_unix.go +176 -0
- package/internal/auth/store_file_windows.go +11 -0
- package/internal/auth/types.go +72 -0
- package/internal/canvas/allocate.go +70 -0
- package/internal/canvas/apply.go +279 -0
- package/internal/canvas/canvas_test.go +559 -0
- package/internal/canvas/create.go +380 -0
- package/internal/canvas/get.go +156 -0
- package/internal/canvas/types.go +68 -0
- package/internal/canvas/upload.go +250 -0
- package/internal/common/access_key.go +42 -6
- package/internal/common/access_key_test.go +113 -0
- package/internal/common/client.go +119 -21
- package/internal/common/client_test.go +213 -0
- package/internal/common/get_credit_balance.go +61 -0
- package/internal/common/get_credit_balance_test.go +75 -0
- package/internal/common/runner.go +15 -0
- package/internal/config/config.go +11 -28
- package/internal/config/config_test.go +3 -18
- package/package.json +9 -2
- package/scripts/canvas-command.js +881 -0
- package/scripts/run.js +21 -4
- package/skills/short-drama/SKILL.md +4 -4
- package/skills/xyq-nest-skill/SKILL.md +11 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
package canvas
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/rand"
|
|
5
|
+
"encoding/hex"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"fmt"
|
|
8
|
+
"io"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"strings"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
canvascore "github.com/Pippit-dev/pippit-cli/internal/canvas"
|
|
15
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
16
|
+
"github.com/spf13/cobra"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const maxApplyRequestBytes = 64 << 20
|
|
20
|
+
|
|
21
|
+
// NewCommand builds the provider-neutral personal Canvas command tree.
|
|
22
|
+
func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
|
|
23
|
+
cmd := &cobra.Command{
|
|
24
|
+
Use: "canvas",
|
|
25
|
+
Short: "Create and operate personal novel canvases",
|
|
26
|
+
Args: cobra.NoArgs,
|
|
27
|
+
}
|
|
28
|
+
cmd.SetOut(stdout)
|
|
29
|
+
cmd.SetErr(stderr)
|
|
30
|
+
cmd.AddCommand(newCreateCommand(stdout, stderr, runner))
|
|
31
|
+
cmd.AddCommand(newGetCommand(stdout, stderr, runner))
|
|
32
|
+
cmd.AddCommand(newAllocateCommand(stdout, stderr, runner))
|
|
33
|
+
cmd.AddCommand(newApplyCommand(stdout, stderr, runner))
|
|
34
|
+
cmd.AddCommand(newUploadCommand(stdout, stderr, runner))
|
|
35
|
+
return cmd
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func newAllocateCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
|
|
39
|
+
var count int
|
|
40
|
+
cmd := &cobra.Command{
|
|
41
|
+
Use: "allocate",
|
|
42
|
+
Short: "Allocate IDs for assets created by a later Canvas transaction",
|
|
43
|
+
Args: cobra.NoArgs,
|
|
44
|
+
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
45
|
+
result, err := canvascore.Allocate(cmd.Context(), count, runner)
|
|
46
|
+
if err != nil {
|
|
47
|
+
logCanvasError("canvas allocate", err, map[string]string{"count": fmt.Sprint(count)})
|
|
48
|
+
return err
|
|
49
|
+
}
|
|
50
|
+
return common.WriteJSON(stdout, result)
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
cmd.SetOut(stdout)
|
|
54
|
+
cmd.SetErr(stderr)
|
|
55
|
+
cmd.Flags().IntVar(&count, "count", 0, "number of unique Pippit asset IDs to allocate")
|
|
56
|
+
return cmd
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func newCreateCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
|
|
60
|
+
var opts canvascore.CreateOptions
|
|
61
|
+
cmd := &cobra.Command{
|
|
62
|
+
Use: "create",
|
|
63
|
+
Short: "Create a personal novel Canvas project",
|
|
64
|
+
Args: cobra.NoArgs,
|
|
65
|
+
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
66
|
+
if strings.TrimSpace(opts.RequestID) == "" {
|
|
67
|
+
requestID, err := newRequestID()
|
|
68
|
+
if err != nil {
|
|
69
|
+
return fmt.Errorf("generate canvas request ID: %w", err)
|
|
70
|
+
}
|
|
71
|
+
opts.RequestID = requestID
|
|
72
|
+
}
|
|
73
|
+
result, err := canvascore.Create(cmd.Context(), opts, runner)
|
|
74
|
+
if result != nil {
|
|
75
|
+
if writeErr := common.WriteJSON(stdout, result); writeErr != nil {
|
|
76
|
+
return writeErr
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if err != nil {
|
|
80
|
+
logCanvasError("canvas create", err, map[string]string{"request_id": opts.RequestID})
|
|
81
|
+
return err
|
|
82
|
+
}
|
|
83
|
+
return nil
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
cmd.SetOut(stdout)
|
|
87
|
+
cmd.SetErr(stderr)
|
|
88
|
+
flags := cmd.Flags()
|
|
89
|
+
flags.StringVar(&opts.Title, "title", "", "project title (maximum 50 characters)")
|
|
90
|
+
flags.StringVar(&opts.RequestID, "request-id", "", "caller request ID; generated when omitted")
|
|
91
|
+
flags.BoolVar(&opts.Wait, "wait", false, "wait for the novel overview artifact")
|
|
92
|
+
flags.DurationVar(&opts.PollInterval, "poll-interval", time.Second, "artifact polling interval")
|
|
93
|
+
flags.DurationVar(&opts.WaitTimeout, "timeout", 2*time.Minute, "maximum artifact wait time")
|
|
94
|
+
return cmd
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func newGetCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
|
|
98
|
+
var assetIDs []string
|
|
99
|
+
cmd := &cobra.Command{
|
|
100
|
+
Use: "get",
|
|
101
|
+
Short: "Get personal Canvas assets by ID",
|
|
102
|
+
Args: cobra.NoArgs,
|
|
103
|
+
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
104
|
+
result, err := canvascore.Get(cmd.Context(), canvascore.GetOptions{AssetIDs: assetIDs}, runner)
|
|
105
|
+
if err != nil {
|
|
106
|
+
logCanvasError("canvas get", err, map[string]string{"asset_count": fmt.Sprint(len(assetIDs))})
|
|
107
|
+
return err
|
|
108
|
+
}
|
|
109
|
+
return common.WriteJSON(stdout, result)
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
cmd.SetOut(stdout)
|
|
113
|
+
cmd.SetErr(stderr)
|
|
114
|
+
cmd.Flags().StringArrayVar(&assetIDs, "asset-id", nil, "Pippit asset ID; repeat for multiple assets")
|
|
115
|
+
return cmd
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func newApplyCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
|
|
119
|
+
var filePath string
|
|
120
|
+
var projectID string
|
|
121
|
+
var transportResult bool
|
|
122
|
+
cmd := &cobra.Command{
|
|
123
|
+
Use: "apply",
|
|
124
|
+
Short: "Apply one Canvas patch transaction",
|
|
125
|
+
Args: cobra.NoArgs,
|
|
126
|
+
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
127
|
+
request, err := readApplyRequest(cmd.InOrStdin(), filePath)
|
|
128
|
+
if err != nil {
|
|
129
|
+
return err
|
|
130
|
+
}
|
|
131
|
+
result, err := canvascore.Apply(cmd.Context(), canvascore.ApplyOptions{
|
|
132
|
+
ProjectID: projectID,
|
|
133
|
+
Request: request,
|
|
134
|
+
AllowNonAcknowledgedResults: transportResult,
|
|
135
|
+
}, runner)
|
|
136
|
+
if err != nil {
|
|
137
|
+
logCanvasError("canvas apply", err, map[string]string{
|
|
138
|
+
"batch_id": request.BatchID,
|
|
139
|
+
"project_id": projectID,
|
|
140
|
+
"transactions": fmt.Sprint(len(request.Transactions)),
|
|
141
|
+
})
|
|
142
|
+
return err
|
|
143
|
+
}
|
|
144
|
+
return common.WriteJSON(stdout, result)
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
cmd.SetOut(stdout)
|
|
148
|
+
cmd.SetErr(stderr)
|
|
149
|
+
flags := cmd.Flags()
|
|
150
|
+
flags.StringVar(&filePath, "file", "-", "BatchPatch JSON request file, or - for stdin")
|
|
151
|
+
flags.StringVar(&projectID, "project-id", "", "personal novel project ID as a decimal string")
|
|
152
|
+
flags.BoolVar(&transportResult, "transport-result", false, "return explicit transaction outcomes for transport decoding")
|
|
153
|
+
if err := flags.MarkHidden("transport-result"); err != nil {
|
|
154
|
+
panic(err)
|
|
155
|
+
}
|
|
156
|
+
return cmd
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func newUploadCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
|
|
160
|
+
var opts canvascore.UploadOptions
|
|
161
|
+
cmd := &cobra.Command{
|
|
162
|
+
Use: "upload",
|
|
163
|
+
Short: "Upload a file to personal assets and wait until it is queryable",
|
|
164
|
+
Args: cobra.NoArgs,
|
|
165
|
+
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
166
|
+
result, err := canvascore.Upload(cmd.Context(), opts, runner)
|
|
167
|
+
if err != nil {
|
|
168
|
+
logCanvasError("canvas upload", err, map[string]string{"file_name": filepath.Base(strings.TrimSpace(opts.Path))})
|
|
169
|
+
return err
|
|
170
|
+
}
|
|
171
|
+
return common.WriteJSON(stdout, result)
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
cmd.SetOut(stdout)
|
|
175
|
+
cmd.SetErr(stderr)
|
|
176
|
+
flags := cmd.Flags()
|
|
177
|
+
flags.StringVar(&opts.Path, "path", "", "local file path to upload")
|
|
178
|
+
flags.DurationVar(&opts.PollInterval, "poll-interval", time.Second, "asset visibility polling interval")
|
|
179
|
+
flags.DurationVar(&opts.WaitTimeout, "timeout", 2*time.Minute, "maximum asset visibility wait time")
|
|
180
|
+
return cmd
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func newRequestID() (string, error) {
|
|
184
|
+
random := make([]byte, 16)
|
|
185
|
+
if _, err := rand.Read(random); err != nil {
|
|
186
|
+
return "", err
|
|
187
|
+
}
|
|
188
|
+
return "pippit_cli_canvas_" + hex.EncodeToString(random), nil
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
func readApplyRequest(stdin io.Reader, filePath string) (canvascore.ApplyRequest, error) {
|
|
192
|
+
filePath = strings.TrimSpace(filePath)
|
|
193
|
+
if filePath == "" {
|
|
194
|
+
return canvascore.ApplyRequest{}, fmt.Errorf("canvas apply --file must not be empty")
|
|
195
|
+
}
|
|
196
|
+
var reader io.Reader
|
|
197
|
+
var file *os.File
|
|
198
|
+
if filePath == "-" {
|
|
199
|
+
reader = stdin
|
|
200
|
+
} else {
|
|
201
|
+
var err error
|
|
202
|
+
file, err = os.Open(filePath)
|
|
203
|
+
if err != nil {
|
|
204
|
+
return canvascore.ApplyRequest{}, fmt.Errorf("open canvas apply request: %w", err)
|
|
205
|
+
}
|
|
206
|
+
defer file.Close()
|
|
207
|
+
reader = file
|
|
208
|
+
}
|
|
209
|
+
limited := io.LimitReader(reader, maxApplyRequestBytes+1)
|
|
210
|
+
payload, err := io.ReadAll(limited)
|
|
211
|
+
if err != nil {
|
|
212
|
+
return canvascore.ApplyRequest{}, fmt.Errorf("read canvas apply request: %w", err)
|
|
213
|
+
}
|
|
214
|
+
if len(payload) > maxApplyRequestBytes {
|
|
215
|
+
return canvascore.ApplyRequest{}, fmt.Errorf("canvas apply request exceeds %d bytes", maxApplyRequestBytes)
|
|
216
|
+
}
|
|
217
|
+
decoder := json.NewDecoder(strings.NewReader(string(payload)))
|
|
218
|
+
decoder.DisallowUnknownFields()
|
|
219
|
+
var request canvascore.ApplyRequest
|
|
220
|
+
if err := decoder.Decode(&request); err != nil {
|
|
221
|
+
return canvascore.ApplyRequest{}, fmt.Errorf("decode canvas apply request: %w", err)
|
|
222
|
+
}
|
|
223
|
+
if err := ensureJSONEOF(decoder); err != nil {
|
|
224
|
+
return canvascore.ApplyRequest{}, err
|
|
225
|
+
}
|
|
226
|
+
return request, nil
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
func ensureJSONEOF(decoder *json.Decoder) error {
|
|
230
|
+
var trailing any
|
|
231
|
+
if err := decoder.Decode(&trailing); err == io.EOF {
|
|
232
|
+
return nil
|
|
233
|
+
} else if err != nil {
|
|
234
|
+
return fmt.Errorf("decode trailing canvas apply data: %w", err)
|
|
235
|
+
}
|
|
236
|
+
return fmt.Errorf("canvas apply request must contain exactly one JSON object")
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
func logCanvasError(command string, err error, fields map[string]string) {
|
|
240
|
+
_ = common.AppendDailyErrorLog(command, err, fields)
|
|
241
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
package canvas
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
|
|
10
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
11
|
+
"github.com/spf13/cobra"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
type commandFakeClient struct {
|
|
15
|
+
response string
|
|
16
|
+
request map[string]any
|
|
17
|
+
handler func(string, any, any) error
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func (f *commandFakeClient) SendRequest(_ context.Context, path string, body any, out any) error {
|
|
21
|
+
payload, _ := json.Marshal(body)
|
|
22
|
+
_ = json.Unmarshal(payload, &f.request)
|
|
23
|
+
if f.handler != nil {
|
|
24
|
+
return f.handler(path, body, out)
|
|
25
|
+
}
|
|
26
|
+
return json.Unmarshal([]byte(f.response), out)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func (f *commandFakeClient) SendRequestWithHeaders(ctx context.Context, path string, body any, _ map[string]string, out any) error {
|
|
30
|
+
return f.SendRequest(ctx, path, body, out)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func (f *commandFakeClient) SendMultipartRequest(context.Context, string, map[string]string, common.MultipartFile, any) error {
|
|
34
|
+
return nil
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func TestCommandExposesOnlyProviderNeutralPublicVerbs(t *testing.T) {
|
|
38
|
+
cmd := NewCommand(&bytes.Buffer{}, &bytes.Buffer{}, &common.Runner{Client: &commandFakeClient{}})
|
|
39
|
+
got := make([]string, 0, len(cmd.Commands()))
|
|
40
|
+
for _, child := range cmd.Commands() {
|
|
41
|
+
got = append(got, child.Name())
|
|
42
|
+
}
|
|
43
|
+
if strings.Join(got, ",") != "allocate,apply,create,get,upload" {
|
|
44
|
+
t.Fatalf("commands = %v, want allocate/apply/create/get/upload", got)
|
|
45
|
+
}
|
|
46
|
+
for _, forbidden := range []string{"import", "bind", "team"} {
|
|
47
|
+
if strings.Contains(strings.ToLower(cmd.CommandPath()+" "+cmd.Short+" "+strings.Join(got, " ")), forbidden) {
|
|
48
|
+
t.Fatalf("public command surface contains forbidden verb %q", forbidden)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func TestAllocateCommandPrintsOneMachineReadableJSONLine(t *testing.T) {
|
|
54
|
+
client := &commandFakeClient{response: `{"ret":"0","log_id":"log-1","data":{"ids":["10","11"]}}`}
|
|
55
|
+
var stdout, stderr bytes.Buffer
|
|
56
|
+
cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client})
|
|
57
|
+
cmd.SetArgs([]string{"allocate", "--count", "2"})
|
|
58
|
+
if err := cmd.Execute(); err != nil {
|
|
59
|
+
t.Fatalf("Execute() error = %v", err)
|
|
60
|
+
}
|
|
61
|
+
if strings.Count(stdout.String(), "\n") != 1 {
|
|
62
|
+
t.Fatalf("stdout = %q, want one JSON line", stdout.String())
|
|
63
|
+
}
|
|
64
|
+
var result map[string]any
|
|
65
|
+
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil {
|
|
66
|
+
t.Fatalf("stdout is not JSON: %v", err)
|
|
67
|
+
}
|
|
68
|
+
if len(result["asset_ids"].([]any)) != 2 || client.request["count"] != float64(2) {
|
|
69
|
+
t.Fatalf("result/request = (%#v, %#v), want two allocated IDs", result, client.request)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func TestCreateCommandPrintsOneMachineReadableJSONLine(t *testing.T) {
|
|
74
|
+
client := &commandFakeClient{response: `{"ret":"0","log_id":"log-1","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`}
|
|
75
|
+
var stdout, stderr bytes.Buffer
|
|
76
|
+
cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client})
|
|
77
|
+
cmd.SetArgs([]string{"create", "--title", "Demo", "--request-id", "request-1"})
|
|
78
|
+
if err := cmd.Execute(); err != nil {
|
|
79
|
+
t.Fatalf("Execute() error = %v", err)
|
|
80
|
+
}
|
|
81
|
+
if strings.Count(stdout.String(), "\n") != 1 {
|
|
82
|
+
t.Fatalf("stdout = %q, want one JSON line", stdout.String())
|
|
83
|
+
}
|
|
84
|
+
var result map[string]any
|
|
85
|
+
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil {
|
|
86
|
+
t.Fatalf("stdout is not JSON: %v", err)
|
|
87
|
+
}
|
|
88
|
+
if result["request_id"] != "request-1" || result["project_id"] != "100" || client.request["surface"] != "novel" {
|
|
89
|
+
t.Fatalf("result/request = (%#v, %#v), want personal novel create", result, client.request)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func TestCreateCommandWaitTimeoutPrintsAcceptedIDs(t *testing.T) {
|
|
94
|
+
client := &commandFakeClient{handler: func(path string, _ any, out any) error {
|
|
95
|
+
response := `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run-1","state":1}]}}}`
|
|
96
|
+
if path == "/api/biz/v1/skill/canvas/create" {
|
|
97
|
+
response = `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`
|
|
98
|
+
}
|
|
99
|
+
return json.Unmarshal([]byte(response), out)
|
|
100
|
+
}}
|
|
101
|
+
var stdout, stderr bytes.Buffer
|
|
102
|
+
cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client})
|
|
103
|
+
cmd.SetArgs([]string{
|
|
104
|
+
"create", "--request-id", "request-1", "--wait",
|
|
105
|
+
"--poll-interval", "50ms", "--timeout", "1ms",
|
|
106
|
+
})
|
|
107
|
+
if err := cmd.Execute(); err != nil {
|
|
108
|
+
t.Fatalf("Execute() error = %v, want accepted create outcome", err)
|
|
109
|
+
}
|
|
110
|
+
var result map[string]any
|
|
111
|
+
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil {
|
|
112
|
+
t.Fatalf("stdout = %q, want JSON: %v", stdout.String(), err)
|
|
113
|
+
}
|
|
114
|
+
if result["project_id"] != "100" || result["state"] != "creating" || result["warning"] == "" {
|
|
115
|
+
t.Fatalf("result = %#v, want accepted IDs and wait warning", result)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func TestApplyRequestRejectsTeamOrImportExtensions(t *testing.T) {
|
|
120
|
+
for _, field := range []string{"team_id", "provider", "import_source"} {
|
|
121
|
+
payload := `{"batch_id":"b","client_id":"c","transactions":[],"` + field + `":"x"}`
|
|
122
|
+
_, err := readApplyRequest(strings.NewReader(payload), "-")
|
|
123
|
+
if err == nil || !strings.Contains(err.Error(), "unknown field") {
|
|
124
|
+
t.Fatalf("readApplyRequest(%s) error = %v, want unknown field rejection", field, err)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func TestApplyTransportResultFlagIsHiddenAndPrintsExplicitReject(t *testing.T) {
|
|
130
|
+
client := &commandFakeClient{response: `{"ret":"0","log_id":"transport-log","data":{"results":[{"transaction_id":"tx-1","status":"reject","message":"version conflict","current_asset_versions":{"asset-1":9},"server_detail":{"reason":"conflict"}}]}}`}
|
|
131
|
+
var stdout, stderr bytes.Buffer
|
|
132
|
+
cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client})
|
|
133
|
+
var applyCommand *cobra.Command
|
|
134
|
+
for _, child := range cmd.Commands() {
|
|
135
|
+
if child.Name() == "apply" {
|
|
136
|
+
applyCommand = child
|
|
137
|
+
break
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if applyCommand == nil {
|
|
141
|
+
t.Fatal("apply command not found")
|
|
142
|
+
}
|
|
143
|
+
flag := applyCommand.Flags().Lookup("transport-result")
|
|
144
|
+
if flag == nil || !flag.Hidden {
|
|
145
|
+
t.Fatalf("transport-result flag = %#v, want hidden flag", flag)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
request := `{"batch_id":"batch-1","client_id":"client-1","transactions":[{"transaction_id":"tx-1","patches":[{"asset_id":"asset-1","op":"replace","path":"","value":{}}]}]}`
|
|
149
|
+
cmd.SetIn(strings.NewReader(request))
|
|
150
|
+
cmd.SetArgs([]string{"apply", "--file", "-", "--transport-result"})
|
|
151
|
+
if err := cmd.Execute(); err != nil {
|
|
152
|
+
t.Fatalf("Execute() error = %v", err)
|
|
153
|
+
}
|
|
154
|
+
var output map[string]any
|
|
155
|
+
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &output); err != nil {
|
|
156
|
+
t.Fatalf("stdout = %q, want JSON: %v", stdout.String(), err)
|
|
157
|
+
}
|
|
158
|
+
result := output["results"].([]any)[0].(map[string]any)
|
|
159
|
+
if result["status"] != "reject" || result["message"] != "version conflict" || result["server_detail"] == nil {
|
|
160
|
+
t.Fatalf("result = %#v, want complete explicit reject", result)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func TestApplyTransportResultPrintsRootCreateAckWithoutVersion(t *testing.T) {
|
|
165
|
+
client := &commandFakeClient{response: `{"ret":"0","data":{"results":[{"transaction_id":"tx-1","status":"ack","server_detail":{"created":true}}]}}`}
|
|
166
|
+
var stdout, stderr bytes.Buffer
|
|
167
|
+
cmd := NewCommand(&stdout, &stderr, &common.Runner{Client: client})
|
|
168
|
+
request := `{"batch_id":"batch-1","client_id":"client-1","transactions":[{"transaction_id":"tx-1","patches":[{"asset_id":"asset-new","op":"add","path":"","value":{}}]}]}`
|
|
169
|
+
cmd.SetIn(strings.NewReader(request))
|
|
170
|
+
cmd.SetArgs([]string{"apply", "--file", "-", "--transport-result"})
|
|
171
|
+
if err := cmd.Execute(); err != nil {
|
|
172
|
+
t.Fatalf("Execute() error = %v", err)
|
|
173
|
+
}
|
|
174
|
+
var output map[string]any
|
|
175
|
+
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &output); err != nil {
|
|
176
|
+
t.Fatalf("stdout = %q, want JSON: %v", stdout.String(), err)
|
|
177
|
+
}
|
|
178
|
+
result := output["results"].([]any)[0].(map[string]any)
|
|
179
|
+
if result["status"] != "ack" || result["asset_versions"] != nil || result["server_detail"] == nil {
|
|
180
|
+
t.Fatalf("result = %#v, want unchanged root-create ACK", result)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"io"
|
|
5
|
+
|
|
6
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
7
|
+
"github.com/spf13/cobra"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func newGetCreditBalanceCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
|
|
11
|
+
var withLogID bool
|
|
12
|
+
cmd := &cobra.Command{
|
|
13
|
+
Use: "get-credit-balance",
|
|
14
|
+
Short: "Get the effective credit balance",
|
|
15
|
+
Args: cobra.NoArgs,
|
|
16
|
+
RunE: withErrorLog("get-credit-balance", nil, func(cmd *cobra.Command, _ []string) error {
|
|
17
|
+
result, err := common.GetCreditBalance(cmd.Context(), runner)
|
|
18
|
+
if err != nil {
|
|
19
|
+
return err
|
|
20
|
+
}
|
|
21
|
+
if withLogID {
|
|
22
|
+
return common.WriteJSON(stdout, struct {
|
|
23
|
+
TotalRemainAmount int64 `json:"total_remain_amount,string"`
|
|
24
|
+
LogID string `json:"log_id"`
|
|
25
|
+
}{
|
|
26
|
+
TotalRemainAmount: result.TotalRemainAmount,
|
|
27
|
+
LogID: result.LogID,
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
return common.WriteJSON(stdout, result)
|
|
31
|
+
}),
|
|
32
|
+
}
|
|
33
|
+
cmd.SetOut(stdout)
|
|
34
|
+
cmd.SetErr(stderr)
|
|
35
|
+
cmd.Flags().BoolVar(&withLogID, "with-log-id", false, "include the request log ID in JSON output")
|
|
36
|
+
return cmd
|
|
37
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"io"
|
|
6
|
+
"net/http"
|
|
7
|
+
"net/http/httptest"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestGetCreditBalanceCommand(t *testing.T) {
|
|
12
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
13
|
+
if r.Method != http.MethodPost {
|
|
14
|
+
t.Fatalf("method = %s, want POST", r.Method)
|
|
15
|
+
}
|
|
16
|
+
if r.URL.Path != "/api/biz/v1/skill/get_credit_balance" {
|
|
17
|
+
t.Fatalf("path = %s, want get_credit_balance path", r.URL.Path)
|
|
18
|
+
}
|
|
19
|
+
if r.Header.Get("Authorization") != "Bearer test-token" {
|
|
20
|
+
t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
|
|
21
|
+
}
|
|
22
|
+
body, err := io.ReadAll(r.Body)
|
|
23
|
+
if err != nil {
|
|
24
|
+
t.Fatalf("read body: %v", err)
|
|
25
|
+
}
|
|
26
|
+
if string(body) != "{}" {
|
|
27
|
+
t.Fatalf("body = %s, want empty JSON object", body)
|
|
28
|
+
}
|
|
29
|
+
w.Header().Set("Content-Type", "application/json")
|
|
30
|
+
_, _ = w.Write([]byte(`{"ret":"0","data":{"total_remain_amount":"0"}}`))
|
|
31
|
+
}))
|
|
32
|
+
defer server.Close()
|
|
33
|
+
|
|
34
|
+
var stdout, stderr bytes.Buffer
|
|
35
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
36
|
+
root.SetArgs([]string{"get-credit-balance"})
|
|
37
|
+
|
|
38
|
+
if err := root.Execute(); err != nil {
|
|
39
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
40
|
+
}
|
|
41
|
+
if got, want := stdout.String(), "{\"total_remain_amount\":\"0\"}\n"; got != want {
|
|
42
|
+
t.Fatalf("stdout = %q, want %q", got, want)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func TestGetCreditBalanceCommandRejectsArguments(t *testing.T) {
|
|
47
|
+
var stdout, stderr bytes.Buffer
|
|
48
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
49
|
+
root.SetArgs([]string{"get-credit-balance", "unexpected"})
|
|
50
|
+
|
|
51
|
+
if err := root.Execute(); err == nil {
|
|
52
|
+
t.Fatal("Execute() error = nil, want argument rejection")
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func TestGetCreditBalanceCommandSupportsLogID(t *testing.T) {
|
|
57
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
58
|
+
w.Header().Set("Content-Type", "application/json")
|
|
59
|
+
_, _ = w.Write([]byte(`{"ret":"0","log_id":"log_123","data":{"total_remain_amount":"48592"}}`))
|
|
60
|
+
}))
|
|
61
|
+
defer server.Close()
|
|
62
|
+
|
|
63
|
+
var stdout, stderr bytes.Buffer
|
|
64
|
+
root := newTestRootCommand(t, &stdout, &stderr, server.URL)
|
|
65
|
+
root.SetArgs([]string{"get-credit-balance", "--with-log-id"})
|
|
66
|
+
|
|
67
|
+
if err := root.Execute(); err != nil {
|
|
68
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
69
|
+
}
|
|
70
|
+
if got, want := stdout.String(), "{\"total_remain_amount\":\"48592\",\"log_id\":\"log_123\"}\n"; got != want {
|
|
71
|
+
t.Fatalf("stdout = %q, want %q", got, want)
|
|
72
|
+
}
|
|
73
|
+
}
|
package/cmd/root.go
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
package cmd
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"context"
|
|
4
5
|
"fmt"
|
|
5
6
|
"io"
|
|
6
7
|
"os"
|
|
7
8
|
"strings"
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
authcmd "github.com/Pippit-dev/pippit-cli/cmd/auth"
|
|
11
|
+
canvascmd "github.com/Pippit-dev/pippit-cli/cmd/canvas"
|
|
10
12
|
"github.com/Pippit-dev/pippit-cli/cmd/generate_image"
|
|
11
13
|
"github.com/Pippit-dev/pippit-cli/cmd/generate_video"
|
|
12
14
|
"github.com/Pippit-dev/pippit-cli/cmd/short_drama"
|
|
13
15
|
updatecmd "github.com/Pippit-dev/pippit-cli/cmd/update"
|
|
14
16
|
"github.com/Pippit-dev/pippit-cli/cmd/video_tool"
|
|
17
|
+
internal_auth "github.com/Pippit-dev/pippit-cli/internal/auth"
|
|
15
18
|
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
16
19
|
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
17
20
|
"github.com/Pippit-dev/pippit-cli/internal/version"
|
|
@@ -25,16 +28,35 @@ func Execute() error {
|
|
|
25
28
|
|
|
26
29
|
func NewRootCommand(stdout, stderr io.Writer) *cobra.Command {
|
|
27
30
|
cfg := config.Load()
|
|
28
|
-
|
|
29
|
-
runner := common.NewRunner(cfg, client)
|
|
31
|
+
runner := newRootRunner(cfg)
|
|
30
32
|
return newRootCommand(stdout, stderr, runner)
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
func newRootRunner(cfg *config.Config) *common.Runner {
|
|
36
|
+
runner := common.NewRunner(cfg, nil)
|
|
37
|
+
runner.Auth = internal_auth.NewManager(cfg)
|
|
38
|
+
runner.Client = common.NewHTTPClient(
|
|
39
|
+
cfg.BaseURL,
|
|
40
|
+
cfg.HTTPTimeout,
|
|
41
|
+
newRunnerAuthorizer(runner),
|
|
42
|
+
)
|
|
43
|
+
return runner
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func newRunnerAuthorizer(runner *common.Runner) common.RequestAuthorizer {
|
|
47
|
+
return common.NewAccessKeyContextProviderAuthorizer(func(ctx context.Context) (string, error) {
|
|
48
|
+
if runner.Auth == nil {
|
|
49
|
+
return "", nil
|
|
50
|
+
}
|
|
51
|
+
return runner.Auth.ResolveAccessKey(ctx)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
|
|
34
56
|
root := &cobra.Command{
|
|
35
57
|
Use: "pippit-tool-cli",
|
|
36
58
|
Short: "Pippit CLI",
|
|
37
|
-
Long: "Pippit CLI generates and processes videos and images, submits short-drama workflows, downloads generated assets, and updates the installed CLI package.",
|
|
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.",
|
|
38
60
|
Version: version.Current(),
|
|
39
61
|
SilenceUsage: true,
|
|
40
62
|
SilenceErrors: true,
|
|
@@ -43,8 +65,12 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm
|
|
|
43
65
|
root.SetVersionTemplate("{{.Version}}\n")
|
|
44
66
|
root.SetOut(stdout)
|
|
45
67
|
root.SetErr(stderr)
|
|
46
|
-
|
|
68
|
+
root.AddCommand(authcmd.NewLoginCommand(stdout, stderr, runner))
|
|
69
|
+
root.AddCommand(authcmd.NewStatusCommand(stdout, stderr, runner))
|
|
70
|
+
root.AddCommand(authcmd.NewLogoutCommand(stdout, stderr, runner))
|
|
71
|
+
root.AddCommand(canvascmd.NewCommand(stdout, stderr, runner))
|
|
47
72
|
root.AddCommand(newDownloadResultCommand(stdout, stderr, runner))
|
|
73
|
+
root.AddCommand(newGetCreditBalanceCommand(stdout, stderr, runner))
|
|
48
74
|
root.AddCommand(newGetThreadCommand(stdout, stderr, runner))
|
|
49
75
|
root.AddCommand(newListThreadFileCommand(stdout, stderr, runner))
|
|
50
76
|
root.AddCommand(generate_image.NewCommand(stdout, stderr, runner))
|
package/cmd/root_test.go
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
package cmd
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"net/http"
|
|
7
|
+
"net/http/httptest"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
func TestRootRunnerReadsUpdatedAccessKeyForEveryRequest(t *testing.T) {
|
|
16
|
+
received := make([]string, 0, 2)
|
|
17
|
+
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
18
|
+
received = append(received, request.Header.Get("Authorization"))
|
|
19
|
+
writer.Header().Set("Content-Type", "application/json")
|
|
20
|
+
_, _ = writer.Write([]byte(`{"ok":true}`))
|
|
21
|
+
}))
|
|
22
|
+
defer server.Close()
|
|
23
|
+
|
|
24
|
+
cfg := config.Load()
|
|
25
|
+
cfg.BaseURL = server.URL
|
|
26
|
+
cfg.HTTPTimeout = time.Second
|
|
27
|
+
cfg.AccessKey = "first-key"
|
|
28
|
+
runner := newRootRunner(cfg)
|
|
29
|
+
|
|
30
|
+
for _, accessKey := range []string{"first-key", "second-key"} {
|
|
31
|
+
runner.Config.AccessKey = accessKey
|
|
32
|
+
var response map[string]any
|
|
33
|
+
if err := runner.Client.SendRequest(context.Background(), "/probe", map[string]any{}, &response); err != nil {
|
|
34
|
+
t.Fatalf("SendRequest(%q) error = %v", accessKey, err)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if got, want := strings.Join(received, ","), "Bearer first-key,Bearer second-key"; got != want {
|
|
38
|
+
t.Fatalf("Authorization headers = %q, want %q", got, want)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func TestRootRegistersCanvas(t *testing.T) {
|
|
43
|
+
var stdout, stderr bytes.Buffer
|
|
44
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
45
|
+
command, _, err := root.Find([]string{"canvas"})
|
|
46
|
+
if err != nil || command == nil || command.Name() != "canvas" {
|
|
47
|
+
t.Fatalf("root.Find(%q) = %#v, %v", "canvas", command, err)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func TestRootRegistersGetCreditBalance(t *testing.T) {
|
|
52
|
+
var stdout, stderr bytes.Buffer
|
|
53
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
54
|
+
command, _, err := root.Find([]string{"get-credit-balance"})
|
|
55
|
+
if err != nil || command == nil || command.Name() != "get-credit-balance" {
|
|
56
|
+
t.Fatalf("root.Find(%q) = %#v, %v", "get-credit-balance", command, err)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func TestRootRegistersTopLevelBrowserAuthCommands(t *testing.T) {
|
|
61
|
+
var stdout, stderr bytes.Buffer
|
|
62
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
63
|
+
for _, name := range []string{"login", "status", "logout"} {
|
|
64
|
+
command, _, err := root.Find([]string{name})
|
|
65
|
+
if err != nil || command == nil || command.Name() != name {
|
|
66
|
+
t.Fatalf("root.Find(%q) = %#v, %v", name, command, err)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|