@xinhehuimeng/cli 0.2.4 → 0.2.5

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
@@ -13,6 +13,25 @@ npx @xinhehuimeng/cli login --phone 13800000000 --code 123456
13
13
  npx @xinhehuimeng/cli me --json
14
14
  ```
15
15
 
16
+ 管理员为普通成员发放 30 分钟临时口令后,可以改用自己的长期口令:
17
+
18
+ ```bash
19
+ # 1) 用临时口令登录
20
+ printf '%s' "$TEMP_PW" | xinhe-oa-cli login --password --account <手机号>
21
+
22
+ # 2) 首次登录必须改密;改密会吊销该账号全部旧会话
23
+ printf '%s\n%s\n%s\n' "$TEMP_PW" "$NEW_PW" "$NEW_PW" | \
24
+ xinhe-oa-cli login --change-password
25
+ ```
26
+
27
+ 管理员发放临时口令:
28
+
29
+ ```bash
30
+ xinhe-oa-cli member --reset-password <USER_ID> --yes --json
31
+ ```
32
+
33
+ 临时口令只显示一次,不要发到工单评论或群聊。
34
+
16
35
  装一次就够(`npm i -g @xinhehuimeng/cli` 之后直接用 `xinhe-oa-cli`):
17
36
 
18
37
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xinhehuimeng/cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "心河 OA 客户端 CLI(Node 版):多维表格 / 应用 / 权限的唯一命令行入口",
5
5
  "keywords": [
6
6
  "xinhe-oa",
@@ -1,4 +1,4 @@
1
- // 成员域:`member --list|--search|--create|--role|--revoke-role|--remove`(契约 §2、§6.2),
1
+ // 成员域:`member --list|--search|--create|--role|--revoke-role|--remove|--reset-password`,
2
2
  // 含 ORG-001 的部门归属三件套(`--set-dept` / `--unset-dept` / `--set-manager|--clear-manager`)、
3
3
  // ORG-002 的授权三动作(`--grant|--revoke-grant|--grants`,issue #82)、
4
4
  // ORG-004 的离职 / 移交四动作(issue #81)与 MEMBER-001 的 `--search`(issue #99)。
@@ -81,6 +81,8 @@ export async function handleMember({ args, runtime }) {
81
81
  return revokeRole(runtime, args, action.role, action.user);
82
82
  case 'remove':
83
83
  return removeMember(runtime, action.user, args);
84
+ case 'reset-password':
85
+ return resetPassword(runtime, action.user);
84
86
  case 'set-dept':
85
87
  return setDept(runtime, args, action.user, action.deptId, action.primary);
86
88
  case 'unset-dept':
@@ -129,6 +131,7 @@ function memberAction(args) {
129
131
  ['--role', given('--role') && !on('--create')],
130
132
  ['--revoke-role', given('--revoke-role')],
131
133
  ['--remove', given('--remove')],
134
+ ['--reset-password', given('--reset-password')],
132
135
  ['--set-dept', given('--set-dept')],
133
136
  ['--unset-dept', given('--unset-dept')],
134
137
  ['--set-manager', given('--set-manager')],
@@ -396,6 +399,15 @@ function memberAction(args) {
396
399
  }
397
400
  return { kind: 'transfer-revert', transferId, note: String(note) };
398
401
  }
402
+ case '--reset-password':
403
+ return {
404
+ kind: 'reset-password',
405
+ user: requiredId(
406
+ val('--reset-password'),
407
+ '--reset-password',
408
+ '例:xinhe-oa-cli member --reset-password usr_123 --yes --json',
409
+ ),
410
+ };
399
411
  default:
400
412
  return { kind: 'remove', user: String(val('--remove') ?? '') };
401
413
  }
@@ -779,6 +791,32 @@ async function removeMember(runtime, userId, args) {
779
791
  });
780
792
  }
781
793
 
794
+ /** 管理员发放 30 分钟一次性口令,并吊销该成员全部旧会话。 */
795
+ async function resetPassword(runtime, userId) {
796
+ runtime.requireYes(
797
+ '重置成员口令',
798
+ `xinhe-oa-cli member --reset-password ${userId}`,
799
+ );
800
+ const path = `/admin/users/${userId}/password/reset`;
801
+ const spec = requestSpec();
802
+ if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
803
+ const response = await runtime.sendAuthed('POST', path, spec);
804
+ const data = dataOf(response);
805
+ runtime.finishFrom(response, {
806
+ user: data.user ?? null,
807
+ temporary_password: data.temporary_password ?? null,
808
+ expires_at: data.expires_at ?? null,
809
+ sessions_revoked: data.sessions_revoked ?? null,
810
+ must_change_password: data.must_change_password ?? null,
811
+ next_steps: [
812
+ '通过安全渠道把临时口令单独交给本人,不要发群聊或工单评论',
813
+ "本人登录:printf '%s' \"$TEMP_PW\" | xinhe-oa-cli login --password --account <手机号>",
814
+ "本人立即改密:printf '%s\\n%s\\n%s\\n' \"$TEMP_PW\" \"$NEW_PW\" \"$NEW_PW\" | xinhe-oa-cli login --change-password",
815
+ ],
816
+ warning: '旧口令与该成员的全部旧会话已失效;临时口令只显示这一次。',
817
+ });
818
+ }
819
+
782
820
  // ---------------------------------------------------------------------------
783
821
  // 部门归属(ORG-001):set-dept / unset-dept / set-manager
784
822
  // ---------------------------------------------------------------------------
@@ -33,6 +33,7 @@
33
33
  import { flagOn, flagValue } from '../core/args.js';
34
34
  import {
35
35
  adoptSession,
36
+ credentialFromTokens,
36
37
  envelopeData,
37
38
  loginWithPassword,
38
39
  logout as logoutSession,
@@ -53,8 +54,9 @@ const SCOPE_CAP = 50;
53
54
  * 顺序不是随手写的:clap 的 `ArgGroup(multiple = false)` 保证四选一,
54
55
  * 但"既有 code 又 adopt"这种组合在 Rust 里由 adopt 先赢 —— `meta.command` 要一致。
55
56
  */
56
- function loginKind({ adopt, password, hasCode }) {
57
+ function loginKind({ adopt, password, changePassword, hasCode }) {
57
58
  if (adopt) return '--adopt-session';
59
+ if (changePassword) return '--change-password';
58
60
  if (password) return '--password';
59
61
  if (hasCode) return '--phone --code';
60
62
  return '--phone';
@@ -64,6 +66,7 @@ function loginKind({ adopt, password, hasCode }) {
64
66
  export async function handleLogin({ args, runtime }) {
65
67
  const adopt = flagOn(args, '--adopt-session');
66
68
  const password = flagOn(args, '--password');
69
+ const changePassword = flagOn(args, '--change-password');
67
70
  const phone = flagValue(args, '--phone');
68
71
  const code = flagValue(args, '--code');
69
72
  const account = flagValue(args, '--account');
@@ -72,15 +75,18 @@ export async function handleLogin({ args, runtime }) {
72
75
  // (`login --phone` / `login --phone --code` / `login --password` / `login --adopt-session`),
73
76
  // 而 Node 的命令表按域名折叠成 `login`。这里在收尾(以及发请求)之前把它校正成
74
77
  // Rust 的形态,`--json` 的 `meta.command` 才逐字一致。
75
- runtime.command = `login ${loginKind({ adopt, password, hasCode: code !== null })}`;
78
+ runtime.command = `login ${loginKind({ adopt, password, changePassword, hasCode: code !== null })}`;
76
79
 
77
80
  // ① 交接会话(install.sh 用):不联网,只把它写进自己的凭据文件。
78
81
  if (adopt) return adoptFromStdin(runtime);
79
82
 
80
- // ② 口令登录(仅引导账号;口令从 stdin 读,**不进 argv**)。
83
+ // ② 当前会话改密:先完成临时口令登录,再读取三行口令。
84
+ if (changePassword) return changeCurrentPassword(runtime);
85
+
86
+ // ③ 口令登录(普通成员或引导账号;口令从 stdin 读,**不进 argv**)。
81
87
  if (password) return loginByPassword(runtime, account);
82
88
 
83
- // ③ 拿验证码换凭据。
89
+ // ④ 拿验证码换凭据。
84
90
  //
85
91
  // 判空用 `=== null`(而不是 falsy):`--phone=` 这种显式空串在 Rust 里是
86
92
  // `Some("")`,会被原样发给服务端,由服务端回"号码不合法" —— CLI 不替它判。
@@ -91,12 +97,59 @@ export async function handleLogin({ args, runtime }) {
91
97
  }
92
98
  if (code !== null) return loginByCode(runtime, phone, code);
93
99
 
94
- // ④ 只给手机号 = 发码。
100
+ // ⑤ 只给手机号 = 发码。
95
101
  return requestOtp(runtime, phone);
96
102
  }
97
103
 
104
+ async function changeCurrentPassword(runtime) {
105
+ const raw = await readStdinText();
106
+ const lines = raw.split(/\r?\n/).map((line) => line.trim());
107
+ if (lines.length < 3 || lines.slice(0, 3).some((line) => line.length === 0)) {
108
+ throw CliError.usage('标准输入里需要当前口令、新口令、确认新口令三行').withHint(
109
+ "用法:printf '%s\\n%s\\n%s\\n' \"$OLD_PW\" \"$NEW_PW\" \"$NEW_PW\" | xinhe-oa-cli login --change-password",
110
+ );
111
+ }
112
+ const [currentPassword, newPassword, confirmation] = lines;
113
+ if (newPassword !== confirmation) throw CliError.usage('两次输入的新口令不一致');
114
+ if (newPassword.length < 12) throw CliError.usage('新口令至少需要 12 个字符');
115
+ if (newPassword === currentPassword) throw CliError.usage('新口令不能与当前口令相同');
116
+
117
+ const spec = requestSpec({
118
+ body: { current_password: currentPassword, new_password: newPassword },
119
+ });
120
+ if (runtime.dryRun) {
121
+ return runtime.dryRunReport('POST', '/auth/password/change', spec);
122
+ }
123
+
124
+ const response = await runtime.sendAuthed('POST', '/auth/password/change', spec);
125
+ const body = envelopeData(response);
126
+ const previous = runtime.credentials.get(runtime.profile);
127
+ const phone = previous?.phone ?? runtime.config.profile(runtime.profile).phone ?? null;
128
+ const deviceId = runtime.ensureDeviceId();
129
+ const credential = credentialFromTokens(body, runtime.baseUrl, deviceId, phone);
130
+ runtime.credentials.put(runtime.profile, credential);
131
+ if (typeof phone === 'string' && phone.length > 0) {
132
+ runtime.config.setPhone(phone);
133
+ runtime.config.save();
134
+ }
135
+
136
+ return runtime.finishFrom(response, {
137
+ profile: runtime.profile,
138
+ base_url: credential.base_url,
139
+ user: credential.user ?? null,
140
+ auth_method: 'password',
141
+ sessions_revoked: body.sessions_revoked ?? null,
142
+ credentials_file: runtime.paths.credentialsFile,
143
+ access_token: maskToken(credential.tokens?.access_token ?? ''),
144
+ refresh_token: maskToken(credential.tokens?.refresh_token ?? ''),
145
+ access_expires_at: credential.tokens?.access_expires_at ?? null,
146
+ next_step: '口令已更新并保存新会话;现在可以执行 `xinhe-oa-cli me --json` 或每日任务',
147
+ note: '旧口令、全部旧会话和临时口令均已失效;新令牌未进入 argv 与日志。',
148
+ });
149
+ }
150
+
98
151
  /**
99
- * `login --password --account`:仅引导账号,口令从 stdin 读。
152
+ * `login --password --account`:普通成员或引导账号,口令从 stdin 读。
100
153
  *
101
154
  * 三步的顺序是刻意的(与 Rust 一致):**先落盘 device_id,再读口令,最后才碰网络**。
102
155
  * 口令读失败(stdin 是空的)时,device_id 已经存好了 —— 用户重跑一次不会换指纹。
@@ -299,6 +299,43 @@
299
299
  "--password"
300
300
  ]
301
301
  },
302
+ {
303
+ "name": "login --change-password",
304
+ "flags": {
305
+ "--change-password": {
306
+ "takes_value": false,
307
+ "required": false,
308
+ "optional_value": false
309
+ }
310
+ },
311
+ "extra_flags": [],
312
+ "allowed_flags": [],
313
+ "params_raw": [
314
+ "--change-password"
315
+ ],
316
+ "params": [
317
+ {
318
+ "flag": "--change-password"
319
+ }
320
+ ],
321
+ "risk": "write",
322
+ "requires_yes": false,
323
+ "endpoints": [
324
+ "POST /auth/password/change"
325
+ ],
326
+ "examples": [
327
+ "printf '%s\\n%s\\n%s\\n' \"$OLD_PW\" \"$NEW_PW\" \"$NEW_PW\" | xinhe-oa-cli login --change-password"
328
+ ],
329
+ "output_schema": "{user, auth_method, sessions_revoked, tokens_saved: true}(旧会话与临时口令失效)",
330
+ "synonym_flags": [],
331
+ "rejected_flags": [
332
+ "--phone",
333
+ "--code",
334
+ "--password",
335
+ "--account",
336
+ "--adopt-session"
337
+ ]
338
+ },
302
339
  {
303
340
  "name": "logout",
304
341
  "flags": {},
@@ -26809,6 +26846,38 @@
26809
26846
  "--primary"
26810
26847
  ]
26811
26848
  },
26849
+ {
26850
+ "name": "member --reset-password",
26851
+ "flags": {
26852
+ "--reset-password": {
26853
+ "takes_value": true,
26854
+ "required": false,
26855
+ "optional_value": false
26856
+ }
26857
+ },
26858
+ "extra_flags": [],
26859
+ "allowed_flags": [],
26860
+ "params_raw": [
26861
+ "--reset-password <USER_ID>"
26862
+ ],
26863
+ "params": [
26864
+ {
26865
+ "flag": "--reset-password",
26866
+ "value": "USER_ID"
26867
+ }
26868
+ ],
26869
+ "risk": "high-risk-write",
26870
+ "requires_yes": true,
26871
+ "endpoints": [
26872
+ "POST /admin/users/{id}/password/reset"
26873
+ ],
26874
+ "examples": [
26875
+ "xinhe-oa-cli member --reset-password usr_123 --yes --json"
26876
+ ],
26877
+ "output_schema": "{user, temporary_password, expires_at, sessions_revoked, must_change_password}(临时口令只显示一次,30 分钟有效)",
26878
+ "synonym_flags": [],
26879
+ "rejected_flags": []
26880
+ },
26812
26881
  {
26813
26882
  "name": "config --list",
26814
26883
  "flags": {
package/src/version.js CHANGED
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // 这个常量与 `package.json` 的 `version` **必须一致**:`test/version.test.js`
8
8
  // 会逐字比对,`npm version` 之后忘了改这里就会红。
9
- export const VERSION = '0.2.4';
9
+ export const VERSION = '0.2.5';
10
10
 
11
11
  /**
12
12
  * npm 包名(升级指引 `npm i -g <包名>@latest` 与 `doctor` 的 npm 探测都用它)。