@xinhe-oa/cli 0.0.0-stage → 0.2.0

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,78 @@
1
+ // 命令分发表:命令标签 → 处理函数。
2
+ //
3
+ // 每条命令处理函数的签名统一是 `async ({ args, positional, runtime })`,
4
+ // 自己负责把结果写出去(`runtime.finish(...)` / `runtime.finishWithHuman(...)`),
5
+ // 失败就 `throw CliError`。**退出码由 `main.js` 统一收口**(契约 §3.2)。
6
+ //
7
+ // 为什么一张显式的表,而不是按域名动态 import:命令面是**冻结契约**(153 条),
8
+ // 表里缺一条就是"这条命令在 Node 版上不存在"——显式的表能让这件事一眼可见,
9
+ // 也方便 `test/surface.test.js` 断言"153 条命令每条都有处理函数"。
10
+
11
+ import { handleLogin, handleLogout, handleMe } from './session.js';
12
+ import { handleTenant } from './tenant.js';
13
+ import { handleMember } from './member.js';
14
+ import { handleInvestor } from './investor.js';
15
+ import { handleOrg } from './org.js';
16
+ import { handleBitable } from './bitable.js';
17
+ import { handleApplication } from './application.js';
18
+ import { handleDashboard } from './dashboard.js';
19
+ import { handleWorkflow } from './workflow.js';
20
+ import { handleAttachment } from './attachment.js';
21
+ import { handleFile } from './file.js';
22
+ import { handleEvent } from './event.js';
23
+ import { handleSkills } from './skills.js';
24
+ import { handleApproval } from './approval.js';
25
+ import { handleReport } from './report.js';
26
+ import { handleConfig } from './config.js';
27
+ import { handleDoctor } from './doctor.js';
28
+ import { handleSchema } from './schema.js';
29
+
30
+ /** 命令标签 → 处理函数。 */
31
+ export const HANDLERS = new Map([
32
+ ['login', handleLogin],
33
+ ['logout', handleLogout],
34
+ ['me', handleMe],
35
+ ['tenant', handleTenant],
36
+ ['member', handleMember],
37
+ ['investor', handleInvestor],
38
+ ['org', handleOrg],
39
+ ['bitable base', handleBitable],
40
+ ['bitable table', handleBitable],
41
+ ['bitable field', handleBitable],
42
+ ['bitable record', handleBitable],
43
+ ['bitable view', handleBitable],
44
+ ['bitable form', handleBitable],
45
+ ['application', handleApplication],
46
+ ['dashboard', handleDashboard],
47
+ ['workflow', handleWorkflow],
48
+ ['attachment', handleAttachment],
49
+ ['file', handleFile],
50
+ ['event', handleEvent],
51
+ ['skills', handleSkills],
52
+ ['approval', handleApproval],
53
+ ['report rule', handleReport],
54
+ ['report', handleReport],
55
+ ['config', handleConfig],
56
+ ['doctor', handleDoctor],
57
+ ['schema', handleSchema],
58
+ ]);
59
+
60
+ /**
61
+ * 命令规格名 → `meta.command` 标签(对齐 Rust `main.rs::command_label`)。
62
+ *
63
+ * `meta.command` 是**域名**而不是整条命令:`bitable record --list` 与
64
+ * `bitable record --create` 的 `meta.command` 都是 `bitable record`。
65
+ * 判卷按这个字段对齐,所以它必须与 Rust 版逐字相同。
66
+ */
67
+ export function commandLabel(specName) {
68
+ const tokens = specName.split(' ');
69
+ const domain = tokens[0];
70
+ if (domain === 'bitable') return `bitable ${tokens[1]}`;
71
+ if (domain === 'report' && tokens[1] === 'rule') return 'report rule';
72
+ return domain;
73
+ }
74
+
75
+ /** 命令规格名 → 处理函数。 */
76
+ export function handlerFor(specName) {
77
+ return HANDLERS.get(commandLabel(specName)) ?? null;
78
+ }
@@ -0,0 +1,179 @@
1
+ // 投资人:`investor --list|--endorse|--revoke`(契约 §2、§6.2)。
2
+ //
3
+ // **两道门缺一不可(BASE-012)**:
4
+ //
5
+ // - **平台声明**(`member --create --role investor`)= **能力面**:super_admin 说
6
+ // "这个人是投资人",他能进 CLI、能看 `me`,但**还看不到任何一家企业的数据**;
7
+ // - **企业背书**(本命令的 `--endorse`)= **数据面**:企业负责人点头"我这家企业给
8
+ // 他看",服务端才把这家企业写进他的 `scope.tenants`。
9
+ //
10
+ // 这个命令只管第二道门。为什么必须分开:投资关系是**双边**事实 —— 平台知道他是
11
+ // 投资人,不等于开叔企业愿意让他看自己的流水。把两者合成一个动作,等于任何一次
12
+ // 平台赋角都会自动打开所有企业的数据。
13
+ //
14
+ // 与 Rust 版逐条对齐的三个细节:
15
+ // 1. `--list` 的 `status` **永远在 query 里**(clap 的 `default_value = "all"`);
16
+ // 2. `--endorse` 的企业缺省值走一次 `GET /me`,`--revoke` **不走**(收回背书时要能
17
+ // 明确指向某一家,绝不替用户猜);
18
+ // 3. 收回背书的 `--reason` 至少 2 个字(写进审计,"1" 这种原因等于没写)。
19
+
20
+ import { CliError, exactlyOne } from '../core/error.js';
21
+ import { flagOn, flagValue } from '../core/args.js';
22
+ import { dataOf, idempotencyKey, requestSpec } from '../core/runtime.js';
23
+ import { queryPairs } from '../core/values.js';
24
+ import { resolveTenant } from './tenant.js';
25
+
26
+ /** `investor --list|--endorse|--revoke` 的分发。 */
27
+ export async function handleInvestor({ args, positional, runtime, command }) {
28
+ // 动作按**旗标**判,不按 `command.name`:Node 的解析器可能把
29
+ // `investor --endorse --reason r` 判成兄弟候选(`--reason` 只登记在 `--revoke` 上),
30
+ // 但 args 里的动作旗标永远是用户真正敲的那个 —— Rust 的 `action()` 也是这么判的。
31
+ const action = exactlyOne(
32
+ [
33
+ ['--list', flagOn(args, '--list')],
34
+ ['--endorse', flagOn(args, '--endorse')],
35
+ ['--revoke', flagOn(args, '--revoke')],
36
+ ],
37
+ 'investor',
38
+ '例:xinhe-oa-cli investor --list --status pending --json',
39
+ );
40
+ if (action === '--list') return list(runtime, args);
41
+ if (action === '--endorse') return endorse(runtime, args);
42
+ return revoke(runtime, args);
43
+ }
44
+
45
+ async function list(runtime, args) {
46
+ // `--status` 缺省是 `all`(Rust clap 的 `default_value`):**不筛**也要显式发出去,
47
+ // 因为服务端把"不筛"与"筛 all"当同一件事,缺了这个参数反而会走另一条分支。
48
+ const status = flagValue(args, '--status') ?? 'all';
49
+ // `--user` / `--tenant`:Rust 的扁平结构里 `--list` 也认这两个过滤条件
50
+ // (query 名是 `investor_user_id` / `tenant_id`)。空值不进 query(`queryPairs`)。
51
+ const query = queryPairs([
52
+ ['status', status],
53
+ ['investor_user_id', flagValue(args, '--user')],
54
+ ['tenant_id', flagValue(args, '--tenant')],
55
+ ]);
56
+ const response = await runtime.sendAuthed(
57
+ 'GET',
58
+ '/admin/investor-endorsements',
59
+ requestSpec({ query }),
60
+ );
61
+ const data = dataOf(response);
62
+
63
+ // 服务端 `GET /admin/investor-endorsements` 返回的是**一个合并清单**:
64
+ // `{items, count, summary, tenants}` —— `items` 已经把「平台声明(pending)」与
65
+ // 「已签发的背书(active/revoked/expired)」按 `status` 合成一列。这正是调用方需要
66
+ // 的形状,所以**如实透传**。
67
+ //
68
+ // 早先这里按 `declarations` / `endorsements` 两个键去取,结果永远是空数组:企业
69
+ // 负责人看到"没有待确认的投资人",于是**没有任何办法拿到投资人 user_id 去签发
70
+ // 背书**(`--endorse` 正需要它)。两条并行线各自单测都绿,只有真接起来才暴露 ——
71
+ // 同 `tenant --create` 的 `stores[].code` 字段名错位是同一类问题。
72
+ const items = hasKey(data, 'items') ? data.items : [];
73
+ const summary = hasKey(data, 'summary')
74
+ ? data.summary
75
+ : { pending: 0, active: 0, revoked: 0, expired: 0 };
76
+ const count = Number.isInteger(data?.count) && data.count >= 0
77
+ ? data.count
78
+ : Array.isArray(items)
79
+ ? items.length
80
+ : 0;
81
+ // `pending_count` 是本地数的:服务端的 `summary` 有的版本里没有 `pending` 这一项,
82
+ // 而"有几条等我点头"恰恰是这条命令最常见的用途(企业负责人据此决定签不签)。
83
+ const pendingCount = Array.isArray(items)
84
+ ? items.filter((item) => item !== null && typeof item === 'object' && item.status === 'pending')
85
+ .length
86
+ : 0;
87
+
88
+ runtime.finishFrom(response, {
89
+ items,
90
+ count,
91
+ pending_count: pendingCount,
92
+ summary,
93
+ tenants: hasKey(data, 'tenants') ? data.tenants : null,
94
+ note: '投资人只看得到「平台声明」∩「企业背书」两家企业;status=pending 就是等本企业点头的那些',
95
+ next_step:
96
+ '签发背书:xinhe-oa-cli investor --endorse --user <投资人 user_id> --tenant <企业> --reason <原因>',
97
+ });
98
+ }
99
+
100
+ async function endorse(runtime, args) {
101
+ const userId = flagValue(args, '--user');
102
+ if (userId === null) {
103
+ throw CliError.usage('`investor --endorse` 需要 --user <投资人 user_id>').withHint(
104
+ '先看待确认清单:xinhe-oa-cli investor --list --status pending --json',
105
+ );
106
+ }
107
+ // 原因不传就给一条**可审计**的默认值(不是空串):这条记录日后要能回答
108
+ // "当年为什么给他看"。用户显式给的空串则原样发(不替用户改主意)。
109
+ const reason = flagValue(args, '--reason') ?? '企业背书:确认投资人对本企业只读';
110
+ // 企业:显式 `--tenant` > 你的唯一可见企业(企业负责人只能给自己企业背书)。
111
+ // ⚠️ 这一步可能在 `--dry-run` 下**真的发一次 `GET /me`** —— 与 Rust 一致:
112
+ // 它是读,不是写;不知道要给哪家企业背书,连"将发送什么"都打印不出来。
113
+ const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'));
114
+
115
+ const body = {
116
+ investor_user_id: userId,
117
+ tenant_id: tenant,
118
+ expires_at: flagValue(args, '--expires-at'),
119
+ reason,
120
+ };
121
+ // 写操作必带幂等键:重发不会签出两条背书(服务端按幂等键去重)。
122
+ const spec = requestSpec({
123
+ body,
124
+ idempotencyKey: idempotencyKey('investor --endorse', flagValue(args, '--idempotency-key')),
125
+ });
126
+ if (runtime.dryRun) return runtime.dryRunReport('POST', '/admin/investor-endorsements', spec);
127
+
128
+ const response = await runtime.sendAuthed('POST', '/admin/investor-endorsements', spec);
129
+ const data = dataOf(response);
130
+ runtime.finishFrom(response, {
131
+ endorsement: hasKey(data, 'endorsement') ? data.endorsement : data,
132
+ // 背书签完 ≠ 立刻生效到客户端:让对方自己 `me --json` 看一眼 `scope.tenants`,
133
+ // 这是唯一能证明"数据面真的开了"的证据(能力面早就有了)。
134
+ next_step:
135
+ '确认投资人能看到数据:让他跑 xinhe-oa-cli me --json' +
136
+ `(scope.tenants 里应出现 ${tenant ?? '<本企业>'})`,
137
+ });
138
+ }
139
+
140
+ async function revoke(runtime, args) {
141
+ const userId = flagValue(args, '--user');
142
+ if (userId === null) {
143
+ throw CliError.usage('`investor --revoke` 需要 --user <投资人 user_id>');
144
+ }
145
+ const reason = flagValue(args, '--reason') ?? '';
146
+ // 收回背书是**降险动作**(契约 §2.1.2b 的四条口径):不卡 `--yes`,
147
+ // 但原因必须写清楚 —— 卡确认会让"发现给错了赶紧收回"变慢,而审计不能没有理由。
148
+ if (reason.trim().length < 2) {
149
+ throw CliError.usage('收回背书需要 --reason(写进审计)').withHint(
150
+ '例:xinhe-oa-cli investor --revoke --user usr_qin --reason 投资关系结束',
151
+ );
152
+ }
153
+ // ⚠️ 与 `--endorse` 的**刻意不对称**(照抄 Rust):这里不解析"唯一可见企业"。
154
+ // 收回背书要能明确指向某一家企业(`tenant_id: null` 由服务端按你的企业兜底);
155
+ // 本地猜一个等于把"收回哪一家"这件事交给运气。
156
+ const body = {
157
+ investor_user_id: userId,
158
+ tenant_id: flagValue(args, '--tenant'),
159
+ reason,
160
+ };
161
+ const spec = requestSpec({
162
+ body,
163
+ idempotencyKey: idempotencyKey('investor --revoke', flagValue(args, '--idempotency-key')),
164
+ });
165
+ if (runtime.dryRun) {
166
+ return runtime.dryRunReport('POST', '/admin/investor-endorsements/revoke', spec);
167
+ }
168
+
169
+ const response = await runtime.sendAuthed('POST', '/admin/investor-endorsements/revoke', spec);
170
+ runtime.finishFrom(response, dataOf(response));
171
+ }
172
+
173
+ /** 键存在就取(`null` 也算存在)—— 对齐 Rust `Value::get`:**不能**用 `??` 兜底,
174
+ * 否则服务端显式回的 `null` 会被当成"没这个键"而套上默认值。 */
175
+ function hasKey(value, key) {
176
+ return (
177
+ value !== null && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, key)
178
+ );
179
+ }