dsh-cnb-assist 1.0.1 → 1.0.3

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,99 +1,101 @@
1
- # CNB 反代辅助(DSH 插件)
1
+ # CNB 反代辅助
2
2
 
3
- 把 `cnb-assist-wpf` 那套已经跑通的 CNB 逻辑搬进 DeepSeek Harness 现在的 IDE 里。
4
- 令牌留在 Host 进程,浏览器半身只拿算好的数字;写配置只替换 `baseURL` 那一个字符串。
3
+ DeepSeek Harness(DSH)插件。把 CNB 的额度、AI 用量和运行中容器的反代地址放进 IDE 侧栏,
4
+ 并提供「一键写回模型地址」和「四步重启容器」。
5
5
 
6
- 包名:`dsh-cnb-assist` · 当前版本 `1.0.1`
6
+ - **令牌不出 Host 进程** —— 浏览器那半只拿到算好的数字。
7
+ - **写配置只改一个字符串** —— 只替换模型 `baseURL` 里那条 `*.cnb.run` 地址,不动整份 YAML。
8
+ - **零运行时依赖** —— 纯 ESM 源码,装上就能用。
7
9
 
8
- ## 装法
10
+ ---
9
11
 
10
- ### 方式 A:从 npm 装(推荐,换机器最省事)
12
+ ## 装
11
13
 
12
- ```bash
14
+ 在 DSH 里打开插件管理:
15
+
16
+ ```
13
17
  plugin_manager → install_bundle → target: dsh-cnb-assist
14
- # 或指定版本:
15
- plugin_manager → install_bundle → target: dsh-cnb-assist@1.0.1
16
18
  ```
17
19
 
18
- DSH 的插件管理用 pnpm 去 registry 拉包,目标机器能访问 npm 即可。
20
+ 要固定版本就写 `dsh-cnb-assist@1.0.2`。
19
21
 
20
- ### 方式 B:离线 tgz
22
+ 离线安装也行,把 `dsh-cnb-assist-*.tgz` 拷到目标机器后填该文件的绝对路径。
23
+ 安装器认**目录**和 **`.tgz`/`.tar.gz`**,`.zip` 不被接受。
21
24
 
22
- **安装器只认目录或 `.tgz`/`.tar.gz`,不认 `.zip`**(`.zip` 会被当成目录去读 `package.json`,
23
- 所以会报"该路径不存在或不是有效的插件包")。
25
+ 装完**重启一次 dsh**(Host 层代码不会热更新),然后刷新页面。
24
26
 
25
- ```bash
26
- npm pack dsh-cnb-assist # 得到 dsh-cnb-assist-1.0.1.tgz
27
- plugin_manager → install_bundle → target: 该 tgz 的绝对路径
28
- ```
27
+ ---
29
28
 
30
- ### 方式 C:本机源码目录(改代码时用)
29
+ ## 用
31
30
 
32
- ```bash
33
- plugin_manager → install_bundle → target: 本目录的绝对路径
34
- # 示例:C:\Users\LMUD\Desktop\cnb2api\dsh-cnb-assist
35
- # 用 link: 安装,改完代码只刷新页面即可(Host 半身改动才需要重启 dsh)
36
- ```
31
+ ### 1. 填令牌
37
32
 
38
- ### 方式 B:另一台电脑(推荐)
33
+ `设置 → CNB 反代辅助`,只有两项:
39
34
 
40
- **安装器只认目录或 `.tgz`/`.tar.gz`,不认 `.zip`**(`.zip` 会被当成目录去读 `package.json`,
41
- 所以会报"该路径不存在或不是有效的插件包")。
35
+ | 项 | 说明 |
36
+ |---|---|
37
+ | **CNB 令牌** | CNB 的 access token,至少要有容器与构建日志的读权限 |
38
+ | **模型 provider** | 写回地址时要改哪个 provider,默认 `cnb`,需与配置里的键名一致 |
42
39
 
43
- ```bash
44
- # 把 dsh-cnb-assist-1.0.1.tgz 拷过去,然后在 DSH 里:
45
- plugin_manager → install_bundle → target: 该 tgz 的绝对路径
46
- # 也可以直接填解压后的目录绝对路径(目录里要有 package.json)
47
- ```
40
+ 令牌也可以不填,插件会按顺序兜底查找:
41
+
42
+ 1. 插件设置里的 `config.token`
43
+ 2. `%APPDATA%\CnbAssist\settings.json`(桌面版辅助程序存过的那份)
44
+ 3. 环境变量 `CNB_API_KEY`
48
45
 
49
- > 版本号要在两台机器上对得起来。`files` 字段只列真实存在的条目(`locale` 写成目录而不是
50
- > `locale/*.json`),否则 `pnpm pack` 的 `scandir` 会直接报 ENOENT。
46
+ 令牌与地址**不随包分发**,每台机器各自配置。
51
47
 
52
- 本包是纯 ESM 源码、零运行时依赖,除 `@deepseek-ai/schemastery`(从 dsh 安装里解析,不需要
53
- 随包分发)之外不 import 任何第三方包,所以不存在"换台机器装不上"的 node_modules 问题。
48
+ ### 2. 打开面板
54
49
 
55
- **装完重启一次 dsh**:`cordis.patch.yml` 属于 Host 层,HMR 不会重建 Host 模块。
50
+ 点左侧栏底部的橘黄叶片图标,面板从上到下是四块:
56
51
 
57
- ## 落点
52
+ - **运行中容器** —— 反代地址,可复制、可四步重启、可一键写回模型地址
53
+ - **当前配置** —— 从本 profile 的 `cordis.patch.yml` 里读出来的现有地址,标了行号和 provider
54
+ - **额度** —— 每个组织的月额度 / 已用 / 预冻结,带进度条
55
+ - **本月按组织** —— 本月创建的容器按组织汇总的 credits 与 token 用量
58
56
 
59
- | 位置 | 说明 |
57
+ ### 3. 写回模型地址
58
+
59
+ 容器那一行点「设为模型地址」,插件把 `https://<business_id>-9001.cnb.run` 写进
60
+ 设置里那个 provider 的 `baseURL`。写前会留 `.bak`,只替换地址字符串本身。
61
+
62
+ ---
63
+
64
+ ## 面板之外
65
+
66
+ 模型工具 `cnb_assist` 始终可用,四个动作:
67
+
68
+ | 动作 | 作用 |
60
69
  |---|---|
61
- | 左侧栏底部按钮 + 居中浮层 | 主入口(橘黄叶片图标) |
62
- | 设置 →「CNB 反代辅助」 | 只两项:CNB 令牌、模型 provider |
63
- | 模型工具 `cnb_assist` | 始终注册:`snapshot` / `config` / `restart` / `apply` |
70
+ | `snapshot` | 额度 / 用量 / 容器 / 当前配置,一次拿全 |
71
+ | `config` | 读配置,或从中解析出现有反代地址 |
72
+ | `restart` | 走 stop → 等停稳 → start → 等新地址 |
73
+ | `apply` | 把地址写进指定 provider |
74
+
75
+ ---
76
+
77
+ ## 常见问题
64
78
 
65
- > 右侧栏页签已移除:与左侧按钮重复,插件也不再依赖 `dsh-better-sidebar`。
79
+ **面板说「未配置令牌」** —— 去设置里填,或把 `CNB_API_KEY` 设成环境变量后重启 dsh。
66
80
 
67
- ## 换到新机器后要做的事
81
+ **额度是空的** —— 令牌权限不够。读容器列表要 `account-engage:r`,读构建日志要
82
+ `repo-cnb-history:r`。
68
83
 
69
- 1. 装好后刷新页面,点左侧栏底部的橘黄叶片
70
- 2. 设置 →「CNB 反代辅助」→ 填 **CNB 令牌**(或让它读 `CNB_API_KEY` / 旧的
71
- `%APPDATA%\CnbAssist\settings.json`)
72
- 3. 确认 **模型 provider** 与 `llm-pi-ai` 行里的键名一致(默认 `cnb`)
73
- 4. 面板里点容器那一行的「设为模型地址」,把反代地址写进该 provider 的 `baseURL`
84
+ **本月合计比网页上少** —— 已删除的容器查不到归属,会被丢弃,这是预期行为。
74
85
 
75
- 令牌与地址**不会**随包带走,每台机器各自配置。
86
+ **重启后地址变了** —— 正常。重启会重建容器,`business_id` 变,地址跟着变。
76
87
 
77
- ## 面板有什么
88
+ **改完配置没生效** —— `cordis.patch.yml` 属于 Host 层,要重启 dsh,刷新页面不够。
78
89
 
79
- - **额度**:每个组织的月额度 / 已用 / 预冻结,带进度条。`credit_in_milli` 一律 ÷1000;
80
- 特权用四重判定(`total<=0 || bonus<=0 || 已过期 || bonus>total` 即视为无)。
81
- - **本月按组织**:只统计本地时区本月创建的容器,归属只认构建日志的 `groupName`,
82
- 查不到的容器直接丢弃(不拿 slug 兜底)。
83
- - **运行中容器**:反代地址(`business_id` + `-9001` 优先,解析不到就从阶段日志里抠),
84
- 复制、四步重启、一键写回模型 `baseURL`。
85
- - **配置里现有的地址**:从本 profile 的 `cordis.patch.yml` 里正则读出来,标行号与 provider。
90
+ ---
86
91
 
87
- ## 令牌从哪来
92
+ ## 兼容性
88
93
 
89
- 1. 设置页 / 插件行 `config.token`
90
- 2. `%APPDATA%\CnbAssist\settings.json`(桌面版辅助程序已经存过的那一份,不用再抄一遍)
91
- 3. `CNB_API_KEY` 环境变量
94
+ - Node.js ≥ 20
95
+ - 只依赖宿主提供的 `@deepseek-ai/dsh-client-ui-slots` 与 `@deepseek-ai/dsh-client-locale`,
96
+ 无需额外安装任何包
97
+ - 样式只用 DSH 主题变量,跟随亮色 / 暗色
92
98
 
93
- ## 关键约定(来自 cnb-assist-wpf/AGENTS.md)
99
+ ## 许可
94
100
 
95
- - 所有 CNB 请求必须带单值 `Accept: application/vnd.cnb.api+json`,多值会 406。
96
- - 组织归属只认 `build/logs` 的 `groupName`,查不到就丢弃。
97
- - 「本月」按本机时区取 1 号 00:00。
98
- - 重启必须四步:`stop → 等非 running → start → 等新地址`;`start` 的 `branch` 必填。
99
- - 写配置绝不重写整份 YAML:只替换匹配到的 `-9001.cnb.run` 字符串,写前留 `.bak`。
101
+ MIT
package/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-cnb-assist — Client half.
2
+ * @local/dsh-cnb-assist — Client half.
3
3
  *
4
4
  * 三个落点,按有没有装 dsh-better-sidebar 自动选:
5
5
  * 1. better-sidebar 在 → 右侧栏页签「CNB」(页签 = 一个组件,数据经 host.call 从 Host 半身取)
@@ -10,13 +10,13 @@
10
10
  * 样式只用 --dsw-alias-* 主题令牌,亮色/暗色跟随宿主。
11
11
  */
12
12
  window.__ModuleLoader__.load({
13
- id: 'dsh-cnb-assist',
13
+ id: '@local/dsh-cnb-assist',
14
14
  factory(require) {
15
15
  const React = require('react')
16
16
  const h = React.createElement
17
17
  const { useState, useEffect, useMemo, useRef, useSyncExternalStore, createContext, useContext } = React
18
18
 
19
- const PLUGIN_ID = 'dsh-cnb-assist'
19
+ const PLUGIN_ID = '@local/dsh-cnb-assist'
20
20
  const ROUTE = 'cnb-assist/api'
21
21
  const ASSIST_KEY = 'dsh-cnb-assist'
22
22
  /** 本插件在宿主 locale 注册表里的命名空间(也是 slot 项的 locale 标记)。 */
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-cnb-assist — Host half.
2
+ * @local/dsh-cnb-assist — Host half.
3
3
  *
4
4
  * 把 cnb-assist-wpf/core 里那套已经跑通的 CNB 逻辑搬进 DSH:
5
5
  * Api.cs → 这里的 callCnb / 各 endpoint
@@ -29,6 +29,17 @@ export const inject = ['webServer', 'tools']
29
29
  /** CNB 地址固定的反代端口(用户已确认固定,见 AGENTS.md §7)。 */
30
30
  const RELAY_PORT = 9001
31
31
 
32
+ /**
33
+ * 阶段日志里平台注入的地址模板,形如:
34
+ * PROXY_URI=https://n2lc6c2bdm-{{port}}.cnb.run
35
+ * ★ {{port}} 是字面量双大括号,不是模板字符串语法。
36
+ * ★ 这一行有时会和后面的输出粘在同一行尾(`... ===== register skipped`),必须按空白截断。
37
+ */
38
+ const PROXY_URI_RX = /PROXY_URI\s*=\s*([^\s"'`,;]+)/
39
+
40
+ /** 把地址模板里的 {{port}} 换成真实端口。 */
41
+ const PORT_PLACEHOLDER_RX = /\{\{\s*port\s*\}\}/gi
42
+
32
43
  /** 浏览器半身投递的路由。 */
33
44
  const ROUTE = '/cnb-assist/api'
34
45
 
@@ -542,15 +553,47 @@ async function buildMonthSummary(call) {
542
553
 
543
554
  // ───────────────────────────── 反代地址 ─────────────────────────────
544
555
 
545
- /** business_id + 固定端口拼地址(最后一级兜底)。 */
556
+ /** business_id + 固定端口拼地址(兜底之一)。 */
546
557
  function relayFromBusinessId(businessId) {
547
558
  const id = String(businessId ?? '').trim()
548
559
  return id === '' ? null : `https://${id}-${RELAY_PORT}.cnb.run/v1`
549
560
  }
550
561
 
562
+ /**
563
+ * 把地址里的 {{port}} 占位符换成真实端口,再补成可用的 /v1 地址。
564
+ * 已经是成品地址的(没有占位符)原样保留,只补 /v1。
565
+ */
566
+ function materializeRelayUrl(raw) {
567
+ const text = String(raw ?? '').trim()
568
+ if (text === '') return null
569
+ let url = text.replace(PORT_PLACEHOLDER_RX, String(RELAY_PORT))
570
+ let parsed
571
+ try {
572
+ parsed = new URL(url)
573
+ } catch {
574
+ return null
575
+ }
576
+ if (!parsed.host.toLowerCase().endsWith('.cnb.run')) return null
577
+ const path = parsed.pathname.replace(/\/+$/, '')
578
+ parsed.pathname = path.toLowerCase().endsWith('/v1') ? path : `${path}/v1`
579
+ return parsed.toString()
580
+ }
581
+
582
+ /**
583
+ * 从阶段日志里取 PROXY_URI 模板。
584
+ * ★ 这是最权威的来源:容器重建后 business_id 由平台重新分配,模板里的就是新值。
585
+ */
586
+ function relayFromProxyUri(text) {
587
+ if (typeof text !== 'string' || text === '') return null
588
+ const match = PROXY_URI_RX.exec(text)
589
+ return match === null ? null : materializeRelayUrl(match[1])
590
+ }
591
+
551
592
  /**
552
593
  * 从阶段日志里抠地址,带打分:来自该 business_id 的 > 路径以 /v1 结尾的 >
553
594
  * 端口是 9001 的。找不到 cnb.run 就返回 null。
595
+ * ★ new URL() 能"接受" {{port}}(它把整段当主机名),所以旧写法不会报错,
596
+ * 只会把没替换占位符的字符串原样展示出去。必须先 materialize 再判分。
554
597
  */
555
598
  function extractRelayAddress(text, businessId = '') {
556
599
  if (typeof text !== 'string' || text === '') return null
@@ -561,9 +604,11 @@ function extractRelayAddress(text, businessId = '') {
561
604
  const raw = match[0].replace(/[.,。;]+$/, '')
562
605
  if (raw.length <= 12) continue
563
606
  if (!raw.includes('.cnb.run')) continue
607
+ const normalized = materializeRelayUrl(raw)
608
+ if (normalized === null) continue
564
609
  let url
565
610
  try {
566
- url = new URL(raw)
611
+ url = new URL(normalized)
567
612
  } catch {
568
613
  continue
569
614
  }
@@ -571,17 +616,18 @@ function extractRelayAddress(text, businessId = '') {
571
616
  let score = 1
572
617
  if (businessId !== '' && host.startsWith(`${businessId.toLowerCase()}-`)) score += 10
573
618
  if (url.pathname.replace(/\/+$/, '').toLowerCase().endsWith('/v1')) score += 4
574
- if (url.port === String(RELAY_PORT) || host.endsWith(`-${RELAY_PORT}.cnb.run`)) score += 3
619
+ if (host.endsWith(`-${RELAY_PORT}.cnb.run`)) score += 3
620
+ if (PORT_PLACEHOLDER_RX.test(raw)) score += 5
621
+ PORT_PLACEHOLDER_RX.lastIndex = 0
575
622
  if (score > bestScore) {
576
623
  bestScore = score
577
- best = raw
624
+ best = normalized
578
625
  }
579
626
  }
580
627
  return bestScore > 0 ? best : null
581
628
  }
582
629
 
583
630
  async function resolveRelayAddress(call, slug, sn, pipelineId) {
584
- const businessRelay = (item) => relayFromBusinessId(item?.business_id)
585
631
  if (slug === '' || sn === '') return null
586
632
  const status = await call(`/${enc(slug)}/-/build/status/${enc(sn)}`)
587
633
  const stageIds = []
@@ -595,18 +641,25 @@ async function resolveRelayAddress(call, slug, sn, pipelineId) {
595
641
  }
596
642
  // stage-0 优先(AGENTS.md §7.3:不同仓库可能没有这个阶段名,所以逐阶段扫)。
597
643
  stageIds.sort((a, b) => (a === 'stage-0' ? 0 : 1) - (b === 'stage-0' ? 0 : 1))
644
+
645
+ // 先找 PROXY_URI 那一行(权威来源),扫不到再退回通用打分。两轮都扫同一批日志。
646
+ let scanned = ''
598
647
  for (const stage of stageIds) {
599
648
  const log = await call(
600
649
  `/${enc(slug)}/-/build/logs/stage/${enc(sn)}/${enc(pipelineId)}/${enc(stage)}`,
601
650
  )
602
651
  if (!log.ok) continue
603
652
  const content = Array.isArray(log.json?.content) ? log.json.content.join('\n') : ''
604
- const found = extractRelayAddress(content, '')
605
- if (found !== null) return found
653
+ scanned += `${content}\n`
654
+ const fromTemplate = relayFromProxyUri(content)
655
+ if (fromTemplate !== null) return fromTemplate
606
656
  }
657
+ const fromLog = extractRelayAddress(scanned, '')
658
+ if (fromLog !== null) return fromLog
659
+
607
660
  const listed = await listWorkspaces(call)
608
661
  const me = listed.items.find(item => String(item?.sn ?? '') === sn)
609
- return businessRelay(me)
662
+ return relayFromBusinessId(me?.business_id)
610
663
  }
611
664
 
612
665
  /** 运行中容器一行:地址还没解析出来时先给 business_id 兜底值。 */
@@ -622,13 +675,19 @@ async function buildRelayRows(call) {
622
675
  const slug = String(item?.slug ?? '')
623
676
  const pipelineId = String(item?.pipeline_id ?? '')
624
677
  const businessRelay = relayFromBusinessId(item?.business_id)
625
- let address = businessRelay
678
+ // ★ 两路都要试:日志里的 PROXY_URI 模板是平台重建后注入的新值,比 business_id 新。
679
+ // 先查日志,查不到才退回 business_id 拼的地址。
680
+ let address = null
681
+ let source = 'none'
682
+ try {
683
+ address = await resolveRelayAddress(call, slug, sn, pipelineId)
684
+ if (address !== null) source = 'log'
685
+ } catch {
686
+ address = null
687
+ }
626
688
  if (address === null) {
627
- try {
628
- address = await resolveRelayAddress(call, slug, sn, pipelineId)
629
- } catch {
630
- address = null
631
- }
689
+ address = businessRelay
690
+ if (address !== null) source = 'businessId'
632
691
  }
633
692
  const created = parseTime(item?.create_time)
634
693
  const seconds = created === null ? Number(item?.duration) || 0 : (Date.now() - created) / 1000
@@ -641,6 +700,7 @@ async function buildRelayRows(call) {
641
700
  pipelineId,
642
701
  businessId: String(item?.business_id ?? ''),
643
702
  address,
703
+ addressSource: source,
644
704
  createdAt: created,
645
705
  runningSeconds: Math.max(0, seconds),
646
706
  text: {
@@ -734,8 +794,13 @@ async function runRestart(call, input, settings) {
734
794
  const listed = await listWorkspaces(call)
735
795
  const me = listed.items.find(item => String(item?.sn ?? '') === newSn)
736
796
  if (me === undefined) continue
737
- address = relayFromBusinessId(me?.business_id)
738
- ?? await resolveRelayAddress(call, String(me?.slug ?? slug), newSn, String(me?.pipeline_id ?? ''))
797
+ let fromLog = null
798
+ try {
799
+ fromLog = await resolveRelayAddress(call, String(me?.slug ?? slug), newSn, String(me?.pipeline_id ?? ''))
800
+ } catch {
801
+ fromLog = null
802
+ }
803
+ address = fromLog ?? relayFromBusinessId(me?.business_id)
739
804
  if (address !== null) break
740
805
  }
741
806
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-cnb-assist",
3
- "version": "1.0.1",
4
- "description": "CNB 反代辅助(DSH 插件):各组织额度、本月 AI 用量、运行中容器反代地址,一键写回 DSH 模型配置",
3
+ "version": "1.0.3",
4
+ "description": "CNB 反代辅助(DSH 插件):查看各组织额度与本月 AI 用量,管理运行中容器的反代地址,一键写回模型 baseURL",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
@@ -17,7 +17,8 @@
17
17
  "icon.svg",
18
18
  "locale",
19
19
  "README.md",
20
- "selfcheck.mjs"
20
+ "selfcheck.mjs",
21
+ "selfcheck-relay.mjs"
21
22
  ],
22
23
  "icon": "./icon.svg",
23
24
  "keywords": [
@@ -38,7 +39,9 @@
38
39
  "node": ">=20"
39
40
  },
40
41
  "dsh": {
41
- "bundle": { "patch": "./cordis.patch.yml" },
42
+ "bundle": {
43
+ "patch": "./cordis.patch.yml"
44
+ },
42
45
  "client": {
43
46
  "platform": "web",
44
47
  "immediately": true,
@@ -0,0 +1,127 @@
1
+ /**
2
+ * 反代地址规则的离线自检:不联网,纯字符串。
3
+ * 覆盖两种来源(PROXY_URI 模板 / business_id 拼)、占位符替换、以及旧版会漏掉的情形。
4
+ */
5
+ import assert from 'node:assert/strict'
6
+
7
+ const RELAY_PORT = 9001
8
+ const PROXY_URI_RX = /PROXY_URI\s*=\s*([^\s"'`,;]+)/
9
+ const PORT_PLACEHOLDER_RX = /\{\{\s*port\s*\}\}/gi
10
+
11
+ function materializeRelayUrl(raw) {
12
+ const text = String(raw ?? '').trim()
13
+ if (text === '') return null
14
+ let url = text.replace(PORT_PLACEHOLDER_RX, String(RELAY_PORT))
15
+ let parsed
16
+ try {
17
+ parsed = new URL(url)
18
+ } catch {
19
+ return null
20
+ }
21
+ if (!parsed.host.toLowerCase().endsWith('.cnb.run')) return null
22
+ const path = parsed.pathname.replace(/\/+$/, '')
23
+ parsed.pathname = path.toLowerCase().endsWith('/v1') ? path : `${path}/v1`
24
+ return parsed.toString()
25
+ }
26
+
27
+ function relayFromProxyUri(text) {
28
+ if (typeof text !== 'string' || text === '') return null
29
+ const match = PROXY_URI_RX.exec(text)
30
+ return match === null ? null : materializeRelayUrl(match[1])
31
+ }
32
+
33
+ function relayFromBusinessId(businessId) {
34
+ const id = String(businessId ?? '').trim()
35
+ return id === '' ? null : `https://${id}-${RELAY_PORT}.cnb.run/v1`
36
+ }
37
+
38
+ let pass = 0
39
+ function ok(name, cond) {
40
+ assert.ok(cond, name)
41
+ pass += 1
42
+ console.log('PASS ', name)
43
+ }
44
+ function eq(name, actual, expected) {
45
+ assert.equal(actual, expected, `${name}\n actual : ${actual}\n expected: ${expected}`)
46
+ pass += 1
47
+ console.log('PASS ', name)
48
+ }
49
+
50
+ // ── 1. 真实日志原样(今天从 cnb-ps0-1k77t7qst 抓的)
51
+ const REAL = [
52
+ 'Runner[10.235.16.12][docker] 2026-11-12 20:55:52 $',
53
+ '===== proxy self-check: health OK =====',
54
+ '{"status":"ok","uptime_sec":1}',
55
+ 'PROXY_URI=https://n2lc6c2bdm-{{port}}.cnb.run',
56
+ '===== register skipped (REGISTER_URL / REG_TOKEN / PROXY_URI missing) =====',
57
+ ].join('\n')
58
+
59
+ eq('真实日志 → 模板地址',
60
+ relayFromProxyUri(REAL),
61
+ 'https://n2lc6c2bdm-9001.cnb.run/v1')
62
+
63
+ // ── 2. 占位符替换本身
64
+ eq('{{port}} 换成 9001',
65
+ materializeRelayUrl('https://n2lc6c2bdm-{{port}}.cnb.run'),
66
+ 'https://n2lc6c2bdm-9001.cnb.run/v1')
67
+ eq('带空格 {{ port }} 也认',
68
+ materializeRelayUrl('https://abc-{{ port }}.cnb.run'),
69
+ 'https://abc-9001.cnb.run/v1')
70
+ eq('大写 {{PORT}} 也认',
71
+ materializeRelayUrl('https://abc-{{PORT}}.cnb.run'),
72
+ 'https://abc-9001.cnb.run/v1')
73
+
74
+ // ── 3. 已经是成品地址的不该被破坏
75
+ eq('成品地址原样保留',
76
+ materializeRelayUrl('https://n2lc6c2bdm-9001.cnb.run/v1'),
77
+ 'https://n2lc6c2bdm-9001.cnb.run/v1')
78
+ eq('成品地址缺 /v1 要补',
79
+ materializeRelayUrl('https://n2lc6c2bdm-9001.cnb.run'),
80
+ 'https://n2lc6c2bdm-9001.cnb.run/v1')
81
+
82
+ // ── 4. 粘在行尾的情形(真实日志里出现过)
83
+ eq('PROXY_URI 后面粘了别的输出',
84
+ relayFromProxyUri('PROXY_URI=https://n2lc6c2bdm-{{port}}.cnb.run ===== register skipped ====='),
85
+ 'https://n2lc6c2bdm-9001.cnb.run/v1')
86
+
87
+ // ── 5. 多行里夹杂其它 URL,只认 PROXY_URI 那行
88
+ eq('只认 PROXY_URI 那行',
89
+ relayFromProxyUri('see https://example.com/x\nPROXY_URI=https://zz9-{{port}}.cnb.run\nmore https://other.cnb.run/y'),
90
+ 'https://zz9-9001.cnb.run/v1')
91
+
92
+ // ── 6. 没有模板行 → null,交给 business_id 兜底
93
+ eq('无模板行返回 null', relayFromProxyUri('nothing here'), null)
94
+ eq('空串返回 null', relayFromProxyUri(''), null)
95
+ eq('非字符串返回 null', relayFromProxyUri(null), null)
96
+
97
+ // ── 7. business_id 兜底
98
+ eq('business_id 拼地址',
99
+ relayFromBusinessId('n2lc6c2bdm'),
100
+ 'https://n2lc6c2bdm-9001.cnb.run/v1')
101
+ eq('business_id 为空返回 null', relayFromBusinessId(''), null)
102
+ eq('business_id 是 undefined 返回 null', relayFromBusinessId(undefined), null)
103
+
104
+ // ── 8. 两种来源必须同址(本容器实测)
105
+ eq('模板与 business_id 结果一致',
106
+ relayFromProxyUri(REAL),
107
+ relayFromBusinessId('n2lc6c2bdm'))
108
+
109
+ // ── 9. 旧版行为回归:模板行不会被 new URL() 拒绝(它把 {{port}} 当主机名的一部分),
110
+ // 但旧代码会以"未替换占位符"的形态胜出并展示出去,且拿不到端口那 3 分。
111
+ // ★ 这一点很容易误判成"旧代码会抛异常"——实测不会。
112
+ const oldParsed = new URL('https://n2lc6c2bdm-{{port}}.cnb.run')
113
+ ok('模板能被 new URL() 解析(所以旧代码不是抛异常,是错在别处)',
114
+ oldParsed.host === 'n2lc6c2bdm-{{port}}.cnb.run')
115
+ ok('旧代码的端口判分条件不成立(拿不到 +3 分)',
116
+ oldParsed.host.toLowerCase().endsWith(`-${RELAY_PORT}.cnb.run`) === false)
117
+ ok('旧代码会原样输出未替换的占位符',
118
+ oldParsed.toString().includes('{{port}}'))
119
+ ok('新代码输出已替换的成品地址',
120
+ relayFromProxyUri(REAL) === 'https://n2lc6c2bdm-9001.cnb.run/v1')
121
+ ok('新代码输出不再含占位符',
122
+ !relayFromProxyUri(REAL).includes('{{'))
123
+
124
+ // ── 10. 非 cnb.run 域名不该被当成反代地址
125
+ eq('非 cnb.run 返回 null', materializeRelayUrl('https://evil.example.com/{{port}}'), null)
126
+
127
+ console.log(`\nall ${pass} checks passed`)