@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,151 @@
1
+ // 变更事件与订阅:`event --list|--subscriptions|--subscribe|--replay`(契约 §2)。
2
+ //
3
+ // 读事件比读业务数据更敏感(事件流等于全量变更面):服务端用 `admin:audit` 门禁,
4
+ // 所以普通业务员跑 `event --list` 会拿到 403 `authz.missing_scope`(退出码 4)——
5
+ // 这是对的,不是 bug。
6
+ //
7
+ // 两条与 Rust 逐条对齐、容易"顺手改错"的地方:
8
+ //
9
+ // 1. `--list` / `--subscriptions` 是纯读,Rust **没有**给它们 `--dry-run` 分支,
10
+ // Node 版照抄(`--dry-run` 在这两条上仍会真的发请求);
11
+ // 2. `--subscribe` / `--replay` 是写,但 Rust **没有**给它们生成幂等键
12
+ // (服务端这两条路由也不要求 `Idempotency-Key`:建订阅/重放不是"重放即等值"的
13
+ // 幂等写)。这里保持与 Rust **同一份线上请求**:不加键。
14
+ // 文件/附件域的写操作则一律带键(见 `file.js`)。
15
+
16
+ import { CliError, exactlyOne } from '../core/error.js';
17
+ import { flagList, flagOn, flagValue, intFlag } from '../core/args.js';
18
+ import { requestSpec } from '../core/http.js';
19
+ import { dataOf } from '../core/runtime.js';
20
+ import { resolveTenant } from './file.js';
21
+
22
+ /**
23
+ * `event` 的动作分发。
24
+ *
25
+ * 与 Rust 的 clap `ArgGroup(action, multiple=false)` 同义:四个动作**恰好一个**。
26
+ * 规格驱动的解析器不替我们做这件事(`--list` 这类动作旗标只在 `extra_flags` 里),
27
+ * `event --list --replay sub_1` 这种命令行必须在这里拒掉(Rust 退出码 2、不发请求)。
28
+ */
29
+ export async function handleEvent({ args, runtime }) {
30
+ const picked = exactlyOne(
31
+ [
32
+ ['--list', flagOn(args, '--list')],
33
+ ['--subscribe', flagOn(args, '--subscribe')],
34
+ ['--subscriptions', flagOn(args, '--subscriptions')],
35
+ ['--replay', args['--replay'] !== undefined],
36
+ ],
37
+ 'event',
38
+ '例:xinhe-oa-cli event --list --since-seq 0 --limit 20 --json',
39
+ );
40
+ if (picked === '--list') return list(runtime, args);
41
+ if (picked === '--subscriptions') return subscriptions(runtime);
42
+ if (picked === '--subscribe') return subscribe(runtime, args);
43
+ return replay(runtime, args);
44
+ }
45
+
46
+ /** `event --list [--since-seq N] [--limit N] [--resource-type T]... [--operation OP]`。 */
47
+ async function list(runtime, args) {
48
+ // `--since-seq` 是 i64(可以是 0 或正数游标),`--limit` 是 u32。
49
+ const query = [
50
+ ['since_seq', String(intFlag(args, '--since-seq', { fallback: 0 }))],
51
+ ['limit', String(intFlag(args, '--limit', { fallback: 100, min: 0 }))],
52
+ ];
53
+ for (const resourceType of flagList(args, '--resource-type')) {
54
+ query.push(['resource_types', resourceType]);
55
+ }
56
+ const resourceId = flagValue(args, '--resource-id');
57
+ if (resourceId !== null) query.push(['resource_id', resourceId]);
58
+ const operation = flagList(args, '--operation')[0];
59
+ if (operation !== undefined) query.push(['operation', operation]);
60
+
61
+ const response = await runtime.sendAuthed('GET', '/events', requestSpec({ query }));
62
+ const body = dataOf(response);
63
+ runtime.finishFrom(response, {
64
+ events: fieldOr(body, 'events', []),
65
+ since_seq: body?.since_seq ?? null,
66
+ next_seq: body?.next_seq ?? null,
67
+ has_more: body?.has_more ?? null,
68
+ next_step: '接着读:把这个 next_seq 传回 --since-seq 继续(游标补偿)',
69
+ });
70
+ }
71
+
72
+ /** `event --subscriptions`:列出订阅(含游标与投递统计)。 */
73
+ async function subscriptions(runtime) {
74
+ const response = await runtime.sendAuthed('GET', '/event-subscriptions', requestSpec());
75
+ const body = dataOf(response);
76
+ runtime.finishFrom(response, {
77
+ subscriptions: fieldOr(body, 'subscriptions', []),
78
+ total: body?.total ?? null,
79
+ });
80
+ }
81
+
82
+ /** `event --subscribe --tenant <T> --name <名字> --target-url <http(s)://...>`。 */
83
+ async function subscribe(runtime, args) {
84
+ const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'), '建订阅');
85
+ const name = flagValue(args, '--name');
86
+ if (name === null) throw CliError.usage('`event --subscribe` 需要 --name <订阅名>');
87
+ const targetUrl = flagValue(args, '--target-url');
88
+ if (targetUrl === null) {
89
+ throw CliError.usage('`event --subscribe` 需要 --target-url <http(s)://...>').withHint(
90
+ '服务端只接受 http:// 或 https:// 开头(其它 scheme 是 SSRF 面)',
91
+ );
92
+ }
93
+ // scheme 白名单在**本地**判:非 http(s) 的目标地址是 SSRF 面,客户端完全能提前拦下。
94
+ if (!(targetUrl.startsWith('http://') || targetUrl.startsWith('https://'))) {
95
+ throw CliError.usage('--target-url 必须是 http:// 或 https:// 开头').withHint(
96
+ '例:--target-url https://example.com/hooks/oa',
97
+ );
98
+ }
99
+ const spec = requestSpec({
100
+ body: {
101
+ tenant,
102
+ name,
103
+ target_url: targetUrl,
104
+ resource_types: flagList(args, '--resource-type'),
105
+ operations: flagList(args, '--operation'),
106
+ },
107
+ });
108
+ if (runtime.dryRun) {
109
+ runtime.dryRunReport('POST', '/event-subscriptions', spec);
110
+ return;
111
+ }
112
+ const response = await runtime.sendAuthed('POST', '/event-subscriptions', spec);
113
+ runtime.finishFrom(response, { subscription: dataOf(response) });
114
+ }
115
+
116
+ /** `event --replay <SUBSCRIPTION_ID> [--since-seq N]`(高风险:必须 `--yes`)。 */
117
+ async function replay(runtime, args) {
118
+ const subscriptionId = String(args['--replay'] ?? '');
119
+ runtime.requireYes(
120
+ 'event --replay(把死信重新投递到客户的外部地址)',
121
+ `xinhe-oa-cli event --replay ${subscriptionId}`,
122
+ );
123
+ const spec = requestSpec({
124
+ body: { since_seq: intFlag(args, '--since-seq', { fallback: 0 }) },
125
+ });
126
+ const requestPath = `/event-subscriptions/${subscriptionId}/replay`;
127
+ if (runtime.dryRun) {
128
+ runtime.dryRunReport('POST', requestPath, spec);
129
+ return;
130
+ }
131
+ const response = await runtime.sendAuthed('POST', requestPath, spec);
132
+ const data = dataOf(response);
133
+ runtime.finishFrom(response, {
134
+ // 服务端回 `{replayed: N}`;形状不对时把整个 `data` 交出去(不静默丢字段)。
135
+ replayed: fieldOr(data, 'replayed', data),
136
+ subscription_id: subscriptionId,
137
+ });
138
+ }
139
+
140
+ /**
141
+ * 取字段:**键存在就用它的值**(哪怕是 `null`),键不存在才用兜底。
142
+ *
143
+ * 与 Rust 的 `body.get(key).cloned().unwrap_or(fallback)` 同义 ——
144
+ * 用 JS 的 `??` 会把"服务端明确回了 null"和"服务端没回这个键"混成一种。
145
+ */
146
+ function fieldOr(body, key, fallback) {
147
+ if (body !== null && typeof body === 'object' && !Array.isArray(body) && key in body) {
148
+ return body[key];
149
+ }
150
+ return fallback;
151
+ }