ek-ui 0.9.28

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
//! # ekapp —— 「ek-station 桌面」产品入口(0.9.0 打包案)。
//!
//! 三张脸(同一二进制):
//! - **`ekapp`(无参数)= 桌面主窗**:引擎 + ui/dm/oms 常驻 + 启动清点已存账户
//!   (行显「档案·密库引用」,手点「连接」= svctl.start 存档直连)。**不自动连**;
//!   行上的「自动连」是给守护复原(ek-station)与后续启动清点勾的。
//! - **`ekapp add-account …` = 无界面一次性写档**:受理三件套同款(明文键 +
//!   密码进 Windows 凭据管理器的密库引用),写完下次启动就能点。
//!   **密码永远走 stdin**(不进 argv:进程行能被旁人看到,红线)。
//! - **`ekapp list-accounts` = 已存档案清点**(只读元信息,不含任何密码值)。
//! - **`ekapp tq-secret` = 天勤桥凭据入库**(0.9.5):两行依次走 stdin(user、password),
//!   值进 OS 密库(`ek/tq/user`、`ek/tq/password`),档案 `tq.ini` 只落引用行。
//!
//! 桌面路的形貌(0.9.5 阶段机;0.9.10 一钮收口):**开窗即组件配置面** ——
//! 勾选组件 → 「启动运行」(先按勾选装配、再起跑)才出主界面,运行后装配面锁定
//! 只读(锁是界面层旗,底层不写死);装配失败 = 报错留在配置相等用户。
//! 演示骨架(demo_tour)走的是 auto_boot 老序(巡回判据面隔离,不动本文件)。

// 臂折:CLI 模式不建窗,先分支后引。
fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    match args.first().map(|s| s.as_str()) {
        Some("add-account") => {
            if let Err(e) = cli_add_account(&args[1..]) {
                eprintln!("add-account 失败: {e}");
                std::process::exit(1);
            }
        }
        Some("list-accounts") => cli_list_accounts(),
        Some("export-bundle") => {
            if let Err(e) = cli_export_bundle(&args[1..]) {
                eprintln!("export-bundle 失败: {e}");
                std::process::exit(1);
            }
        }
        Some("import-bundle") => {
            if let Err(e) = cli_import_bundle(&args[1..]) {
                eprintln!("import-bundle 失败: {e}");
                std::process::exit(1);
            }
        }
        Some("remove-account") => {
            if let Err(e) = cli_remove_account(&args[1..]) {
                eprintln!("remove-account 失败: {e}");
                std::process::exit(1);
            }
        }
        Some("secret-check") => {
            if let Err(e) = cli_secret_check(&args[1..]) {
                eprintln!("secret-check 失败: {e}");
                std::process::exit(1);
            }
        }
        Some("tq-secret") => {
            if let Err(e) = cli_tq_secret(&args[1..]) {
                eprintln!("tq-secret 失败: {e}");
                std::process::exit(1);
            }
        }
        Some("init-master-key") => {
            if let Err(e) = cli_init_master_key() {
                eprintln!("init-master-key 失败: {e}");
                std::process::exit(1);
            }
        }
        Some("rekey-secrets") => {
            if let Err(e) = cli_rekey_secrets() {
                eprintln!("rekey-secrets 失败: {e}");
                std::process::exit(1);
            }
        }
        Some("encrypt-blobs") => {
            if let Err(e) = cli_encrypt_blobs() {
                eprintln!("encrypt-blobs 失败: {e}");
                std::process::exit(1);
            }
        }
        Some("migrate-db") => {
            if let Err(e) = cli_migrate_db() {
                eprintln!("migrate-db 失败: {e}");
                std::process::exit(1);
            }
        }
        Some("help" | "--help" | "-h") => println!("{}", usage_screen()),
        Some(other) => {
            eprintln!("未知子命令 `{other}`。\n{}", usage_screen());
            std::process::exit(2);
        }
        None => desktop_main(),
    }
}

/// 子命令总表(**单源**):`ekapp help` 与「未知子命令」两处同屏,不再各写一份。
///
/// 纪律:每加一个 `Some("<子命令>")` 分支,这里必须有对应行 —— 否则「help 查不到
/// 但确实能跑」= 入口不可发现(复审 L6:8 个新入口没登记)。
fn usage_screen() -> String {
    "用法:
  ekapp                            # 桌面主窗(勾选组件 → 启动运行)
  ekapp add-account …              # 无界面写档(密码走 stdin,不进 argv)
  ekapp list-accounts              # 已存档案清点(只读元信息,零密码值)
  ekapp remove-account <ns>        # 删户:段行 + 该段全部密文(幂等)
  ekapp secret-check <ns> [字段]   # 密库引用自检(只报在/缺与长度,零值)
  ekapp tq-secret [--check]        # 天勤桥凭据入密库(stdin 两行;--check 只打形状)
  ekapp export-bundle <文件>       # 整库导出(秘值用口令包裹,不含主密钥)
  ekapp import-bundle <文件> [根]  # 导入包(秘值改本机主钥重加密;根缺省 = eco)
  ekapp init-master-key            # 生成主密钥(已存在即拒,值不回显)
  ekapp encrypt-blobs              # 明文遗留就地加密(幂等)
  ekapp rekey-secrets              # 换钥修复(旧钥经 EK_OLD_MASTER_KEY=64 位 hex)
  ekapp migrate-db                 # 一次性迁移 *.ini → cfg.db(幂等,旧档改名存档)
  ekapp help                       # 本屏

  口令纪律:凡口令一律 stdin / 环境变量,永不进 argv(进程行能被旁人看到)。"
        .to_string()
}

// ═════════════════════════════ 桌面主窗 ═════════════════════════════

fn desktop_main() {
    // 0.9.5 阶段机:开窗**不装配不启动**(用户裁定「先配置组件、运行后才出主界面;
    // 运行后装配只可看」)。整条相路单源在 ek_ui::runtime::run_desktop
    // (原 bin 自带的 boot/start/DesktopApp 副本已删,消重)。
    // 单模显形(步骤 5):根内还有未迁旧档 `*.ini` = 上一轮漏跑迁移,这些段对
    // 库门面不可见(界面会像「空白首启」)。不静默,点名提醒后照常起。
    if let Ok(st) = ek_cfg::Store::eco() {
        let pending = st.legacy_pending();
        if !pending.is_empty() {
            eprintln!(
                "ekapp: 根内发现未迁旧档 {} 个段({});单模下不可见 —— 请先跑 `ekapp migrate-db`",
                pending.len(),
                pending.join(", ")
            );
        }
    }
    if let Err(e) = ek_ui::runtime::run_desktop() {
        eprintln!("ekapp: {e}");
        std::process::exit(1);
    }
}

// ═════════════════════════════ add-account ═════════════════════════════

fn usage() -> String {
    "用法:
  ekapp add-account --broker 9999 --user 278153 \
       --td tcp://182.254.243.31:30001 --md tcp://182.254.243.31:30011 \
       --app-id simnow_client_test --auth-code 0000000000000000 \
       [--classes 1(只期货;现时唯一合法档)] \
       [--md-user u --md-password-stdin]

  密码在提示后从 stdin 读一行(不进 argv;进程行能被旁人看到)。"
        .to_string()
}

fn cli_add_account(rest: &[String]) -> Result<(), String> {
    let mut a = ek_ui::appconfig::AcctSpec {
        broker_id: String::new(),
        user: String::new(),
        td_addr: String::new(),
        md_addr: String::new(),
        app_id: String::new(),
        auth_code: String::new(),
        classes: "1".to_string(),
        md_user: String::new(),
        md_password: None,
    };
    let mut i = 0;
    while i < rest.len() {
        let flag = rest[i].as_str();
        let need = |i: &mut usize, rest: &[String], what: &str| -> Result<String, String> {
            *i += 1;
            rest.get(*i)
                .cloned()
                .ok_or_else(|| format!("{what} 缺值(参数后要跟值)"))
        };
        match flag {
            "--broker" => a.broker_id = need(&mut i, rest, "--broker")?,
            "--user" => a.user = need(&mut i, rest, "--user")?,
            "--td" => a.td_addr = need(&mut i, rest, "--td")?,
            "--md" => a.md_addr = need(&mut i, rest, "--md")?,
            "--app-id" => a.app_id = need(&mut i, rest, "--app-id")?,
            "--auth-code" => a.auth_code = need(&mut i, rest, "--auth-code")?,
            "--classes" => a.classes = need(&mut i, rest, "--classes")?,
            "--md-user" => a.md_user = need(&mut i, rest, "--md-user")?,
            "--md-password-stdin" => a.md_password = Some(String::new()),
            other => {
                return Err(format!("未知参数 `{other}`\n{}", usage()));
            }
        }
        i += 1;
    }

    let guid = ek_ui::appconfig::derived_name(&a)?;

    // 密码:stdin 读一行 —— 永不进 argv(BD-APP-2 红线:进程行能被旁人看到)。
    eprint!("[ekapp] 输入 {guid} 的 CTP 密码(stdin 单行): ");

    let mut buf = String::new();
    std::io::stdin()
        .read_line(&mut buf)
        .map_err(|e| format!("stdin 读失败: {e}"))?;
    let password = buf.trim().to_string();
    if password.is_empty() {
        return Err("密码为空:档案不接受无密码账户(受理校验同款)".to_string());
    }
    if a.md_password.is_some() {
        eprint!("[ekapp] 输入 {guid} 的行情密码(stdin 单行;留空回落 td 密码): ");
        let mut mbuf = String::new();
        std::io::stdin()
            .read_line(&mut mbuf)
            .map_err(|e| format!("stdin 读失败: {e}"))?;
        let mp = mbuf.trim().to_string();
        // 留空 = 段里不出 md_password(行情会话回落 td 凭据)。
        a.md_password = (!mp.is_empty()).then_some(mp);
    }
    let store = ek_cfg::Store::eco().map_err(|e| format!("配置根不可用: {e:?}"))?;
    let gw = ek_ui::appconfig::write_account(&store, &a, &password)?;
    let md_txt = if a.md_addr.is_empty() {
        "—未配—".to_string()
    } else {
        a.md_addr.clone()
    };
    println!(
        "[ekapp] 已写档 ctp.{gw}.ini(td={td};md={md};密码=密库引用,值不落盘)。
下一步:启动 `ekapp`(桌面)点该行的『连接』;或起 `ek-station` 守护(自动连接先在桌面行上勾)。",
        td = a.td_addr,
        md = md_txt
    );
    Ok(())
}
// ═════════════════════════════ list-accounts ═════════════════════════════

/// ekapp remove-account <ns> —— 步骤 8:CLI 级删户(段行 + 该段全部密文)。
///
/// 只认**账户段**(`ctp.*`/`tq`/`oms.*` 之类),`ui` 段拒删(主题/别名
/// 不是账户;误删 = 用户偏好全丢)。幂等:段不存在 = 报「本来就没有」而非错。
fn cli_remove_account(rest: &[String]) -> Result<(), String> {
    let Some(ns) = rest.first() else {
        return Err("用法: ekapp remove-account <ns>".to_string());
    };
    if ns == "ui" {
        return Err("ui 段是界面偏好(主题/别名),不是账户,拒删;要清就手工编辑".to_string());
    }
    let store = ek_cfg::Store::eco().map_err(|e| format!("配置根不可用: {e:?}"))?;
    let existed = matches!(store.load(ns), Ok(ek_cfg::LoadOutcome::Loaded { .. }));
    if !existed {
        println!("remove-account:段 {ns} 本来就没有(幂等,无动作)");
        return Ok(());
    }
    store.purge(ns).map_err(|e| format!("抹段显形: {e}"))?;
    let after = matches!(store.load(ns), Ok(ek_cfg::LoadOutcome::Loaded { .. }));
    if after {
        return Err(format!("抹段后仍读得到 {ns}(库写未落地,查 WAL/磁盘)"));
    }
    println!("remove-account 完成:{ns} 段行 + 该段密文已抹净,回读已确认不存在。");
    Ok(())
}

/// ekapp export-bundle <文件> —— 步骤 8:整库导出(段行 + 秘值,秘值用口令包裹)。
/// 口令走 stdin 一行,**绝不进 argv**;包内不含主密钥。
fn cli_export_bundle(rest: &[String]) -> Result<(), String> {
    let Some(path) = rest.first() else {
        return Err("用法: ekapp export-bundle <文件>  (口令从 stdin 读一行)".to_string());
    };
    println!("输入导出口令(≥8 字符,只在本进程内用,不落盘/不回显):");
    let pass = read_stdin_line().ok_or("stdin 缺口令行")?;
    let store = ek_cfg::Store::eco().map_err(|e| format!("配置根不可用: {e:?}"))?;
    let pack = ek_cfg::bundle::export(store.root(), pass.trim()).map_err(|e| format!("导出显形: {e}"))?;
    std::fs::write(path, &pack).map_err(|e| format!("写包失败 {path}: {e}"))?;
    println!(
        "export-bundle 完成:{} 字节 → {path}(段面 + 秘值;秘值用口令包裹,不含主密钥)",
        pack.len()
    );
    Ok(())
}

/// ekapp import-bundle <文件> [根] —— 步骤 8:导入(段面覆盖写;秘值改写成本机主密钥形态)。
fn cli_import_bundle(rest: &[String]) -> Result<(), String> {
    let Some(path) = rest.first() else {
        return Err("用法: ekapp import-bundle <文件> [根]  (口令从 stdin 读一行)".to_string());
    };
    println!("输入导入口令(导出时那个):");
    let pass = read_stdin_line().ok_or("stdin 缺口令行")?;
    let root = match rest.get(1) {
        Some(r) => std::path::PathBuf::from(r),
        None => ek_cfg::Store::eco().map_err(|e| format!("配置根不可用: {e:?}"))?.root().to_path_buf(),
    };
    let data = std::fs::read(path).map_err(|e| format!("读包失败 {path}: {e}"))?;
    let rep = ek_cfg::bundle::import(&root, pass.trim(), &data).map_err(|e| format!("导入显形: {e}"))?;
    println!(
        "import-bundle 完成:{} 段 / {} 键 / {} 秘值(秘值已用本机主密钥重加密);根 = {}",
        rep.sections,
        rep.keys,
        rep.secrets,
        root.display()
    );
    Ok(())
}

fn cli_list_accounts() {
    let store = match ek_cfg::Store::eco() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("[ekapp] 配置根不可用: {e:?}");
            std::process::exit(1);
        }
    };
    // 列举失败必须**显形**:吞成空表会让「库打不开」和「库是空的」印出同一句
    // 「无 ctp.* 账户段(空白首启)」—— 2026-11-01 实测把一份完好的三账户档案
    // 误报成空白,排查方向被带偏整整一轮。错误就是错误,不许伪装成"没有"。
    let nss = match store.list_namespaces() {
        Ok(v) => v,
        Err(e) => {
            eprintln!("[ekapp] 档案目录列举失败: {e:?}");
            eprintln!("[ekapp] (这不是「没有账户」,是库读不开 —— 查 WAL/权限/路径)");
            std::process::exit(1);
        }
    };
    let mut shown = 0usize;
    for ns in &nss {
        if !ns.starts_with("ctp.") {
            continue;
        }
        shown += 1;
        match store.load(ns) {
            Ok(ek_cfg::LoadOutcome::Loaded { map, .. }) => {
                let p = |k: &str| map.get_plain(k).unwrap_or("—").to_string();
                let has_pw = map
                    .get_secret_key("td_password")
                    .is_some_and(|k| !k.is_empty());
                println!(
                    "{}\n  账号 {} @ broker {}\n  td = {}\n  md = {}\n  凭据 = {} | 收录 = {}",
                    ns,
                    p("td_user"),
                    p("broker_id"),
                    p("td_addr"),
                    p("md_addr"),
                    if has_pw {
                        "密库引用 ✓"
                    } else {
                        "缺密码引用"
                    },
                    p("product_classes"),
                );
            }
            Ok(_) => println!("{ns}(一段都读不出;该档恐已坏)"),
            Err(e) => println!("{ns}(读失败:{e:?})"),
        }
    }
    if shown == 0 {
        println!("[ekapp] 档案清点:无 ctp.* 账户段(空白首启)");
    }
}

// ═════════════════════════════ secret-check ═════════════════════════════

/// `ekapp secret-check <ns> [字段=td_password]` —— 档案密库引用自检。
/// 档案字段存的是**引用**(`secret://gw_td_password` 形态的 combo 键);
/// 本命令拿 combo 键去密库探**库值在/缺**,只报状态与长度,**永不打印值**(红线同 add-account)。
/// 用途:桌面报「库值缺」时,与 CLI 进程读数比对,定位是密库条目没了还是引用坏了。
fn cli_secret_check(rest: &[String]) -> Result<(), String> {
    use ek_cfg::secret;
    let Some(ns) = rest.first() else {
        return Err("用法: ekapp secret-check <ns> [字段=td_password]".to_string());
    };
    let field = rest.get(1).map(|s| s.as_str()).unwrap_or("td_password");
    let store = ek_cfg::Store::eco().map_err(|e| format!("配置根不可用: {e:?}"))?;
    let map = match store.load(ns) {
        Ok(ek_cfg::LoadOutcome::Loaded { map, .. }) => map,
        Ok(_) => return Err(format!("{ns}: 档案读不出(段坏/不存在)")),
        Err(e) => return Err(format!("{ns}: 档案读失败 {e:?}")),
    };
    let Some(combo) = map.get_secret_key(field) else {
        return Err(format!("{ns}.{field}: 无密库引用(明文键或键不存在)"));
    };
    println!("{ns}.{field} → 引用 combo 键 {combo:?}");
    match secret::get(ns, combo) {
        Some(v) => println!("库值:在(长度 {} 字节)", v.as_bytes().len()),
        None => println!("库值:缺(引用在,密库无条目 —— 编辑弹窗重输后『保存并连接』补录)"),
    }
    Ok(())
}

// ═════════════════════════════ tq-secret(天勤桥凭据,stdin 专用) ═════════════════════════════

/// `ekapp migrate-db` —— 一次性迁移(升级方案步骤 5):扫 eco 根内 `*.ini`
/// 各 ns + 旧秘面后端值 → 灌 `cfg.db` → 旧文件改名 `*.migrated`(**不删 =
/// 回滚路径**:去后缀改名还原 + 删 cfg.db 即回旧形)。幂等:二次运行 = 全跳过。
/// 回执只报计数与段名,**永不打印值**(红线同 tq-secret)。
/// 旧秘面后端 = 编译特征决定(keyring/env/fake);从 keyring 迁 = 本命令须
/// `--features secret-windows` 构建(同 secret 工具配方;存储层无特征开关)。
/// `ekapp init-master-key` —— 生成主密钥(步骤 6):`${config_dir}/ek/master.key`,
/// 32B 随机(**已存在 = 拒,不覆盖**)。回执只报路径与长度,**永不打印密钥**。
fn cli_init_master_key() -> Result<(), String> {
    let store = ek_cfg::Store::eco().map_err(|e| format!("配置根不可用: {e:?}"))?;
    let (_mk, path) = ek_cfg::MasterKey::write_new(store.root())
        .map_err(|e| format!("生成主密钥显形: {e}"))?;
    println!(
        "init-master-key 完成:{} ({}B,弱钥校验通过;值不回显、不进日志)。\n\
         纪律:该文件是**唯一门槛** —— 丢了 = 库里秘值不可解(需重灌口令);\n\
         备份建议:离线介质另存,勿入 git(仓内禁 *.key / cfg.db 已由闸口守)。",
        path.display(),
        ek_cfg::crypto::MASTER_KEY_LEN
    );
    Ok(())
}

/// `ekapp encrypt-blobs` —— 步骤 5 明文遗留就地加密(幂等;已密文行跳过)。
/// 回执只报计数,**永不打印值**。
/// `ekapp rekey-secrets` —— 主钥轮换/事故修复:把「旧钥封装」的密文就地重封为当前主钥。
///
/// 旧钥从环境变量 `EK_OLD_MASTER_KEY`(64 位 hex)读,**不落盘、不回显**。
/// 只在内存里解一次、立刻用当前主钥重封;输出只有条数。
fn cli_rekey_secrets() -> Result<(), String> {
    use ek_cfg::crypto::MasterKey;
    let hex = std::env::var("EK_OLD_MASTER_KEY")
        .map_err(|_| "缺 EK_OLD_MASTER_KEY(64 位 hex 旧主钥)".to_string())?;
    let raw = (0..hex.len() / 2)
        .map(|i| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16))
        .collect::<Result<Vec<u8>, _>>()
        .map_err(|_| "EK_OLD_MASTER_KEY 不是合法 hex".to_string())?;
    let old = MasterKey::from_bytes(&raw).map_err(|e| format!("旧主钥非法: {e}"))?;
    let root = ek_cfg::Store::default_root().ok_or("取不到配置根(APPDATA/ek 缺席)")?;
    let (total, fixed) = ek_cfg::cfg_rekey_blobs(&root, &old).map_err(|e| e.to_string())?;
    println!("rekey: 共 {total} 条密文,重封 {fixed} 条");
    Ok(())
}
fn cli_encrypt_blobs() -> Result<(), String> {
    let store = ek_cfg::Store::eco().map_err(|e| format!("配置根不可用: {e:?}"))?;
    let (done, skipped) = ek_cfg::cfg_encrypt_blobs(store.root())
        .map_err(|e| format!("加密显形: {e}"))?;
    println!(
        "encrypt-blobs 完成:新加密 {done} 条,已是密文跳过 {skipped} 条。\n\
         库 = {}(段标记 nsblob.<ns>=enc)",
        store.db_path().display()
    );
    Ok(())
}

fn cli_migrate_db() -> Result<(), String> {
    let store = ek_cfg::Store::eco().map_err(|e| format!("配置根不可用: {e:?}"))?;
    let rep = ek_cfg::migrate_file_to_db(store.root()).map_err(|e| format!("迁移显形: {e}"))?;
    println!(
        "migrate-db 完成:灌段 {}(跳过 {}),秘值入 blob {},引用缺值 {},中间态改名 {}。\n\
         旧档存档 = 根内 *.migrated(回滚 = 去后缀改名还原 + 删 cfg.db)。\
         段名:{}",
        rep.ns_done.len(),
        rep.ns_skipped.len(),
        rep.secrets_done,
        rep.secrets_missing,
        rep.aux_renamed,
        if rep.ns_done.is_empty() {
            "—".to_string()
        } else {
            rep.ns_done.join(", ")
        }
    );
    Ok(())
}

/// 读一行 stdin(EOF = None)。口令永不进 argv、永不回显(红线同 add-account)。
fn read_stdin_line() -> Option<String> {
    use std::io::BufRead;
    let mut s = String::new();
    let n = std::io::stdin()
        .lock()
        .read_line(&mut s)
        .ok()
        .filter(|n| *n > 0)?;
    let _ = n;
    Some(s)
}

/// `ekapp tq-secret` —— 天勤桥(tq)凭据入库 + 档案段落引用行。
///
/// 形貌与账户面同款:值进 OS 密库(条目名 `ek/tq/user`、`ek/tq/password`,
/// ek-cfg secret 单源),档案 `tq.ini` 只写引用行 `secret://ek/tq/<key>`;
/// tq 插件开机从密库解密灌桥(`plugin.rs` spawn_engine 的 user/password 分支)。
/// 两行依次从 stdin 读:第一行 user,第二行 password(EOF 即止,绝不打印)。
fn cli_tq_secret(rest: &[String]) -> Result<(), String> {
    // --check = 只看形状不落库(排障用:确认 stdin 有没有喂到、有没有被转码)。
    let dry = rest.iter().any(|a| a == "--check");
    use ek_cfg::Store;
    use ek_cfg::secret::{self, SecretValue};
    use ek_core::section::{Field, SectionMap};

    println!("输入天勤 user(回车):");
    let user = read_stdin_line()
        .ok_or("stdin 缺 user 行")?
        .trim()
        .to_string();
    println!("输入天勤 password(回车,不回显):");
    // 口令**不裁剪首尾空白**(空白是口令语义的一部分);只去掉行结束符。
    let pass = read_stdin_line()
        .ok_or("stdin 缺 password 行")?
        .trim_end_matches(['\r', '\n'])
        .to_string();
    if user.is_empty() || pass.is_empty() {
        return Err("user/password 有空行,拒收".to_string());
    }
    if dry {
        for (tag, v) in [("user", user.as_str()), ("password", pass.as_str())] {
            let shape: String = v
                .chars()
                .map(|c| {
                    if !c.is_ascii() {
                        '?'
                    } else if c.is_ascii_alphabetic() {
                        'A'
                    } else if c.is_ascii_digit() {
                        'D'
                    } else if c == ' ' {
                        '_'
                    } else {
                        '.'
                    }
                })
                .collect();
            println!("[check] {tag}: 长度={} 每位={shape}", v.chars().count());
        }
        println!("[check] 未写库(--check 模式)");
        return Ok(());
    }
    // 用户名必须是 ASCII(天勤账号 = 手机号/账号名)。含汉字/全角 = 输入法污染,
    // 那种值一定过不了认证(实撞:首字符被输入法吃成汉字 → 403 invalid_grant),
    // 这里**当场拒收**,免得把错值灌进库再让人反复试。
    if let Some((pos, ch)) = user
        .char_indices()
        .find(|(_, c)| !c.is_ascii() || c.is_whitespace())
    {
        return Err(format!(
            "用户名第 {} 个字符不是 ASCII(是「{}」,疑似输入法/全角);天勤账号应为字母数字(手机号或账号名),拒收",
            user[..pos].chars().count() + 1,
            if ch.is_whitespace() { "空白" } else { "非 ASCII 字符" }
        ));
    }
    secret::register(
        "tq",
        "user",
        SecretValue::from_bytes(user.as_bytes().to_vec()),
    )
    .map_err(|e| format!("密库写入 user 失败:{e}"))?;
    secret::register(
        "tq",
        "password",
        SecretValue::from_bytes(pass.as_bytes().to_vec()),
    )
    .map_err(|e| format!("密库写入 password 失败:{e}"))?;
    let store = Store::eco().map_err(|e| format!("档案托底根不可用:{e}"))?;
    // 两键都是引用形(Field::secret):tq 插件 apply_config 记 (field,key) →
    // spawn_engine 按 (user,password) 解密灌桥;明文永不在档案里出现。
    let map = SectionMap::new(1)
        .set("user", Field::secret("user"))
        .set("password", Field::secret("password"));
    store
        .save("tq", &map)
        .map_err(|e| format!("tq 段写入失败:{e}"))?;
    // 回执只报在场与引用,绝不报值。
    println!(
        "tq 凭据已入密库:user={} password={};存储 = {}(两条引用行)",
        secret::get("tq", "user").is_some(),
        secret::get("tq", "password").is_some(),
        store.db_path().display()
    );
    Ok(())
}