@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.
package/README.md CHANGED
@@ -1,3 +1,160 @@
1
- # Temporary Holding Version
1
+ # @xinhe-oa/cli(Node 版)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 心河 OA 客户端 CLI 的 **Node 实现**:多维表格 / 应用 / 权限的唯一命令行入口。
4
+
5
+ > **这是客户端的唯一入口**(2026-10-16 起)。Rust 版(仓库里的 `cli/`)已**冻结**在
6
+ > `0.1.7`:不更新、不发布,只留作命令面规格与对拍的基准。
7
+ > **版本号以本包的 `package.json` 为权威**(`src/version.js` 必须与它一致)。
8
+
9
+ ```bash
10
+ npx @xinhe-oa/cli config --set base_url=https://oa.example.com
11
+ npx @xinhe-oa/cli login --phone 13800000000
12
+ npx @xinhe-oa/cli login --phone 13800000000 --code 123456
13
+ npx @xinhe-oa/cli me --json
14
+ ```
15
+
16
+ 装一次就够(`npm i -g @xinhe-oa/cli` 之后直接用 `xinhe-oa-cli`):
17
+
18
+ ```bash
19
+ npm i -g @xinhe-oa/cli
20
+ xinhe-oa-cli bitable base --list --json
21
+ ```
22
+
23
+ ## 这是什么、不是什么
24
+
25
+ - **是**客户端唯一实现:153 条命令、同一套退出码、同一份 `--json` 形状。
26
+ 命令面由 Rust 产物生成并逐字节对拍(见下文"为什么可信");Rust 版已冻结为对照。
27
+ - **不是**一个新客户端:配置目录、凭据文件、设备指纹、`User-Agent` 与 Rust 版
28
+ **完全一致**,所以**换用 Node 版不需要重新登录**(见下文"从 Rust 版迁移")。
29
+ - **不需要**下载平台二进制:一份 JS 跑在所有 Node 支持的平台上
30
+ (Windows / macOS / Linux,x64 / arm64)。这正是它存在的理由 ——
31
+ 客户机上不再有"我的系统该下哪个包"这件事。
32
+
33
+ ## 环境要求
34
+
35
+ - **Node ≥ 20.11**(`node --version` 自查)。Rust 版已冻结、不再发布,没有 Node 的机器
36
+ 请先装 Node;服务端产物通道发的那份入口脚本也会在 Node 太旧时直接说清楚。
37
+ - **零运行时依赖**:只用 Node 内置模块,`npm i` 不会拉进任何第三方代码。
38
+
39
+ ## 与 Rust 版的关系
40
+
41
+ | 事项 | 口径 |
42
+ |---|---|
43
+ | 命令面 | 同一份冻结契约(`docs/31-cli-contract.md`)。Node 版的 `schema --json` 与 Rust 产物**逐字节一致**(版本号字段除外) |
44
+ | 退出码 | 0 成功 · 2 用法 · 3 未登录 · 4 权限 · 5 冲突 · 6 服务端 · 7 网络 · 8 版本过旧 |
45
+ | 输出 | `--json` = `{ok,data,meta}`(键按字典序,与 Rust 的 `serde_json` 同形);stdout 只放结果,进度与日志一律 stderr |
46
+ | 配置目录 | `~/.config/xinhe-oa-cli/`(`XINHE_OA_CONFIG_DIR` 可覆盖);`config.json` `0600`、`credentials.json` `0600`、目录 `0700` |
47
+ | 设备指纹 | 每个 profile 一个 `device_id`,与 Rust 版共用同一份文件 |
48
+ | `User-Agent` | 固定 `xinhe-oa-cli/1`(**不含版本号**)。服务端把 refresh 绑在 `HMAC(UA + X-Device-Id)` 上,带版本号会让用户一升级就被 `auth.device_mismatch` 踢下线 |
49
+ | 版本上报 | 走 `X-CLI-Version` 头(Node 版报自己的版本) |
50
+ | 代理 | 尊重 `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` 与 `NO_PROXY`;支持 `http(s)://` 与 `socks5(h)://`;**本机与私网地址永远直连** |
51
+ | 令牌纪律 | 令牌不进 argv、不进日志、不进错误详情;`config --show` 只显示掩码 |
52
+
53
+ ## 从 Rust 版迁移(不用重新登录)
54
+
55
+ 两份实现**读同一个目录、同一份 JSON**。所以:
56
+
57
+ ```bash
58
+ # 1) 装 Node 版
59
+ npm i -g @xinhe-oa/cli
60
+
61
+ # 2) 直接用它 —— 原来的登录还在
62
+ xinhe-oa-cli me --json
63
+ ```
64
+
65
+ 三条纪律值得知道:
66
+
67
+ - `device_id` 沿用凭据文件里的那一个。**别删** `~/.config/xinhe-oa-cli/credentials.json`:
68
+ 服务端把 refresh 绑在设备指纹上,换了设备就是一次短信重登。
69
+ - 两个版本可以**共存**(`xinhe-oa-cli` 这个名字会指向后装的那个;Rust 版二进制
70
+ 在 `/usr/local/bin/xinhe-oa-cli` 或安装脚本给的目录里,直接按路径调用即可)。
71
+ - 回退:删掉 npm 包、按老办法用 Rust 二进制,凭据一样不用重登。
72
+
73
+ ## 为什么可信
74
+
75
+ 这套实现不是"照文档抄的",而是**对着已发布的 Rust 产物逐字节对拍**出来的:
76
+
77
+ ```
78
+ cli-node/scripts/gen-spec.py # 从 Rust 产物的 schema --json 生成命令面规格
79
+ cli-node/test/schema-parity.test.js # 与 Rust 产物逐字节对拍(含 --agent 全文)
80
+ cli-node/test/parse-surface.test.js # 153 条命令的接受面 / 用法错误 / --yes 闸门
81
+ ```
82
+
83
+ `scripts/rust_args.py` 会把 `args.rs` 里"`schema` 没登记、但 clap 实际接受"的旗标
84
+ (如 `config --show`)与"取值可省"的旗标(如 `--show`)一并抽出来 ——
85
+ 这些正是手工移植必然会漏掉的地方。
86
+
87
+ ```bash
88
+ npm test # 全部闸门
89
+ npm run lint # 语法自检
90
+ ```
91
+
92
+ ## 常见问题
93
+
94
+ **`还没有配置后端地址`** —— 先指一次后端:
95
+
96
+ ```bash
97
+ xinhe-oa-cli config --set base_url=https://oa.example.com
98
+ ```
99
+
100
+ **连不上内网后端** —— 内网直连时把代理彻底关掉:
101
+
102
+ ```bash
103
+ xinhe-oa-cli config --set proxy=none
104
+ xinhe-oa-cli doctor # 看它这次到底走的是代理还是直连
105
+ ```
106
+
107
+ **导入慢或断在半路** —— 直接重跑同一条命令,进度记录在
108
+ `<config>/checkpoints/`,只补剩余批次(`--fresh` 才从头来)。
109
+
110
+ **升级** —— 跟着 npm 走,**这是唯一的升级通道**:
111
+
112
+ ```bash
113
+ npm i -g @xinhe-oa/cli@latest
114
+ ```
115
+
116
+ 先看一眼该不该升:
117
+
118
+ ```bash
119
+ xinhe-oa-cli doctor --upgrade # 问一次 npm,告诉你是不是最新、该跑哪条命令
120
+ ```
121
+
122
+ 三条刻意的设计(改之前先读 `src/commands/doctor.js` 顶上那段注释):
123
+
124
+ - `doctor --upgrade` **不替换自己**:它只问 npm 的 `latest`、与本机版本比一比、
125
+ 给出可直接照抄的命令。升级由包管理器做,CLI 不动自己所在的文件树
126
+ (那会把 npm 的账本与实际内容弄不一致)。
127
+ - 升级**不需要登录**:问 npm 与凭据无关。连不上 registry 时如实说明,
128
+ 不报错、不影响其它自检结论(`doctor` 没网也必须能用)。
129
+ - 内网源用 `XINHE_OA_NPM_REGISTRY=<你的 registry>` 覆盖,包名用
130
+ `XINHE_OA_NPM_PACKAGE` 覆盖(默认 `@xinhe-oa/cli`)。
131
+
132
+ `doctor --upgrade --out <路径>` 是另一件事:把**服务端当前发布的那份产物**写到那个
133
+ 路径(核对 sha256 / 装到别处 / 排障用),当前可执行文件一个字节都不动。服务端那条通道
134
+ (`/cli/download/{os}/{arch}`)发的是**本包的入口脚本**(见下),不是二进制 ——
135
+ 真发来一个 ELF/PE 时会被拒绝落盘(`cli.artifact_not_javascript`)。
136
+
137
+ ## 发版(维护者)
138
+
139
+ ```bash
140
+ # ① 客户端本体:npm 是唯一升级通道,版本号以 package.json 为准
141
+ # (src/version.js 必须与它一致,release-check 会拦)
142
+ npm publish --access public
143
+
144
+ # ② 服务端产物通道(/cli/download/{os}/{arch}):发一份 Node 入口脚本
145
+ bash scripts/publish-download-artifact.sh # 生成 + 归集到 <repo>/dist/cli
146
+ bash scripts/publish-download-artifact.sh --archive-legacy # 顺手留档旧 Rust 产物到 dist/cli-rust/
147
+ ```
148
+
149
+ 产物脚本的生成器是 `scripts/make-download-artifact.mjs`,模板是
150
+ `scripts/download-entry.template.js`,验收在 `test/download-artifact.test.js`
151
+ (三平台各一份、内容逐字节一致、`#!` 开头、版本钉死、参数与退出码原样透传)。
152
+ 为什么那份模板写成"老式 JS + `new Function`":见模板顶部注释(两种模块解析 + 旧 Node)。
153
+
154
+ 上线步骤、服务端要改的几处、以及"老 Rust 客户端会自毁"的兜法,都在
155
+ [`GO-LIVE.md`](./GO-LIVE.md)。
156
+
157
+
158
+ ## 许可
159
+
160
+ UNLICENSED(内部使用)。
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // `xinhe-oa-cli` 入口(Node 版)。
3
+ //
4
+ // 这一层刻意只有三行:真正的解析/分发在 `src/main.js` 里,
5
+ // 这样测试可以直接 import `runCli()` 而不用起子进程。
6
+ //
7
+ // argv[0] 今天是 `xinhe-oa-cli`,将来统一叫 `xinhe-oa`;两种名字都容忍
8
+ // (`package.json` 的 `bin` 同时注册了两个名字)。
9
+ import { runCli } from '../src/main.js';
10
+
11
+ // 退出码由 `runCli` 返回:契约 §3.2 的 0/2/3/4/5/6/7/8。**不要**在这里兜底成 1 ——
12
+ // 调用方(Agent、判卷、脚本)就是靠这个码分辨"用法错"与"网络不通"的。
13
+ process.exitCode = await runCli(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@xinhe-oa/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "心河 OA 客户端 CLI(Node 版):多维表格 / 应用 / 权限的唯一命令行入口",
5
+ "keywords": [
6
+ "xinhe-oa",
7
+ "cli",
8
+ "bitable",
9
+ "oa"
10
+ ],
11
+ "license": "UNLICENSED",
12
+ "private": false,
13
+ "type": "module",
14
+ "bin": {
15
+ "xinhe-oa-cli": "bin/xinhe-oa-cli.js",
16
+ "xinhe-oa": "bin/xinhe-oa-cli.js"
17
+ },
18
+ "main": "src/main.js",
19
+ "exports": {
20
+ ".": "./src/main.js",
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "bin/",
25
+ "src/",
26
+ "README.md"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20.11"
30
+ },
31
+ "scripts": {
32
+ "test": "node --test --test-timeout 900000 test/*.test.js",
33
+ "lint": "node scripts/check-syntax.js"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/xinhe-oa/cli.git"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }
@@ -0,0 +1,244 @@
1
+ // 应用:`application --list|--show|--export|--install|--create|--copy`(契约 §2)。
2
+ //
3
+ // 应用 = 表 + 视图 + 表单 + 看板 + 工作流 + Skill + 权限的**可复制描述**(Manifest)。
4
+ // 它是"把一套业务结构搬到另一家企业"的唯一入口,所以三条命令判 `high-risk-write`:
5
+ //
6
+ // * `--install` / `--create`:往一家企业里**建结构**(建表 / 字段 / 视图 / 表单 /
7
+ // 看板 / 工作流)。结构与数据不是一回事:写错一行记录删掉就完了,而表/字段一旦被
8
+ // 业务用起来就改不回去 —— docs/26 §2.1 定的口径是 `mode=create` 只增不改、
9
+ // `update` 也只改显示名/视图配置且**永不删除**对象。后果不可逆,所以要 `--yes`。
10
+ // * `--copy`:把结构复制**到另一家企业** —— 目标企业是真实的生产环境,
11
+ // 一次误敲就是往别人家里建了一整套表。
12
+ //
13
+ // 三条都在**发请求之前**过 `--yes` 闸门(退出码 2、一个字节都不发)。
14
+ //
15
+ // 与 Rust 版逐条对齐的四个细节:
16
+ //
17
+ // 1. **`--create` 是 `--install` 的别名**:Rust 的 `ApplicationAction::Install | Create`
18
+ // 走同一个 `install()`,所以 `--create` 缺 `--file` 时报的是
19
+ // "`application --install` 需要 --file <manifest.json>"。这里照样只写一处实现。
20
+ // 2. **manifest 只从本地文件读,不从 stdin 读**:manifest 是要被审阅、被复制、
21
+ // 被 `--export --output` 落成文件的产物;stdin 一过手就没人能复核"我到底装了什么"。
22
+ // 又因为它是本地 JSON,读文件与解析都在发请求**之前**做完 —— 坏文件当场退出码 2,
23
+ // 不会变成一次服务端 400。
24
+ // 3. **`--plan` 与 `--dry-run` 是两件事**:`--plan` 是服务端的 dry_run(只算不写,
25
+ // 响应里的 `dry_run` 回显的是它);全局 `--dry-run` 连请求都不发(契约 §2.1)。
26
+ // 4. **`--list` 上的 `--tenant` 是兄弟参数**:clap 的扁平结构让 `--list` 也认这个旗标,
27
+ // 但 Rust 的 `list()` 根本不读它 —— 这里同样不发 `tenant_id` query,
28
+ // 保持"同参数 ⇒ 同请求"。
29
+
30
+ import fs from 'node:fs';
31
+
32
+ import { CliError, exactlyOne } from '../core/error.js';
33
+ import { flagOn, flagValue } from '../core/args.js';
34
+ import { dataOf, idempotencyKey, readFileBytes, requestSpec } from '../core/runtime.js';
35
+ import { rustIoErrorText } from '../core/util.js';
36
+ import { sortKeysDeep } from '../core/util.js';
37
+ import { resolveTenant } from './tenant.js';
38
+
39
+ /** `application --list|--show|--export|--install|--create|--copy` 的分发。 */
40
+ export async function handleApplication({ args, runtime }) {
41
+ const action = exactlyOne(
42
+ [
43
+ ['--list', flagOn(args, '--list')],
44
+ ['--show', flagValue(args, '--show') !== null],
45
+ ['--export', flagValue(args, '--export') !== null],
46
+ ['--install', flagOn(args, '--install')],
47
+ ['--create', flagOn(args, '--create')],
48
+ ['--copy', flagValue(args, '--copy') !== null],
49
+ ],
50
+ 'application',
51
+ '例:xinhe-oa-cli application --list --json',
52
+ );
53
+ if (action === '--list') return list(runtime);
54
+ if (action === '--show') return show(runtime, flagValue(args, '--show'));
55
+ if (action === '--export') return exportManifest(runtime, args, flagValue(args, '--export'));
56
+ if (action === '--install' || action === '--create') return install(runtime, args);
57
+ return copy(runtime, args, flagValue(args, '--copy'));
58
+ }
59
+
60
+ async function list(runtime) {
61
+ const response = await runtime.sendAuthed('GET', '/apps', requestSpec());
62
+ const data = dataOf(response);
63
+ // 缺键与显式 `null` 都要原样保留(`hasKey` 而不是 `??`):服务端用
64
+ // `"total": null` 表达"这次不报总数",套上默认值会把它变成一句假话。
65
+ runtime.finishFrom(response, {
66
+ apps: hasKey(data, 'apps') ? data.apps : [],
67
+ total: hasKey(data, 'total') ? data.total : null,
68
+ next_step: '看详情:xinhe-oa-cli application --show <APP_ID> --json',
69
+ });
70
+ }
71
+
72
+ async function show(runtime, appId) {
73
+ const response = await runtime.sendAuthed('GET', `/apps/${appId}`, requestSpec());
74
+ // 详情(含 manifest 与最近一次 apply_report)原样透传:调用方要什么键由服务端定,
75
+ // CLI 不加壳也不裁剪。
76
+ runtime.finishFrom(response, dataOf(response));
77
+ }
78
+
79
+ async function exportManifest(runtime, args, appId) {
80
+ const path = `/apps/${appId}/export`;
81
+ // 导出是 POST(服务端要算 checksum、可能要落审计),但**语义是读**:
82
+ // 风险级别是 `read`,所以它不需要 `--yes`,也不需要幂等键。
83
+ const spec = requestSpec();
84
+ if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
85
+
86
+ const response = await runtime.sendAuthed('POST', path, spec);
87
+ const data = dataOf(response);
88
+ // 服务端把可复制描述放在 `manifest` 键下;老版本/直出时没有这层包装,就用整个 data。
89
+ const manifest = hasKey(data, 'manifest') ? data.manifest : data;
90
+
91
+ // `--output` 与隐藏的 `--out` 是同一个出口(Rust:`output.or_else(out)`,output 优先)。
92
+ const target = flagValue(args, '--output') ?? flagValue(args, '--out');
93
+ if (target === null) {
94
+ runtime.finishFrom(response, { manifest });
95
+ return;
96
+ }
97
+
98
+ // 落盘要的是**与 Rust 相同的字节**:serde_json 的 pretty 是 2 空格缩进、
99
+ // 键按字典序、末尾一个换行。`bytes` 报的是**不含**那个换行的那段长度
100
+ // (Rust 先量 `body.len()` 再 `write(format!("{body}\n"))`)。
101
+ const body = JSON.stringify(sortKeysDeep(manifest), null, 2);
102
+ try {
103
+ fs.writeFileSync(target, `${body}\n`);
104
+ } catch (err) {
105
+ throw CliError.usage(`写不了 ${target}:${rustIoErrorText(err)}`);
106
+ }
107
+ runtime.finishFrom(response, {
108
+ path: target,
109
+ bytes: Buffer.byteLength(body, 'utf8'),
110
+ app_id: appId,
111
+ next_step: `装到另一个企业:xinhe-oa-cli application --install --file ${target} --tenant <TENANT_ID> --yes`,
112
+ });
113
+ }
114
+
115
+ /**
116
+ * `application --install` 与它的别名 `--create`。
117
+ *
118
+ * 闸门顺序**照抄 Rust**,因为顺序本身就是行为:确认 → `--file` → 企业 → `--mode`
119
+ * → 读文件 → 解析 JSON。没确认的调用连 `GET /me`(解析缺省企业)都不该发;
120
+ * `--mode` 拼错时也不该先去读盘(参数错与文件错同时存在时,先报参数错)。
121
+ */
122
+ async function install(runtime, args) {
123
+ runtime.requireYes(
124
+ 'application --install(应用会改结构:建表/字段/视图/表单/看板/工作流)',
125
+ 'xinhe-oa-cli application --install --file <manifest.json> --tenant <TENANT_ID>',
126
+ );
127
+
128
+ const file = flagValue(args, '--file');
129
+ if (file === null) {
130
+ // 提示词里恒为 `--install`(Rust 的两个动作共用同一个函数)。
131
+ throw CliError.usage('`application --install` 需要 --file <manifest.json>').withHint(
132
+ 'manifest 从 `application --export <APP_ID> --output ./app.json` 拿',
133
+ );
134
+ }
135
+
136
+ // 企业:显式 `--tenant` > 你的唯一可见企业(一次 `GET /me`)。
137
+ // **多企业时不替用户挑**(挑错就是往别人企业里建表)——`resolveTenant` 会以
138
+ // 退出码 2 报错并把候选企业列进 `details.visible_tenants`。
139
+ const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'));
140
+ if (tenant === null) {
141
+ throw CliError.usage('安装应用必须指明企业').withHint(
142
+ '加 --tenant <TENANT_ID>;用 `xinhe-oa-cli tenant --list` 看有哪些企业',
143
+ );
144
+ }
145
+
146
+ // `--mode` 的 clap 默认值是 `create`:不给就等于 create(响应里也这么回显)。
147
+ //
148
+ // ⚠️ 这一步**刻意排在问企业之后**(与 Rust 同序):于是"没给 --tenant 又把 --mode
149
+ // 写错"时会先发一次 `GET /me`(读)再报用法错误。不在这里"优化"成先判参数 ——
150
+ // 同参数下的请求序列也必须一样,否则对拍会红。
151
+ const mode = flagValue(args, '--mode') ?? 'create';
152
+ if (mode !== 'create' && mode !== 'update') {
153
+ throw CliError.usage(
154
+ `--mode 只能是 create(只增不改)或 update(允许改显示名/视图配置),收到 ${JSON.stringify(mode)}`,
155
+ );
156
+ }
157
+
158
+ // manifest 读到最后一刻才算数:解析失败 = 用法错误(2),不是服务端 400。
159
+ const manifest = parseManifest(file);
160
+ const plan = flagOn(args, '--plan');
161
+ const body = {
162
+ // 键序刻意按字典序摆:Rust 的 serde_json 是 BTreeMap,请求体里的键一律有序,
163
+ // 这样"同参数 ⇒ 同请求体"连字节面都对得上。
164
+ dry_run: plan,
165
+ manifest,
166
+ mode,
167
+ tenant_id: tenant,
168
+ };
169
+ // 写操作必带幂等键:网络抖动重发不会建出第二套结构(服务端按幂等键去重)。
170
+ const spec = requestSpec({
171
+ body,
172
+ idempotencyKey: idempotencyKey('application --install', flagValue(args, '--idempotency-key')),
173
+ });
174
+ if (runtime.dryRun) return runtime.dryRunReport('POST', '/apps', spec);
175
+
176
+ const response = await runtime.sendAuthed('POST', '/apps', spec);
177
+ const data = dataOf(response);
178
+ runtime.finishFrom(response, {
179
+ app_id: hasKey(data, 'id') ? data.id : hasKey(data, 'app_id') ? data.app_id : null,
180
+ mode,
181
+ dry_run: plan,
182
+ report: hasKey(data, 'report') ? data.report : data,
183
+ note: plan
184
+ ? '这是 --plan(服务端 dry_run):一行都没落库'
185
+ : '应用 = 一次幂等的结构变更;同一 checksum 再应用会全部 skipped',
186
+ });
187
+ }
188
+
189
+ /** 复制结构到另一个企业(**一行数据都不搬**)。 */
190
+ async function copy(runtime, args, appId) {
191
+ runtime.requireYes(
192
+ 'application --copy(复制结构到另一个企业)',
193
+ `xinhe-oa-cli application --copy ${appId} --to-tenant <TENANT_ID>`,
194
+ );
195
+
196
+ // 只判"给没给",不判空串:Rust 的 `Option<String>` 里 `Some("")` 也是给了,
197
+ // 服务端会自己拒掉空的企业 id(CLI 不替服务端发明校验规则)。
198
+ const target = flagValue(args, '--to-tenant');
199
+ if (target === null) {
200
+ throw CliError.usage('`application --copy` 需要 --to-tenant <TENANT_ID>');
201
+ }
202
+
203
+ // `semantic_map` 是"源字段语义 → 目标企业已有字段语义"的映射;本期恒为 null
204
+ // (docs/26 §2.1 第 4 条:目标企业已有同义字段时才需要映射)。
205
+ const body = { semantic_map: null, target_tenant_id: target };
206
+ const spec = requestSpec({
207
+ body,
208
+ idempotencyKey: idempotencyKey('application --copy', flagValue(args, '--idempotency-key')),
209
+ });
210
+ const path = `/apps/${appId}/copy`;
211
+ if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
212
+
213
+ const response = await runtime.sendAuthed('POST', path, spec);
214
+ const data = dataOf(response);
215
+ runtime.finishFrom(response, {
216
+ app_id: appId,
217
+ target_tenant_id: target,
218
+ report: hasKey(data, 'report') ? data.report : data,
219
+ note: '复制只搬结构,**一行数据都不搬**(新企业记录数从 0 开始)',
220
+ });
221
+ }
222
+
223
+ /** 读 + 解析 manifest(读不了 / 不是 JSON 都是**本地**用法错误)。 */
224
+ function parseManifest(file) {
225
+ const raw = readFileBytes(file);
226
+ let parsed;
227
+ try {
228
+ parsed = JSON.parse(raw.toString('utf8'));
229
+ } catch (err) {
230
+ throw CliError.usage(`${file} 不是合法 JSON:${err.message}`).withHint(
231
+ 'manifest 应当是 `application --export` 导出的那个文件',
232
+ );
233
+ }
234
+ // 与 Rust 的 serde_json 同形:键按字典序(见 install() 里的说明)。
235
+ return sortKeysDeep(parsed);
236
+ }
237
+
238
+ /** 键存在就取(`null` 也算存在)—— 对齐 Rust `Value::get`:**不能**用 `??` 兜底,
239
+ * 否则服务端显式回的 `null` 会被当成"没这个键"而套上默认值。 */
240
+ function hasKey(value, key) {
241
+ return (
242
+ value !== null && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, key)
243
+ );
244
+ }