@pippit-dev/cli 0.0.1

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.
@@ -0,0 +1,587 @@
1
+ package cmd
2
+
3
+ import (
4
+ "bytes"
5
+ "io"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "testing"
12
+
13
+ "github.com/Pippit-dev/pippit-cli/internal/auth"
14
+ "github.com/Pippit-dev/pippit-cli/internal/common"
15
+ "github.com/Pippit-dev/pippit-cli/internal/config"
16
+ "github.com/bytedance/sonic"
17
+ "github.com/spf13/cobra"
18
+ )
19
+
20
+ func TestShortDramaSubmitRun(t *testing.T) {
21
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22
+ if r.Method != http.MethodPost {
23
+ t.Fatalf("method = %s, want POST", r.Method)
24
+ }
25
+ if r.URL.Path != "/api/biz/v1/skill/submit_run" {
26
+ t.Fatalf("path = %s, want submit_run path", r.URL.Path)
27
+ }
28
+ if r.Header.Get("Authorization") != "Bearer test-token" {
29
+ t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
30
+ }
31
+ data, err := io.ReadAll(r.Body)
32
+ if err != nil {
33
+ t.Fatalf("read body: %v", err)
34
+ }
35
+ var body map[string]any
36
+ if err := sonic.Unmarshal(data, &body); err != nil {
37
+ t.Fatalf("decode body: %v", err)
38
+ }
39
+ if body["message"] != "write a cyberpunk opening" {
40
+ t.Fatalf("message = %v, want submitted message", body["message"])
41
+ }
42
+ if body["thread_id"] != "thread_123" {
43
+ t.Fatalf("thread_id = %v, want thread_123", body["thread_id"])
44
+ }
45
+ if body["agent_name"] != "pippit_nest_novel_agent" {
46
+ t.Fatalf("agent_name = %v, want pippit_nest_novel_agent", body["agent_name"])
47
+ }
48
+ assetIDs, ok := body["asset_ids"].([]any)
49
+ if !ok || len(assetIDs) != 2 || assetIDs[0] != "asset_1" || assetIDs[1] != "asset_2" {
50
+ t.Fatalf("asset_ids = %#v, want two asset ids", body["asset_ids"])
51
+ }
52
+ _, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"run":{"thread_id":"thread_123","run_id":"run_456"},"web_thread_link":"https://xyq.example/thread_123"}}`))
53
+ }))
54
+ defer server.Close()
55
+
56
+ var stdout, stderr bytes.Buffer
57
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
58
+ root.SetArgs([]string{
59
+ "short-drama", "+submit-run",
60
+ "--message", "write a cyberpunk opening",
61
+ "--thread-id", "thread_123",
62
+ "--asset-ids", "asset_1",
63
+ "--asset-ids", "asset_2",
64
+ })
65
+
66
+ if err := root.Execute(); err != nil {
67
+ t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
68
+ }
69
+
70
+ got := decodeJSON(t, stdout.Bytes())
71
+ if got["thread_id"] != "thread_123" {
72
+ t.Fatalf("thread_id = %v, want thread_123", got["thread_id"])
73
+ }
74
+ if got["run_id"] != "run_456" {
75
+ t.Fatalf("run_id = %v, want run_456", got["run_id"])
76
+ }
77
+ if got["web_thread_link"] != "https://xyq.example/thread_123" {
78
+ t.Fatalf("web_thread_link = %v, want returned link", got["web_thread_link"])
79
+ }
80
+ }
81
+
82
+ func TestRootIncludesUpdateCommand(t *testing.T) {
83
+ var stdout, stderr bytes.Buffer
84
+ root := NewRootCommand(&stdout, &stderr)
85
+ cmd, _, err := root.Find([]string{"update"})
86
+ if err != nil {
87
+ t.Fatalf("Find(update) error = %v", err)
88
+ }
89
+ if cmd == nil || cmd.Name() != "update" {
90
+ t.Fatalf("Find(update) = %#v, want update command", cmd)
91
+ }
92
+ }
93
+
94
+ func TestShortDramaSubmitRunRequiresMessage(t *testing.T) {
95
+ var stdout, stderr bytes.Buffer
96
+ root := NewRootCommand(&stdout, &stderr)
97
+ root.SetArgs([]string{"short-drama", "+submit-run"})
98
+
99
+ err := root.Execute()
100
+ if err == nil {
101
+ t.Fatal("Execute() error = nil, want validation error")
102
+ }
103
+ if !strings.Contains(err.Error(), "--message is required") {
104
+ t.Fatalf("error = %q, want message validation", err)
105
+ }
106
+ }
107
+
108
+ func TestShortDramaSubmitRunRequiresAccessKey(t *testing.T) {
109
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
110
+ t.Fatal("server should not receive request without access key")
111
+ }))
112
+ defer server.Close()
113
+
114
+ var stdout, stderr bytes.Buffer
115
+ root := newTestRootCommandWithAccessKey(t, &stdout, &stderr, server.URL, "")
116
+ root.SetArgs([]string{
117
+ "short-drama", "+submit-run",
118
+ "--message", "write a cyberpunk opening",
119
+ })
120
+
121
+ err := root.Execute()
122
+ if err == nil {
123
+ t.Fatal("Execute() error = nil, want access key error")
124
+ }
125
+ if !strings.Contains(err.Error(), "XYQ_ACCESS_KEY is required") {
126
+ t.Fatalf("error = %q, want access key guidance", err)
127
+ }
128
+ }
129
+
130
+ func TestShortDramaUploadFile(t *testing.T) {
131
+ var stdout, stderr bytes.Buffer
132
+ root := NewRootCommand(&stdout, &stderr)
133
+ root.SetArgs([]string{"short-drama", "+upload-file", "--path", "/tmp/story.md"})
134
+
135
+ if err := root.Execute(); err != nil {
136
+ t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
137
+ }
138
+
139
+ got := decodeJSON(t, stdout.Bytes())
140
+ if got["scene"] != "short-drama" {
141
+ t.Fatalf("scene = %v, want short-drama", got["scene"])
142
+ }
143
+ if got["status"] != "uploaded" {
144
+ t.Fatalf("status = %v, want uploaded", got["status"])
145
+ }
146
+ if !strings.HasPrefix(got["file_id"].(string), "file_mock_") {
147
+ t.Fatalf("file_id = %v, want mock file id", got["file_id"])
148
+ }
149
+ }
150
+
151
+ func TestShortDramaUploadFileRequiresPath(t *testing.T) {
152
+ var stdout, stderr bytes.Buffer
153
+ root := NewRootCommand(&stdout, &stderr)
154
+ root.SetArgs([]string{"short-drama", "+upload-file"})
155
+
156
+ err := root.Execute()
157
+ if err == nil {
158
+ t.Fatal("Execute() error = nil, want validation error")
159
+ }
160
+ if !strings.Contains(err.Error(), "--path is required") {
161
+ t.Fatalf("error = %q, want path validation", err)
162
+ }
163
+ }
164
+
165
+ func TestShortDramaDownloadResult(t *testing.T) {
166
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
167
+ if r.Header.Get("User-Agent") != "Pippit-CLI/1.0" {
168
+ t.Fatalf("User-Agent = %q, want Pippit-CLI/1.0", r.Header.Get("User-Agent"))
169
+ }
170
+ switch r.URL.Path {
171
+ case "/image":
172
+ _, _ = w.Write([]byte("image-data"))
173
+ default:
174
+ t.Fatalf("unexpected path %s", r.URL.Path)
175
+ }
176
+ }))
177
+ defer server.Close()
178
+
179
+ chdirTemp(t)
180
+ var stdout, stderr bytes.Buffer
181
+ root := NewRootCommand(&stdout, &stderr)
182
+ outputPath := filepath.Join("results", "cover.jpeg")
183
+ root.SetArgs([]string{
184
+ "short-drama", "+download-result",
185
+ "--output-dir", outputPath,
186
+ "--workers", "2",
187
+ "--url", server.URL + "/image?filename=ignored.jpeg",
188
+ })
189
+
190
+ if err := root.Execute(); err != nil {
191
+ t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
192
+ }
193
+
194
+ got := decodeJSON(t, stdout.Bytes())
195
+ if got["output_dir"] != outputPath {
196
+ t.Fatalf("output_dir = %v, want %s", got["output_dir"], outputPath)
197
+ }
198
+ if got["total"] != float64(1) {
199
+ t.Fatalf("total = %v, want 1", got["total"])
200
+ }
201
+ downloaded, ok := got["downloaded"].([]any)
202
+ if !ok || len(downloaded) != 1 {
203
+ t.Fatalf("downloaded = %#v, want one file", got["downloaded"])
204
+ }
205
+ wantFiles := []string{
206
+ outputPath,
207
+ }
208
+ for i, want := range wantFiles {
209
+ if downloaded[i] != want {
210
+ t.Fatalf("downloaded[%d] = %v, want %s", i, downloaded[i], want)
211
+ }
212
+ }
213
+ assertFileContent(t, wantFiles[0], "image-data")
214
+ }
215
+
216
+ func TestShortDramaDownloadResultSkipsExistingFile(t *testing.T) {
217
+ serverCalled := false
218
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
219
+ serverCalled = true
220
+ t.Fatal("server should not receive request when target file already exists")
221
+ }))
222
+ defer server.Close()
223
+
224
+ chdirTemp(t)
225
+ outputPath := filepath.Join("results", "cover.jpeg")
226
+ if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
227
+ t.Fatalf("MkdirAll(): %v", err)
228
+ }
229
+ if err := os.WriteFile(outputPath, []byte("existing-data"), 0o644); err != nil {
230
+ t.Fatalf("WriteFile(): %v", err)
231
+ }
232
+
233
+ var stdout, stderr bytes.Buffer
234
+ root := NewRootCommand(&stdout, &stderr)
235
+ root.SetArgs([]string{
236
+ "short-drama", "+download-result",
237
+ "--output-dir", outputPath,
238
+ "--url", server.URL + "/image",
239
+ })
240
+
241
+ if err := root.Execute(); err != nil {
242
+ t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
243
+ }
244
+ if serverCalled {
245
+ t.Fatal("server was called, want existing file to skip download")
246
+ }
247
+
248
+ got := decodeJSON(t, stdout.Bytes())
249
+ if got["output_dir"] != outputPath {
250
+ t.Fatalf("output_dir = %v, want %s", got["output_dir"], outputPath)
251
+ }
252
+ if got["total"] != float64(0) {
253
+ t.Fatalf("total = %v, want 0", got["total"])
254
+ }
255
+ alreadyExist, ok := got["already_exist"].([]any)
256
+ if !ok || len(alreadyExist) != 1 || alreadyExist[0] != outputPath {
257
+ t.Fatalf("already_exist = %#v, want existing output path", got["already_exist"])
258
+ }
259
+ assertFileContent(t, outputPath, "existing-data")
260
+ }
261
+
262
+ func TestShortDramaDownloadResultRequiresOutputDir(t *testing.T) {
263
+ var stdout, stderr bytes.Buffer
264
+ root := NewRootCommand(&stdout, &stderr)
265
+ root.SetArgs([]string{"short-drama", "+download-result", "--url", "https://example.com/image.png"})
266
+
267
+ err := root.Execute()
268
+ if err == nil {
269
+ t.Fatal("Execute() error = nil, want validation error")
270
+ }
271
+ if !strings.Contains(err.Error(), "--output-dir is required") {
272
+ t.Fatalf("error = %q, want output-dir validation", err)
273
+ }
274
+ }
275
+
276
+ func TestShortDramaDownloadResultRequiresURL(t *testing.T) {
277
+ var stdout, stderr bytes.Buffer
278
+ root := NewRootCommand(&stdout, &stderr)
279
+ root.SetArgs([]string{"short-drama", "+download-result", "--output-dir", filepath.Join("results", "image.png")})
280
+
281
+ err := root.Execute()
282
+ if err == nil {
283
+ t.Fatal("Execute() error = nil, want validation error")
284
+ }
285
+ if !strings.Contains(err.Error(), "--url is required") {
286
+ t.Fatalf("error = %q, want url validation", err)
287
+ }
288
+ }
289
+
290
+ func TestShortDramaDownloadResultRejectsInvalidScheme(t *testing.T) {
291
+ var stdout, stderr bytes.Buffer
292
+ root := NewRootCommand(&stdout, &stderr)
293
+ root.SetArgs([]string{"short-drama", "+download-result", "--output-dir", filepath.Join("results", "image.png"), "--url", "file:///etc/passwd"})
294
+
295
+ err := root.Execute()
296
+ if err == nil {
297
+ t.Fatal("Execute() error = nil, want scheme validation error")
298
+ }
299
+ if !strings.Contains(err.Error(), "only http and https are allowed") {
300
+ t.Fatalf("error = %q, want scheme validation", err)
301
+ }
302
+ }
303
+
304
+ func TestShortDramaDownloadResultAllFailed(t *testing.T) {
305
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
306
+ w.WriteHeader(http.StatusNotFound)
307
+ }))
308
+ defer server.Close()
309
+
310
+ chdirTemp(t)
311
+ var stdout, stderr bytes.Buffer
312
+ root := NewRootCommand(&stdout, &stderr)
313
+ root.SetArgs([]string{
314
+ "short-drama", "+download-result",
315
+ "--output-dir", filepath.Join("results", "missing.png"),
316
+ "--url", server.URL + "/notfound",
317
+ })
318
+
319
+ err := root.Execute()
320
+ if err == nil {
321
+ t.Fatal("Execute() error = nil, want all-failed error")
322
+ }
323
+ if !strings.Contains(err.Error(), "all 1 download(s) failed") {
324
+ t.Fatalf("error = %q, want all-failed message", err)
325
+ }
326
+ }
327
+
328
+ func TestShortDramaDownloadResultOutputPath(t *testing.T) {
329
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
330
+ _, _ = w.Write([]byte("image-data"))
331
+ }))
332
+ defer server.Close()
333
+
334
+ chdirTemp(t)
335
+
336
+ var stdout, stderr bytes.Buffer
337
+ root := NewRootCommand(&stdout, &stderr)
338
+ outputPath := filepath.Join("custom", "nested", "cover.png")
339
+ root.SetArgs([]string{
340
+ "short-drama", "+download-result",
341
+ "--output-dir", outputPath,
342
+ "--url", server.URL + "/image.png",
343
+ })
344
+
345
+ if err := root.Execute(); err != nil {
346
+ t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
347
+ }
348
+
349
+ got := decodeJSON(t, stdout.Bytes())
350
+ if got["output_dir"] != outputPath {
351
+ t.Fatalf("output_dir = %v, want %s", got["output_dir"], outputPath)
352
+ }
353
+ assertFileContent(t, outputPath, "image-data")
354
+ }
355
+
356
+ func TestShortDramaGetThread(t *testing.T) {
357
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
358
+ if r.Method != http.MethodPost {
359
+ t.Fatalf("method = %s, want POST", r.Method)
360
+ }
361
+ if r.URL.Path != "/api/biz/v1/skill/get_thread" {
362
+ t.Fatalf("path = %s, want get_thread path", r.URL.Path)
363
+ }
364
+ if r.Header.Get("Authorization") != "Bearer test-token" {
365
+ t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
366
+ }
367
+ data, err := io.ReadAll(r.Body)
368
+ if err != nil {
369
+ t.Fatalf("read body: %v", err)
370
+ }
371
+ var body map[string]any
372
+ if err := sonic.Unmarshal(data, &body); err != nil {
373
+ t.Fatalf("decode body: %v", err)
374
+ }
375
+ if body["thread_id"] != "thread_123" {
376
+ t.Fatalf("thread_id = %v, want thread_123", body["thread_id"])
377
+ }
378
+ if body["run_id"] != "run_456" {
379
+ t.Fatalf("run_id = %v, want run_456", body["run_id"])
380
+ }
381
+ if body["after_seq"] != float64(7) {
382
+ t.Fatalf("after_seq = %v, want 7", body["after_seq"])
383
+ }
384
+ _, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"thread":{"run_list":[{"state":3,"entry_list":[{"message":{"message_id":"msg_1","role":"assistant","content":[{"text":"hello"}],"client_tool_calls":[{"name":"tool_call"}]}},{"artifact":{"artifact_id":"artifact_1","role":"assistant","content":[{"type":"image"}]}}]}]}}}`))
385
+ }))
386
+ defer server.Close()
387
+
388
+ var stdout, stderr bytes.Buffer
389
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
390
+ root.SetArgs([]string{
391
+ "short-drama", "+get-thread",
392
+ "--thread-id", "thread_123",
393
+ "--run-id", "run_456",
394
+ "--after-seq", "7",
395
+ })
396
+
397
+ if err := root.Execute(); err != nil {
398
+ t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
399
+ }
400
+
401
+ got := decodeJSON(t, stdout.Bytes())
402
+ messages, ok := got["messages"].([]any)
403
+ if !ok || len(messages) != 2 {
404
+ t.Fatalf("messages = %#v, want two entries", got["messages"])
405
+ }
406
+ first, ok := messages[0].(map[string]any)
407
+ if !ok {
408
+ t.Fatalf("first message = %#v, want object", messages[0])
409
+ }
410
+ if first["id"] != "msg_1" {
411
+ t.Fatalf("first id = %v, want msg_1", first["id"])
412
+ }
413
+ content, ok := first["content"].([]any)
414
+ if !ok || len(content) != 2 {
415
+ t.Fatalf("first content = %#v, want message content plus tool call", first["content"])
416
+ }
417
+ }
418
+
419
+ func TestShortDramaGetThreadRequiresThreadID(t *testing.T) {
420
+ var stdout, stderr bytes.Buffer
421
+ root := NewRootCommand(&stdout, &stderr)
422
+ root.SetArgs([]string{"short-drama", "+get-thread"})
423
+
424
+ err := root.Execute()
425
+ if err == nil {
426
+ t.Fatal("Execute() error = nil, want validation error")
427
+ }
428
+ if !strings.Contains(err.Error(), "--thread-id is required") {
429
+ t.Fatalf("error = %q, want thread-id validation", err)
430
+ }
431
+ }
432
+
433
+ func TestShortDramaGetThreadRequiresAccessKey(t *testing.T) {
434
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
435
+ t.Fatal("server should not receive request without access key")
436
+ }))
437
+ defer server.Close()
438
+
439
+ var stdout, stderr bytes.Buffer
440
+ root := newTestRootCommandWithAccessKey(t, &stdout, &stderr, server.URL, "")
441
+ root.SetArgs([]string{"short-drama", "+get-thread", "--thread-id", "thread_123"})
442
+
443
+ err := root.Execute()
444
+ if err == nil {
445
+ t.Fatal("Execute() error = nil, want access key error")
446
+ }
447
+ if !strings.Contains(err.Error(), "XYQ_ACCESS_KEY is required") {
448
+ t.Fatalf("error = %q, want access key guidance", err)
449
+ }
450
+ }
451
+
452
+ func TestShortDramaListThreadFile(t *testing.T) {
453
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
454
+ if r.Method != http.MethodPost {
455
+ t.Fatalf("method = %s, want POST", r.Method)
456
+ }
457
+ if r.URL.Path != "/api/biz/v1/skill/list_thread_file" {
458
+ t.Fatalf("path = %s, want list_thread_file path", r.URL.Path)
459
+ }
460
+ if r.Header.Get("Authorization") != "Bearer test-token" {
461
+ t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
462
+ }
463
+ data, err := io.ReadAll(r.Body)
464
+ if err != nil {
465
+ t.Fatalf("read body: %v", err)
466
+ }
467
+ var body map[string]any
468
+ if err := sonic.Unmarshal(data, &body); err != nil {
469
+ t.Fatalf("decode body: %v", err)
470
+ }
471
+ if body["thread_id"] != "thread_123" {
472
+ t.Fatalf("thread_id = %v, want thread_123", body["thread_id"])
473
+ }
474
+ if body["page_num"] != float64(2) {
475
+ t.Fatalf("page_num = %v, want 2", body["page_num"])
476
+ }
477
+ if body["page_size"] != float64(10) {
478
+ t.Fatalf("page_size = %v, want 10", body["page_size"])
479
+ }
480
+ _, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"total":1,"files":[{"file_name":"ignored.png","file_path":"results/images/cover.png","download_url":"https://example.com/cover.png"}]}}`))
481
+ }))
482
+ defer server.Close()
483
+
484
+ var stdout, stderr bytes.Buffer
485
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
486
+ root.SetArgs([]string{
487
+ "short-drama", "+list-thread-file",
488
+ "--thread-id", "thread_123",
489
+ "--page-num", "2",
490
+ "--page-size", "10",
491
+ })
492
+
493
+ if err := root.Execute(); err != nil {
494
+ t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
495
+ }
496
+
497
+ got := decodeJSON(t, stdout.Bytes())
498
+ if got["total"] != float64(1) {
499
+ t.Fatalf("total = %v, want 1", got["total"])
500
+ }
501
+ files, ok := got["files"].([]any)
502
+ if !ok || len(files) != 1 {
503
+ t.Fatalf("files = %#v, want one file", got["files"])
504
+ }
505
+ file, ok := files[0].(map[string]any)
506
+ if !ok {
507
+ t.Fatalf("file = %#v, want object", files[0])
508
+ }
509
+ if _, ok := file["file_name"]; ok {
510
+ t.Fatalf("file_name should not be returned: %#v", file)
511
+ }
512
+ wantPath := "." + string(os.PathSeparator) + filepath.Join("thread_123", "results/images/cover.png")
513
+ if file["file_path"] != wantPath {
514
+ t.Fatalf("file_path = %v, want %s", file["file_path"], wantPath)
515
+ }
516
+ if file["download_url"] != "https://example.com/cover.png" {
517
+ t.Fatalf("download_url = %v, want returned url", file["download_url"])
518
+ }
519
+ }
520
+
521
+ func TestShortDramaListThreadFileRequiresThreadID(t *testing.T) {
522
+ var stdout, stderr bytes.Buffer
523
+ root := NewRootCommand(&stdout, &stderr)
524
+ root.SetArgs([]string{"short-drama", "+list-thread-file"})
525
+
526
+ err := root.Execute()
527
+ if err == nil {
528
+ t.Fatal("Execute() error = nil, want validation error")
529
+ }
530
+ if !strings.Contains(err.Error(), "--thread-id is required") {
531
+ t.Fatalf("error = %q, want thread-id validation", err)
532
+ }
533
+ }
534
+
535
+ func newTestRootCommand(t *testing.T, stdout, stderr io.Writer, baseURL string) *cobra.Command {
536
+ t.Helper()
537
+ return newTestRootCommandWithAccessKey(t, stdout, stderr, baseURL, "test-token")
538
+ }
539
+
540
+ func newTestRootCommandWithAccessKey(t *testing.T, stdout, stderr io.Writer, baseURL string, accessKey string) *cobra.Command {
541
+ t.Helper()
542
+ cfg := config.Load()
543
+ cfg.BaseURL = baseURL
544
+ cfg.AccessKey = accessKey
545
+ authAuthorizer := auth.NewManager(cfg)
546
+ client := common.NewHTTPClient(cfg.BaseURL, cfg.HTTPTimeout, common.NewAccessKeyAuthorizer(cfg.AccessKey))
547
+ runner := common.NewRunner(cfg, client, authAuthorizer)
548
+ return newRootCommand(stdout, stderr, runner)
549
+ }
550
+
551
+ func decodeJSON(t *testing.T, data []byte) map[string]any {
552
+ t.Helper()
553
+ var got map[string]any
554
+ if err := sonic.Unmarshal(data, &got); err != nil {
555
+ t.Fatalf("stdout is not JSON: %v\n%s", err, string(data))
556
+ }
557
+ return got
558
+ }
559
+
560
+ func chdirTemp(t *testing.T) string {
561
+ t.Helper()
562
+ cwd := t.TempDir()
563
+ oldWD, err := os.Getwd()
564
+ if err != nil {
565
+ t.Fatalf("Getwd(): %v", err)
566
+ }
567
+ if err := os.Chdir(cwd); err != nil {
568
+ t.Fatalf("Chdir(%s): %v", cwd, err)
569
+ }
570
+ t.Cleanup(func() {
571
+ if err := os.Chdir(oldWD); err != nil {
572
+ t.Fatalf("restore working dir: %v", err)
573
+ }
574
+ })
575
+ return cwd
576
+ }
577
+
578
+ func assertFileContent(t *testing.T, path string, want string) {
579
+ t.Helper()
580
+ data, err := os.ReadFile(path)
581
+ if err != nil {
582
+ t.Fatalf("ReadFile(%s): %v", path, err)
583
+ }
584
+ if string(data) != want {
585
+ t.Fatalf("ReadFile(%s) = %q, want %q", path, string(data), want)
586
+ }
587
+ }
@@ -0,0 +1,124 @@
1
+ package updatecmd
2
+
3
+ import (
4
+ "fmt"
5
+ "io"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "runtime"
10
+ "strings"
11
+
12
+ "github.com/spf13/cobra"
13
+ )
14
+
15
+ const defaultPackage = "@pippit-dev/cli"
16
+
17
+ // NewCommand builds the update command.
18
+ func NewCommand(stdout, stderr io.Writer) *cobra.Command {
19
+ cmd := &cobra.Command{
20
+ Use: "update",
21
+ Short: "Update pippit-cli and bundled skills",
22
+ RunE: func(cmd *cobra.Command, _ []string) error {
23
+ return runUpdate(stdout, stderr)
24
+ },
25
+ }
26
+ return cmd
27
+ }
28
+
29
+ func runUpdate(stdout, stderr io.Writer) error {
30
+ pkg := os.Getenv("PIPPIT_CLI_INSTALL_PACKAGE")
31
+ if pkg == "" {
32
+ pkg = defaultPackage + "@latest"
33
+ }
34
+
35
+ fmt.Fprintf(stderr, "Updating pippit-cli via npm: %s\n", pkg)
36
+ restore, err := prepareSelfReplace()
37
+ if err != nil {
38
+ return fmt.Errorf("prepare self replace: %w", err)
39
+ }
40
+ if err := runInheritEnv(stderr, []string{"PIPPIT_CLI_SKIP_SKILLS=1"}, "npm", "install", "-g", pkg); err != nil {
41
+ restore()
42
+ return fmt.Errorf("update pippit-cli: %w", err)
43
+ }
44
+
45
+ root, err := globalPackageRoot(defaultPackage)
46
+ if err != nil {
47
+ return fmt.Errorf("locate global package: %w", err)
48
+ }
49
+
50
+ fmt.Fprintln(stderr, "Updating pippit-cli skills...")
51
+ if err := installSkills(root, stderr); err != nil {
52
+ return fmt.Errorf("update pippit-cli skills: %w", err)
53
+ }
54
+
55
+ fmt.Fprintln(stdout, "pippit-cli and skills updated")
56
+ return nil
57
+ }
58
+
59
+ func globalPackageRoot(pkg string) (string, error) {
60
+ out, err := command("npm", "root", "-g").Output()
61
+ if err != nil {
62
+ return "", err
63
+ }
64
+ npmRoot := strings.TrimSpace(string(out))
65
+ if npmRoot == "" {
66
+ return "", fmt.Errorf("npm root -g returned empty output")
67
+ }
68
+ return filepath.Join(append([]string{npmRoot}, strings.Split(pkg, "/")...)...), nil
69
+ }
70
+
71
+ func installSkills(root string, stderr io.Writer) error {
72
+ if info, err := os.Stat(filepath.Join(root, "skills")); err != nil {
73
+ return err
74
+ } else if !info.IsDir() {
75
+ return fmt.Errorf("%s is not a directory", filepath.Join(root, "skills"))
76
+ }
77
+ return runInherit(stderr, "npx", "-y", "skills", "add", root, "-g", "-y")
78
+ }
79
+
80
+ func runInherit(stderr io.Writer, name string, args ...string) error {
81
+ return runInheritEnv(stderr, nil, name, args...)
82
+ }
83
+
84
+ func runInheritEnv(stderr io.Writer, env []string, name string, args ...string) error {
85
+ cmd := command(name, args...)
86
+ if len(env) > 0 {
87
+ cmd.Env = append(os.Environ(), env...)
88
+ }
89
+ cmd.Stdout = stderr
90
+ cmd.Stderr = stderr
91
+ return cmd.Run()
92
+ }
93
+
94
+ func command(name string, args ...string) *exec.Cmd {
95
+ if runtime.GOOS == "windows" {
96
+ cmdArgs := append([]string{"/c", name}, args...)
97
+ return exec.Command("cmd.exe", cmdArgs...)
98
+ }
99
+ return exec.Command(name, args...)
100
+ }
101
+
102
+ func prepareSelfReplace() (func(), error) {
103
+ if runtime.GOOS != "windows" {
104
+ return func() {}, nil
105
+ }
106
+ exe, err := os.Executable()
107
+ if err != nil {
108
+ return nil, err
109
+ }
110
+ if filepath.Base(exe) != "pippit-cli.exe" || !strings.Contains(exe, "node_modules") {
111
+ return func() {}, nil
112
+ }
113
+ old := exe + ".old"
114
+ _ = os.Remove(old)
115
+ if err := os.Rename(exe, old); err != nil {
116
+ return nil, err
117
+ }
118
+ return func() {
119
+ if _, err := os.Stat(exe); err == nil {
120
+ return
121
+ }
122
+ _ = os.Rename(old, exe)
123
+ }, nil
124
+ }