@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.
Files changed (50) hide show
  1. package/README.md +41 -2
  2. package/checksums.txt +6 -6
  3. package/cmd/auth/auth.go +136 -142
  4. package/cmd/auth/auth_test.go +134 -0
  5. package/cmd/canvas/canvas.go +241 -0
  6. package/cmd/canvas/canvas_test.go +182 -0
  7. package/cmd/get_credit_balance.go +37 -0
  8. package/cmd/get_credit_balance_test.go +73 -0
  9. package/cmd/root.go +31 -5
  10. package/cmd/root_test.go +69 -0
  11. package/cmd/short_drama_test.go +5 -7
  12. package/cmd/update/update.go +34 -5
  13. package/cmd/update/update_test.go +84 -0
  14. package/dist/checksums.txt +6 -0
  15. package/dist/xyq-canvas-command-runtime.cjs +16 -0
  16. package/dist/xyq-canvas-command-runtime.cjs.LEGAL.txt +599 -0
  17. package/dist/xyq-canvas-command-runtime.cjs.sha256 +2 -0
  18. package/internal/auth/auth_test.go +813 -0
  19. package/internal/auth/browser_darwin.go +14 -0
  20. package/internal/auth/browser_env.go +35 -0
  21. package/internal/auth/browser_linux.go +14 -0
  22. package/internal/auth/browser_windows.go +14 -0
  23. package/internal/auth/identity.go +89 -0
  24. package/internal/auth/loopback.go +324 -0
  25. package/internal/auth/manager.go +338 -144
  26. package/internal/auth/store.go +303 -0
  27. package/internal/auth/store_file_unix.go +176 -0
  28. package/internal/auth/store_file_windows.go +11 -0
  29. package/internal/auth/types.go +72 -0
  30. package/internal/canvas/allocate.go +70 -0
  31. package/internal/canvas/apply.go +279 -0
  32. package/internal/canvas/canvas_test.go +559 -0
  33. package/internal/canvas/create.go +380 -0
  34. package/internal/canvas/get.go +156 -0
  35. package/internal/canvas/types.go +68 -0
  36. package/internal/canvas/upload.go +250 -0
  37. package/internal/common/access_key.go +42 -6
  38. package/internal/common/access_key_test.go +113 -0
  39. package/internal/common/client.go +119 -21
  40. package/internal/common/client_test.go +213 -0
  41. package/internal/common/get_credit_balance.go +61 -0
  42. package/internal/common/get_credit_balance_test.go +75 -0
  43. package/internal/common/runner.go +15 -0
  44. package/internal/config/config.go +11 -28
  45. package/internal/config/config_test.go +3 -18
  46. package/package.json +9 -2
  47. package/scripts/canvas-command.js +881 -0
  48. package/scripts/run.js +21 -4
  49. package/skills/short-drama/SKILL.md +4 -4
  50. package/skills/xyq-nest-skill/SKILL.md +11 -1
package/README.md CHANGED
@@ -205,8 +205,9 @@ python3 skills/xyq-nest-skill/scripts/download_results.py \
205
205
 
206
206
  ```bash
207
207
  npx @pippit-dev/cli@latest install
208
- export XYQ_ACCESS_KEY="<access-key>"
208
+ pippit-tool-cli login
209
209
  pippit-tool-cli --version
210
+ pippit-tool-cli get-credit-balance
210
211
  pippit-tool-cli short-drama +submit-run --message "写一个赛博朋克短剧开头"
211
212
  pippit-tool-cli short-drama +upload-file --path ./reference.doc
212
213
  pippit-tool-cli get-thread --thread-id thread_123 --run-id run_456
@@ -214,6 +215,8 @@ pippit-tool-cli list-thread-file --thread-id thread_123 --page-num 1 --page-size
214
215
  pippit-tool-cli download-result --output-path ./thread_123/results/result.mp4 --url URL --updated-at 1779716734
215
216
  ```
216
217
 
218
+ `get-credit-balance`: 使用当前登录凭证查询个人有效积分余额,并输出 `{"total_remain_amount":"123"}`;零余额会显式输出为 `"0"`。加 `--with-log-id` 可在输出中同时保留本次请求的 `log_id`。
219
+
217
220
  `+submit-run`: 输出 `thread_id`、`run_id` 和 `web_thread_link`;其中 `--message` 为必填参数。
218
221
  `get-thread`: 请求中带 `version=v2`,并输出 `readable_text`。
219
222
  `list-thread-file`: 输出会话文件列表、分页提示和可直接传给下载命令的 `file_path`。
@@ -222,6 +225,40 @@ pippit-tool-cli download-result --output-path ./thread_123/results/result.mp4 --
222
225
 
223
226
  短剧命令的错误日志会追加写入本地每日日志文件:`~/.pippit_tool_cli/logs/yyyy-mm-dd.log`。日志路径会基于当前用户主目录和系统路径分隔符生成,因此可在 macOS、Linux 和 Windows 上使用。
224
227
 
228
+ ## Canvas 原子命令
229
+
230
+ CLI 提供个人漫剧画布的通用原子命令,不包含特定来源的导入或转换逻辑:
231
+
232
+ ```bash
233
+ # 首次使用时打开小云雀网页授权
234
+ pippit-tool-cli login
235
+ pippit-tool-cli status
236
+
237
+ # 创建、分配资产 ID、查询、上传与提交单个画布 transaction
238
+ pippit-tool-cli canvas create --title "CLI Canvas" --wait
239
+ pippit-tool-cli canvas allocate --count 3
240
+ pippit-tool-cli canvas get --asset-id PIPPIT_ASSET_ID
241
+ pippit-tool-cli canvas upload --path ./reference.png
242
+ pippit-tool-cli canvas apply --project-id PROJECT_ID --file ./patch.json
243
+ ```
244
+
245
+ 五个命令均输出单行 JSON,资源 ID 保持字符串。`allocate` 只预留 ID,实际资产仍由后续 `apply` transaction 创建。`create` 的 `request_id` 用于追踪,不是跨服务崩溃窗口的严格幂等键;写请求结果不明确时不要盲目重放,应先使用 `canvas get` 回读确认。`apply` 当前只接受一个 transaction,但该 transaction 可以包含多个 patches;CLI 会严格检查 transaction ACK 和每个目标资产的新版本。
246
+
247
+ 通过 npm 安装的 CLI 还提供基于同一 Canvas SDK 的语义命令目录:
248
+
249
+ ```bash
250
+ # 查看全部公开命令及其参数说明
251
+ pippit-tool-cli canvas command list
252
+ pippit-tool-cli canvas command describe create_biz_node
253
+
254
+ # 由 SDK 业务工厂创建角色节点;修改会通过现有 canvas apply 原子提交
255
+ pippit-tool-cli canvas command run create_biz_node \
256
+ --canvas-id PIPPIT_CANVAS_ASSET_ID \
257
+ --input '{"nodeKind":"role","initialData":{"nodeName":"测试角色"}}'
258
+ ```
259
+
260
+ `canvas command` 由 npm 包内固定的 Canvas SDK 运行时提供,复用网页登录、`canvas get`、`canvas allocate` 和 `canvas apply`;不会读取或打印 Access Key,也不直接选择服务端地址。公开目录只包含已登记的 mutation 和业务命令,不开放任意内部 command 调用。
261
+
225
262
  ## 生图 CLI
226
263
 
227
264
  `generate-image` 会上传本地参考图片,然后向综合 Nest Agent 提交生图请求:
@@ -333,4 +370,6 @@ pippit-tool-cli query-result \
333
370
 
334
371
  ## 鉴权
335
372
 
336
- `short-drama +submit-run`、`get-thread`、`list-thread-file`、`short-drama +upload-file` 以及 `xyq-skill` Python 脚本都使用 `Authorization: Bearer <XYQ_ACCESS_KEY>` 鉴权。OAuth 命令代码仍保留在仓库中,但短剧运行时请求不使用 OAuth。
373
+ 原生 CLI 命令通过 `pippit-tool-cli login` 打开小云雀网页授权,并把本机设备专属凭证保存到系统安全凭证库;Access Key 不会显示在终端。可用 `pippit-tool-cli status` 查看状态、`pippit-tool-cli logout` 清除本机登录。
374
+
375
+ CI 或 Agent 可继续显式设置 `XYQ_ACCESS_KEY`,它会覆盖本机网页登录凭证;配置错误时不会静默回退到个人登录。`skills/xyq-nest-skill/scripts` 下的独立 Python 脚本尚未接入原生 CLI 凭证库,当前仍需要该环境变量。
package/checksums.txt CHANGED
@@ -1,6 +1,6 @@
1
- d6750507d8f6aa3c5ca7eccc020eda460d8d6d928de927da9a977696e36dd41d pippit-tool-cli-1.0.17-darwin-amd64.tar.gz
2
- 1583340a524e123f9458044468c1a2950d36b0eb79f85c19f4801811565caf87 pippit-tool-cli-1.0.17-darwin-arm64.tar.gz
3
- 8a6bc206694a141a860971964b4c7493bfec21539c85b406092be6aae7712269 pippit-tool-cli-1.0.17-linux-amd64.tar.gz
4
- 5e25609c585e4f6e02ae7adc2eb0e4be7d14c35152f23d6113070b3e679617ab pippit-tool-cli-1.0.17-linux-arm64.tar.gz
5
- ef883ce5b445bd41774303729fefda216cce17610131f9a34b5f441ad6607cfd pippit-tool-cli-1.0.17-windows-amd64.zip
6
- ff47a915c07718b40e6342e35b21091f8db84ea7efd1adc351da274f5a2c6e62 pippit-tool-cli-1.0.17-windows-arm64.zip
1
+ 8bbce765a6e25262d7b65c426f74809cb1b56f38e8509c821cff8a5b06d59ba9 pippit-tool-cli-1.0.20-darwin-amd64.tar.gz
2
+ 0cc1d8f2cf1197dcc2992f19e593279e9bc4fb1c72f25f220a933288ab7c8877 pippit-tool-cli-1.0.20-darwin-arm64.tar.gz
3
+ 38e1fb6590cbe62876625ffbc027099992db1a0cb42099c4b3b64e1cc5aae5b3 pippit-tool-cli-1.0.20-linux-amd64.tar.gz
4
+ d44a96fa3cadd7da7e9e1279cb49659d2d9ab7da12f8695267deeb4fd08b33bc pippit-tool-cli-1.0.20-linux-arm64.tar.gz
5
+ 53d350752fcacb60667f0a1733b9edc0be4e2b77744e042f90726584493fb506 pippit-tool-cli-1.0.20-windows-amd64.zip
6
+ 2c04a6f002f3aa0005e6cab4eb18dba5d87bc4f1e158b432bf19c658e4f59b1f pippit-tool-cli-1.0.20-windows-arm64.zip
package/cmd/auth/auth.go CHANGED
@@ -1,144 +1,138 @@
1
1
  package authcmd
2
2
 
3
- //import (
4
- // "fmt"
5
- // "io"
6
- // "strings"
7
- // "time"
8
- //
9
- // "github.com/Pippit-dev/pippit-cli/internal/auth"
10
- // "github.com/Pippit-dev/pippit-cli/internal/common"
11
- // "github.com/bytedance/sonic"
12
- // "github.com/spf13/cobra"
13
- //)
14
- //
15
- //type checkResult struct {
16
- // Pending bool `json:"pending"`
17
- // State any `json:"state,omitempty"`
18
- //}
19
- //
20
- //func NewCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
21
- // cmd := &cobra.Command{
22
- // Use: "auth",
23
- // Short: "Manage Pippit OAuth login state",
24
- // }
25
- // cmd.SetOut(stdout)
26
- // cmd.SetErr(stderr)
27
- // cmd.AddCommand(newLoginCommand(stdout, stderr, runner))
28
- // cmd.AddCommand(newCheckCommand(stdout, stderr, runner))
29
- // cmd.AddCommand(newStatusCommand(stdout, stderr, runner))
30
- // cmd.AddCommand(newLogoutCommand(stdout, stderr, runner))
31
- // return cmd
32
- //}
33
- //
34
- //func newLoginCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
35
- // cmd := &cobra.Command{
36
- // Use: "login",
37
- // Short: "Start an OAuth device login flow",
38
- // Args: cobra.NoArgs,
39
- // RunE: func(cmd *cobra.Command, _ []string) error {
40
- // if runner == nil || runner.AuthAuthorizer == nil {
41
- // return fmt.Errorf("auth manager is required")
42
- // }
43
- // flow, err := runner.AuthAuthorizer.NewLoginFlow(cmd.Context())
44
- // if err != nil {
45
- // return err
46
- // }
47
- // return writeJSON(stdout, flow)
48
- // },
49
- // }
50
- // cmd.SetOut(stdout)
51
- // cmd.SetErr(stderr)
52
- // return cmd
53
- //}
54
- //
55
- //func newCheckCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
56
- // var deviceCode string
57
- // cmd := &cobra.Command{
58
- // Use: "check",
59
- // Short: "Check whether an OAuth device login has completed",
60
- // Args: cobra.NoArgs,
61
- // RunE: func(cmd *cobra.Command, _ []string) error {
62
- // deviceCode = strings.TrimSpace(deviceCode)
63
- // if deviceCode == "" {
64
- // return fmt.Errorf("--device-code is required")
65
- // }
66
- // if runner == nil || runner.AuthAuthorizer == nil {
67
- // return fmt.Errorf("auth manager is required")
68
- // }
69
- // state, err := runner.AuthAuthorizer.CheckLogin(cmd.Context(), deviceCode)
70
- // if auth.IsLoginPending(err) {
71
- // return writeJSON(stdout, checkResult{Pending: true})
72
- // }
73
- // if err != nil {
74
- // return err
75
- // }
76
- // v := map[string]any{
77
- // "logged_in": state.LoggedIn,
78
- // "expires_at": state.ExpiresAt.Format(time.RFC3339),
79
- // }
80
- // return writeJSON(stdout, checkResult{State: v})
81
- // },
82
- // }
83
- // cmd.SetOut(stdout)
84
- // cmd.SetErr(stderr)
85
- // cmd.Flags().StringVar(&deviceCode, "device-code", "", "device code returned by auth login")
86
- // return cmd
87
- //}
88
- //
89
- //func newStatusCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
90
- // cmd := &cobra.Command{
91
- // Use: "status",
92
- // Short: "Show current OAuth login state",
93
- // Args: cobra.NoArgs,
94
- // RunE: func(cmd *cobra.Command, _ []string) error {
95
- // if runner == nil || runner.AuthAuthorizer == nil {
96
- // return fmt.Errorf("auth manager is required")
97
- // }
98
- // state, err := runner.AuthAuthorizer.State(cmd.Context())
99
- // if err != nil {
100
- // return err
101
- // }
102
- // if !state.LoggedIn {
103
- // return fmt.Errorf("not logged in")
104
- // }
105
- // v := map[string]any{
106
- // "logged_in": state.LoggedIn,
107
- // "expires_at": state.ExpiresAt.Format(time.RFC3339),
108
- // }
109
- // return writeJSON(stdout, v)
110
- // },
111
- // }
112
- // cmd.SetOut(stdout)
113
- // cmd.SetErr(stderr)
114
- // return cmd
115
- //}
116
- //
117
- //func newLogoutCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
118
- // cmd := &cobra.Command{
119
- // Use: "logout",
120
- // Short: "Clear local OAuth login state",
121
- // Args: cobra.NoArgs,
122
- // RunE: func(cmd *cobra.Command, _ []string) error {
123
- // if runner == nil || runner.AuthAuthorizer == nil {
124
- // return fmt.Errorf("auth manager is required")
125
- // }
126
- // if err := runner.AuthAuthorizer.Logout(cmd.Context()); err != nil {
127
- // return err
128
- // }
129
- // return writeJSON(stdout, map[string]bool{"logged_out": true})
130
- // },
131
- // }
132
- // cmd.SetOut(stdout)
133
- // cmd.SetErr(stderr)
134
- // return cmd
135
- //}
136
- //
137
- //func writeJSON(w io.Writer, v any) error {
138
- // data, err := sonic.Marshal(v)
139
- // if err != nil {
140
- // return err
141
- // }
142
- // _, err = fmt.Fprintln(w, string(data))
143
- // return err
144
- //}
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "fmt"
7
+ "io"
8
+ "strings"
9
+ "time"
10
+
11
+ internal_auth "github.com/Pippit-dev/pippit-cli/internal/auth"
12
+ "github.com/Pippit-dev/pippit-cli/internal/common"
13
+ "github.com/spf13/cobra"
14
+ )
15
+
16
+ type loginResult struct {
17
+ LoggedIn bool `json:"logged_in"`
18
+ Source string `json:"source"`
19
+ UID string `json:"uid,omitempty"`
20
+ CredentialScope string `json:"credential_scope,omitempty"`
21
+ ExpiresAt string `json:"expires_at,omitempty"`
22
+ }
23
+
24
+ type logoutResult struct {
25
+ LoggedOut bool `json:"logged_out"`
26
+ EnvironmentStillActive bool `json:"environment_still_active,omitempty"`
27
+ RemoteCredentialPreserved bool `json:"remote_credential_preserved"`
28
+ }
29
+
30
+ // NewLoginCommand creates the top-level `pippit-tool-cli login` command.
31
+ func NewLoginCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
32
+ var forceRefresh bool
33
+ command := &cobra.Command{
34
+ Use: "login",
35
+ Short: "通过浏览器登录小云雀 CLI",
36
+ Args: cobra.NoArgs,
37
+ RunE: func(command *cobra.Command, _ []string) error {
38
+ manager, err := requireAuthManager(runner)
39
+ if err != nil {
40
+ return err
41
+ }
42
+ credential, err := manager.Login(command.Context(), internal_auth.LoginOptions{
43
+ Progress: stderr, ForceRefresh: forceRefresh,
44
+ })
45
+ if err != nil {
46
+ return err
47
+ }
48
+ if runner.Config != nil && strings.TrimSpace(runner.Config.AccessKey) != "" {
49
+ _, _ = fmt.Fprintln(stderr, "提示:当前进程设置了 XYQ_ACCESS_KEY,它会继续优先于刚保存的浏览器登录凭证。")
50
+ }
51
+ return writeJSON(stdout, loginResult{
52
+ LoggedIn: true,
53
+ Source: "browser",
54
+ UID: credential.UID,
55
+ CredentialScope: credential.CredentialScope,
56
+ ExpiresAt: time.Unix(credential.ExpiredAt, 0).Format(time.RFC3339),
57
+ })
58
+ },
59
+ }
60
+ command.SetOut(stdout)
61
+ command.SetErr(stderr)
62
+ command.Flags().BoolVar(&forceRefresh, "force", false, "强制轮换当前设备的 CLI Access Key(仅在旧密钥被拒绝时使用)")
63
+ return command
64
+ }
65
+
66
+ // NewStatusCommand creates the top-level `pippit-tool-cli status` command.
67
+ func NewStatusCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
68
+ command := &cobra.Command{
69
+ Use: "status",
70
+ Short: "查看小云雀 CLI 登录状态",
71
+ Args: cobra.NoArgs,
72
+ RunE: func(command *cobra.Command, _ []string) error {
73
+ manager, err := requireAuthManager(runner)
74
+ if err != nil {
75
+ return err
76
+ }
77
+ status, err := manager.Status(command.Context())
78
+ if err != nil {
79
+ return err
80
+ }
81
+ result := loginResult{LoggedIn: status.LoggedIn, Source: status.Source, UID: status.UID, CredentialScope: status.CredentialScope}
82
+ if !status.ExpiresAt.IsZero() {
83
+ result.ExpiresAt = status.ExpiresAt.Format(time.RFC3339)
84
+ }
85
+ return writeJSON(stdout, result)
86
+ },
87
+ }
88
+ command.SetOut(stdout)
89
+ command.SetErr(stderr)
90
+ return command
91
+ }
92
+
93
+ // NewLogoutCommand clears only the browser-managed local credential. An
94
+ // explicit XYQ_ACCESS_KEY belongs to the caller's environment and is never
95
+ // modified or revoked by this command.
96
+ func NewLogoutCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
97
+ command := &cobra.Command{
98
+ Use: "logout",
99
+ Short: "清除本机小云雀 CLI 登录(不撤销远程 Access Key)",
100
+ Args: cobra.NoArgs,
101
+ RunE: func(command *cobra.Command, _ []string) error {
102
+ manager, err := requireAuthManager(runner)
103
+ if err != nil {
104
+ return err
105
+ }
106
+ if err := manager.Logout(command.Context(), false); err != nil && !errors.Is(err, internal_auth.ErrCredentialNotFound) {
107
+ return err
108
+ }
109
+ environmentActive := runner.Config != nil && strings.TrimSpace(runner.Config.AccessKey) != ""
110
+ if environmentActive {
111
+ _, _ = fmt.Fprintln(stderr, "提示:XYQ_ACCESS_KEY 仍由当前 shell 提供,CLI 无法替你清除该环境变量。")
112
+ }
113
+ _, _ = fmt.Fprintln(stderr, "已清除本机登录密钥;远程 Access Key 未撤销,并保留非秘密设备标识供下次登录安全复用。")
114
+ return writeJSON(stdout, logoutResult{
115
+ LoggedOut: true, EnvironmentStillActive: environmentActive, RemoteCredentialPreserved: true,
116
+ })
117
+ },
118
+ }
119
+ command.SetOut(stdout)
120
+ command.SetErr(stderr)
121
+ return command
122
+ }
123
+
124
+ func requireAuthManager(runner *common.Runner) (common.AuthManager, error) {
125
+ if runner == nil || runner.Auth == nil {
126
+ return nil, fmt.Errorf("小云雀 CLI 浏览器授权尚未配置")
127
+ }
128
+ return runner.Auth, nil
129
+ }
130
+
131
+ func writeJSON(writer io.Writer, value any) error {
132
+ payload, err := json.Marshal(value)
133
+ if err != nil {
134
+ return fmt.Errorf("编码登录结果失败: %w", err)
135
+ }
136
+ _, err = fmt.Fprintln(writer, string(payload))
137
+ return err
138
+ }
@@ -0,0 +1,134 @@
1
+ package authcmd
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "io"
8
+ "strings"
9
+ "testing"
10
+ "time"
11
+
12
+ internal_auth "github.com/Pippit-dev/pippit-cli/internal/auth"
13
+ "github.com/Pippit-dev/pippit-cli/internal/common"
14
+ "github.com/Pippit-dev/pippit-cli/internal/config"
15
+ )
16
+
17
+ type fakeAuthManager struct {
18
+ credential *internal_auth.Credential
19
+ status *internal_auth.Status
20
+ loginProgress string
21
+ loginCalls int
22
+ loginOptions []internal_auth.LoginOptions
23
+ logoutCalls int
24
+ }
25
+
26
+ func (manager *fakeAuthManager) ResolveAccessKey(context.Context) (string, error) {
27
+ if manager.credential == nil {
28
+ return "", internal_auth.ErrCredentialNotFound
29
+ }
30
+ return manager.credential.AccessKey, nil
31
+ }
32
+
33
+ func (manager *fakeAuthManager) Login(_ context.Context, options internal_auth.LoginOptions) (*internal_auth.Credential, error) {
34
+ manager.loginCalls++
35
+ manager.loginOptions = append(manager.loginOptions, options)
36
+ if options.Progress != nil {
37
+ message := manager.loginProgress
38
+ if message == "" {
39
+ message = "正在完成浏览器授权…\n"
40
+ }
41
+ _, _ = options.Progress.Write([]byte(message))
42
+ }
43
+ return manager.credential, nil
44
+ }
45
+
46
+ func TestLoginCommandCanRequestSafeCredentialRotation(t *testing.T) {
47
+ manager := &fakeAuthManager{credential: &internal_auth.Credential{
48
+ UID: "123", CredentialScope: "account-device-scope", ExpiredAt: time.Now().Add(time.Hour).Unix(),
49
+ }}
50
+ command := NewLoginCommand(io.Discard, io.Discard, &common.Runner{Config: &config.Config{}, Auth: manager})
51
+ command.SetArgs([]string{"--force"})
52
+ if err := command.Execute(); err != nil {
53
+ t.Fatal(err)
54
+ }
55
+ if len(manager.loginOptions) != 1 || !manager.loginOptions[0].ForceRefresh {
56
+ t.Fatalf("login options = %#v, want forced refresh", manager.loginOptions)
57
+ }
58
+ }
59
+
60
+ func (manager *fakeAuthManager) Status(context.Context) (*internal_auth.Status, error) {
61
+ return manager.status, nil
62
+ }
63
+
64
+ func (manager *fakeAuthManager) Logout(context.Context, bool) error {
65
+ manager.logoutCalls++
66
+ return nil
67
+ }
68
+
69
+ func (manager *fakeAuthManager) CredentialScope(context.Context) (string, error) {
70
+ return "device-scope", nil
71
+ }
72
+
73
+ func TestLoginCommandPrintsMetadataWithoutAccessKey(t *testing.T) {
74
+ const accessKey = "must-never-be-printed"
75
+ const loginURL = "https://xyq.jianying.com/cli/pippit-tool-login?callback=http%3A%2F%2F127.0.0.1%3A1234&random_secret_key=one-time-secret"
76
+ expiresAt := time.Now().Add(time.Hour).Unix()
77
+ manager := &fakeAuthManager{credential: &internal_auth.Credential{
78
+ AccessKey: accessKey, UID: "123", CredentialScope: "device-scope", ExpiredAt: expiresAt,
79
+ }, loginProgress: "正在完成浏览器授权…\n授权地址:\n" + loginURL + "\n"}
80
+ var stdout, stderr bytes.Buffer
81
+ command := NewLoginCommand(&stdout, &stderr, &common.Runner{Config: &config.Config{}, Auth: manager})
82
+ command.SetArgs(nil)
83
+ if err := command.Execute(); err != nil {
84
+ t.Fatalf("Execute() error = %v", err)
85
+ }
86
+ if manager.loginCalls != 1 || !strings.Contains(stderr.String(), "浏览器授权") {
87
+ t.Fatalf("login calls/stderr = %d/%q", manager.loginCalls, stderr.String())
88
+ }
89
+ if strings.Contains(stdout.String(), accessKey) || strings.Contains(stderr.String(), accessKey) {
90
+ t.Fatalf("command output leaked Access Key: stdout=%q stderr=%q", stdout.String(), stderr.String())
91
+ }
92
+ if !strings.Contains(stderr.String(), loginURL) || strings.Contains(stdout.String(), loginURL) {
93
+ t.Fatalf("login URL stream contract violated: stdout=%q stderr=%q", stdout.String(), stderr.String())
94
+ }
95
+ var result loginResult
96
+ if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
97
+ t.Fatal(err)
98
+ }
99
+ if !result.LoggedIn || result.Source != "browser" || result.UID != "123" || result.CredentialScope != "device-scope" {
100
+ t.Fatalf("login result = %#v", result)
101
+ }
102
+ }
103
+
104
+ func TestStatusAndLogoutDoNotModifyExplicitEnvironmentCredential(t *testing.T) {
105
+ manager := &fakeAuthManager{status: &internal_auth.Status{LoggedIn: true, Source: "environment"}}
106
+ runner := &common.Runner{Config: &config.Config{AccessKey: "explicit-ci-key"}, Auth: manager}
107
+ var statusOut, statusErr bytes.Buffer
108
+ status := NewStatusCommand(&statusOut, &statusErr, runner)
109
+ if err := status.Execute(); err != nil {
110
+ t.Fatal(err)
111
+ }
112
+ if strings.Contains(statusOut.String(), runner.Config.AccessKey) {
113
+ t.Fatal("status output leaked explicit Access Key")
114
+ }
115
+
116
+ var logoutOut, logoutErr bytes.Buffer
117
+ logout := NewLogoutCommand(&logoutOut, &logoutErr, runner)
118
+ if err := logout.Execute(); err != nil {
119
+ t.Fatal(err)
120
+ }
121
+ if manager.logoutCalls != 1 || runner.Config.AccessKey != "explicit-ci-key" {
122
+ t.Fatalf("logout calls/key = %d/%q", manager.logoutCalls, runner.Config.AccessKey)
123
+ }
124
+ if !strings.Contains(logoutErr.String(), "无法替你清除") || strings.Contains(logoutErr.String(), runner.Config.AccessKey) {
125
+ t.Fatalf("logout stderr = %q", logoutErr.String())
126
+ }
127
+ var result logoutResult
128
+ if err := json.Unmarshal(logoutOut.Bytes(), &result); err != nil {
129
+ t.Fatal(err)
130
+ }
131
+ if !result.LoggedOut || !result.RemoteCredentialPreserved || !strings.Contains(logoutErr.String(), "远程 Access Key 未撤销") {
132
+ t.Fatalf("logout result/stderr = %#v/%q", result, logoutErr.String())
133
+ }
134
+ }