aaden-cli 1.6.98 → 1.6.102

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.
@@ -70,7 +70,7 @@ network:
70
70
  gateway4: 192.168.121.1
71
71
  nameservers:
72
72
  addresses:
73
- - 192.168.121.1
73
+ - 8.8.8.8
74
74
  EOF
75
75
 
76
76
  echo "已生成配置文件:$CONFIG_FILE"
@@ -26,10 +26,14 @@ echo "END Aaden Deploy CONFIG"
26
26
  echo "Innerken322." | sudo -S rm -rf /etc/cron.d/aaden-cli-upgrade
27
27
  echo "Innerken322." | sudo -S touch conf
28
28
  echo "Innerken322." | sudo -S chmod 777 conf
29
+ # root 的 cron 默认 PATH 只有 /usr/bin:/bin,不含 /usr/local/bin;auto-upgrade 及其
30
+ # 子脚本里的 aaden-cli 裸命令需要它(crontab 不支持 $PATH 展开,只能写完整值)。
31
+ echo "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" >>conf
29
32
  echo "55 4 * * * sudo npm install aaden-cli -g" >>conf
30
- echo "59 4 * * * /usr/local/bin/aaden-cli auto-upgrade" >>conf
33
+ # auto-upgrade 的输出重定向到日志:cron 默认只在有本地邮件时才留输出,很多设备查不到。
34
+ echo "59 4 * * * /usr/local/bin/aaden-cli auto-upgrade >> /var/log/aaden-auto-upgrade.log 2>&1" >>conf
31
35
  echo "@reboot sleep 30 && sudo npm install aaden-cli -g" >>conf
32
- echo "@reboot sleep 60 &&/usr/local/bin/aaden-cli auto-upgrade" >>conf
36
+ echo "@reboot sleep 60 && /usr/local/bin/aaden-cli auto-upgrade >> /var/log/aaden-auto-upgrade.log 2>&1" >>conf
33
37
  echo "Innerken322." | sudo -S crontab -u root -r
34
38
  echo "Innerken322." | sudo -S crontab -u root conf
35
39
  echo "Innerken322." | sudo -S rm -f conf
@@ -2,9 +2,12 @@
2
2
  # 轻量后端部署(light-update 的执行体)。
3
3
  # - composer.lock 门:远端 raw/PHP/composer.lock == 本机? 相同→code.zip(~1MB,保留vendor);不同→latest.zip全量
4
4
  # - **先下载校验,确认源存在后才做任何破坏性操作**(避免"删了迁移又没铺上")
5
+ # - 产物版本门:版本号只认包里的 package.json,不新于本机就拒绝部署(见下)
5
6
  # - 部署前 tar 备份 PHP 代码(排除 vendor);migrate 后健康检查;失败自动回滚
6
7
  # - 绝不 snap refresh 前端、绝不 apt
7
- # 退出码:0=成功(或无需更新),1=失败/已回滚。末行输出 DEPLOY_OK <ver> | ROLLED_BACK | ABORT_*
8
+ # 退出码:0=成功(或无需更新),1=失败/已回滚。末行输出 DEPLOY_OK <ver> | ROLLED_BACK | ABORT_* | SKIP_*
9
+ # 同一行也写进 /var/tmp/aaden-lu-status,供 index.js 读出来发到 cloud deviceLog
10
+ # —— 光靠退出码分不清"TSE 忙跳过"和"产物陈旧",deviceLog 里就只剩一句含糊的失败。
8
11
  set -uo pipefail
9
12
  S3="https://aaden-backend.s3.eu-central-1.amazonaws.com"
10
13
  STAGE=/var/tmp/aaden-lu-stage # 绝对路径:download 与 sudo cp 必须看到同一个目录(~ 在 sudo 下会变 /root)
@@ -12,10 +15,29 @@ BACKUP=/var/lib/aaden-php-backup
12
15
  PW='Innerken322.'
13
16
  sudoS(){ echo "$PW" | sudo -S bash -c "$*"; }
14
17
 
18
+ STATUS_FILE=/var/tmp/aaden-lu-status
19
+ rm -f "$STATUS_FILE" 2>/dev/null || true
20
+ # 每条退出路径都经 finish:状态既打到 stdout(人看日志),也落盘(index.js 读了发 deviceLog)。
21
+ finish(){ echo "$1"; echo "$1" > "$STATUS_FILE" 2>/dev/null || true; exit "$2"; }
22
+
23
+ # 点分数字版本比较:a > b 返回 0。与 index.js 的 versionGt 同一套语义('1.9.61' > '1.7.908')。
24
+ verGt(){ python3 -c "
25
+ import sys
26
+ pa=[int(x) if x.isdigit() else 0 for x in sys.argv[1].split('.')]
27
+ pb=[int(x) if x.isdigit() else 0 for x in sys.argv[2].split('.')]
28
+ n=max(len(pa),len(pb)); pa+=[0]*(n-len(pa)); pb+=[0]*(n-len(pb))
29
+ sys.exit(0 if pa>pb else 1)" "$1" "$2" 2>/dev/null; }
30
+
31
+ # 读 package.json 的 version;读不到输出空串。
32
+ pkgVer(){ python3 -c "
33
+ import json,sys
34
+ try: print(json.load(open(sys.argv[1]))['version'])
35
+ except Exception: print('')" "$1" 2>/dev/null; }
36
+
15
37
  # 早失败:老 aaden 设备的 sudo 密码可能不是共享密码。先探一次 ——
16
38
  # 否则会一路穿到 ABORT_DOWNLOAD,把"密码不符"误报成"下载问题",排障跑偏。
17
39
  # NOPASSWD 设备此探测同样通过(sudo 不问,管道密码被忽略)。
18
- if ! echo "$PW" | sudo -S true 2>/dev/null; then echo "ABORT_SUDO_AUTH (aaden sudo 密码不符?)"; exit 1; fi
40
+ if ! echo "$PW" | sudo -S true 2>/dev/null; then finish "ABORT_SUDO_AUTH (aaden sudo 密码不符?)" 1; fi
19
41
 
20
42
  # ---- composer.lock 门 ----
21
43
  REMOTE_LOCK=$(curl -s "$S3/raw/PHP/composer.lock" --max-time 30)
@@ -34,14 +56,32 @@ sudoS "rm -rf $STAGE && mkdir -p $STAGE && chown $(id -un):$(id -gn) $STAGE"
34
56
  # code.zip 仅 ~1MB,curl 瞬间完成;latest.zip 全量走 curl 稍慢但那是罕见 fallback。
35
57
  ZIP="$STAGE/$ARTIFACT"
36
58
  curl -fsSL "$S3/$ARTIFACT" -o "$ZIP" --max-time 1200
37
- if [ ! -s "$ZIP" ]; then echo "下载失败或为空: $ARTIFACT"; echo "ABORT_DOWNLOAD"; exit 1; fi
59
+ if [ ! -s "$ZIP" ]; then echo "下载失败或为空: $ARTIFACT"; finish "ABORT_DOWNLOAD ($ARTIFACT)" 1; fi
38
60
  unzip -q -o "$ZIP" -d "$STAGE" && rm -f "$ZIP"
39
61
 
40
62
  # ---- 关键:破坏任何东西之前,先确认源 PHP 代码确实在 ----
41
63
  if [ ! -f "$STAGE/PHP/composer.lock" ] || [ ! -d "$STAGE/PHP" ]; then
42
64
  echo "源包缺 PHP/ 或 composer.lock,中止(未改动任何文件)"
43
- echo "ABORT_BAD_ARTIFACT"; exit 1
65
+ finish "ABORT_BAD_ARTIFACT ($ARTIFACT)" 1
66
+ fi
67
+
68
+ # ---- 产物版本门:版本号只认包里的 package.json ----
69
+ # 早先是部署完单独 curl raw/package.json 当版本号,那是两个独立对象,可以不一致:
70
+ # 2026-09 就出现过 raw 已经 1.9.69、code.zip 还停在 8/19 的 1.9.67(发布工作流挂在
71
+ # GitHub Release 上,而那两次发版只推了 tag 没建 Release)。设备于是铺了旧代码、
72
+ # 盖上新版本号,上报 1.9.69 —— 版本门(本机==远端)从此永远跳过,再也不会自愈。
73
+ # 现在:包里没版本号、或不比本机新,就在动任何文件之前拒绝,并把状态上报出去。
74
+ ART_VER=$(pkgVer "$STAGE/package.json")
75
+ LOCAL_VER=$(pkgVer /var/www/html/package.json)
76
+ if [ -z "$ART_VER" ]; then
77
+ echo "源包里没有 package.json,取不到版本号,中止(未改动任何文件)"
78
+ finish "ABORT_NO_VERSION ($ARTIFACT)" 1
79
+ fi
80
+ if ! verGt "$ART_VER" "${LOCAL_VER:-0.0.0}"; then
81
+ echo "源包版本 $ART_VER 不比本机 ${LOCAL_VER:-未知} 新 —— 产物陈旧或发布流水线没跑,中止(未改动任何文件)"
82
+ finish "ABORT_STALE_ARTIFACT (包 $ART_VER <= 本机 ${LOCAL_VER:-未知})" 1
44
83
  fi
84
+ echo "产物版本门通过:${LOCAL_VER:-未知} -> $ART_VER"
45
85
 
46
86
  # ---- TSE 门:绝不在有进行中的交易/TSE 签名时部署 ----
47
87
  # 结账把 START/FINISH 塞进 TseExecuteQueue,后台 worker 签名后置 finished=1(出错也置 1)。
@@ -62,10 +102,10 @@ fi
62
102
  if [ -z "$DUE" ]; then
63
103
  # 查不到 = 无法确认 TSE 状态 → 对财政数据保守跳过(不部署也不冒险打断签名)。
64
104
  # 非破坏性:此刻还没动任何文件。版本停滞会被 fleet-rollout-status 监控发现。
65
- echo "TSE 队列查询失败,保守跳过本轮(未改动任何文件)"; echo "SKIP_TSE_QUERY"; exit 0
105
+ echo "TSE 队列查询失败,保守跳过本轮(未改动任何文件)"; finish "SKIP_TSE_QUERY" 0
66
106
  fi
67
107
  if [ "$DUE" -gt 0 ]; then
68
- echo "有进行中的交易/TSE 签名 (due=$DUE),跳过本轮,等下个周期"; echo "SKIP_TSE_BUSY"; exit 0
108
+ echo "有进行中的交易/TSE 签名 (due=$DUE),跳过本轮,等下个周期"; finish "SKIP_TSE_BUSY (due=$DUE)" 0
69
109
  fi
70
110
  echo "TSE 队列空闲 (due=0),可安全部署"
71
111
 
@@ -76,7 +116,9 @@ sudoS "rm -rf $BACKUP && mkdir -p $BACKUP && tar czf $BACKUP/php-code.tgz --excl
76
116
  sudoS "rm -rf /var/www/html/PHP/DB/migrations/*"
77
117
  if [ "$ARTIFACT" = "code.zip" ]; then
78
118
  sudoS "cp -rf $STAGE/PHP/. /var/www/html/PHP/"
79
- sudoS "curl -s '$S3/raw/package.json' -o /var/www/html/package.json --max-time 30"
119
+ # 版本号取包里的那份 —— 与刚铺下去的代码同源同批,不可能对不上。
120
+ # (旧版这里是 curl raw/package.json,正是"旧代码贴新版本号"的来源。)
121
+ sudoS "cp -f $STAGE/package.json /var/www/html/package.json"
80
122
  else
81
123
  sudoS "cp -rf $STAGE/. /var/www/html/"
82
124
  fi
@@ -96,7 +138,7 @@ done
96
138
  if [ "$HEALTHY" != "1" ]; then
97
139
  echo "健康检查失败 → 回滚"
98
140
  sudoS "tar xzf $BACKUP/php-code.tgz -C /var/www/html && chown -hR www-data:www-data /var/www/html"
99
- sudoS "rm -rf $STAGE"; echo "ROLLED_BACK"; exit 1
141
+ sudoS "rm -rf $STAGE"; finish "ROLLED_BACK (回到 ${LOCAL_VER:-未知})" 1
100
142
  fi
101
143
  sudoS "rm -rf $STAGE"
102
- echo "DEPLOY_OK $(python3 -c "import json;print(json.load(open('/var/www/html/package.json'))['version'])" 2>/dev/null)"
144
+ finish "DEPLOY_OK $(pkgVer /var/www/html/package.json)" 0
@@ -1,5 +1,14 @@
1
1
  #!/bin/bash
2
2
 
3
+ # cron 的默认 PATH 只有 /usr/bin:/bin,不含 /usr/local/bin —— 而 npm 全局的 aaden-cli
4
+ # 就装在这里。不处理的话下面两条命令会 command not found:auto-upgrade 每天静默跳过
5
+ # patch 与 light-update,表现为"CLI 一直在升级、后端却永远不动"(2026-09 全网故障根因)。
6
+ #
7
+ # 用前置而非覆盖,并保持命令为裸名:历史上 aaden-cli 也出现在过 /usr/bin
8
+ # (见 2022 年的 ln -s /usr/bin/aaden-cli /usr/local/bin/aaden-cli),
9
+ # 写死 /usr/local/bin/aaden-cli 在那种设备上会失败,而保留 "$PATH" 两者都能命中。
10
+ export PATH="/usr/local/bin:$PATH"
11
+
3
12
  mkdir -p /etc/aaden/config
4
13
  chmod -R 777 /etc/aaden/config
5
14
 
package/dist/index.js CHANGED
@@ -99,7 +99,7 @@ const {hillo} = __nccwpck_require__(6365)
99
99
  const version = (__nccwpck_require__(4147)/* .version */ .i8)
100
100
  const colors = __nccwpck_require__(3045)
101
101
  const defaultData = {
102
- frontendVersion: '', cliVersion: '', diskUsage: '', upTime: '', ngrokIsNew: false
102
+ frontendVersion: '', cliVersion: '', diskUsage: '', upTime: '', ngrokIsNew: false, hostInfo: {}
103
103
  }
104
104
 
105
105
  async function deviceEcho(data) {
@@ -110,7 +110,7 @@ async function deviceEcho(data) {
110
110
  Object.entries(data).forEach(en => {
111
111
  console.log(...en)
112
112
  })
113
- const {frontendVersion, cliVersion, diskUsage, upTime, ngrokIsNew} = Object.assign({}, defaultData, data)
113
+ const {frontendVersion, cliVersion, diskUsage, upTime, ngrokIsNew, hostInfo} = Object.assign({}, defaultData, data)
114
114
  if (parseInt(myDeviceId) === -1) {
115
115
  return
116
116
  }
@@ -131,7 +131,8 @@ async function deviceEcho(data) {
131
131
  diskUsage,
132
132
  lastUptime: upTime,
133
133
  ngrokOk: ngrokIsNew,
134
- deviceName: `${name}\n${adress1}\n${postCode} ${city} ${state}`
134
+ deviceName: `${name}\n${adress1}\n${postCode} ${city} ${state}`,
135
+ ...hostInfo
135
136
  })
136
137
  console.log('report to remote server complete')
137
138
  } catch (e) {
@@ -179,6 +180,75 @@ async function getNiceRestaurantInfo() {
179
180
 
180
181
 
181
182
 
183
+ /***/ }),
184
+
185
+ /***/ 3727:
186
+ /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => {
187
+
188
+ const os = __nccwpck_require__(2037)
189
+ const fs = __nccwpck_require__(7147)
190
+ const {execSync} = __nccwpck_require__(8493)
191
+
192
+ // do-log 顺带上报的宿主机信息,用来在云端看车队的系统/硬件分布。
193
+ // 每一项单独兜底:探测失败就报 null,不能让 do-log 本身挂掉。
194
+
195
+ function safe(fn, fallback = null) {
196
+ try {
197
+ const value = fn()
198
+ return value === undefined ? fallback : value
199
+ } catch (e) {
200
+ return fallback
201
+ }
202
+ }
203
+
204
+ function run(cmd) {
205
+ return execSync(cmd, {
206
+ encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore']
207
+ }).trim()
208
+ }
209
+
210
+ function osRelease() {
211
+ const text = fs.readFileSync('/etc/os-release', 'utf-8')
212
+ const match = text.match(/^PRETTY_NAME="?([^"\n]*)"?$/m)
213
+ return match ? match[1] : null
214
+ }
215
+
216
+ function rootDisk() {
217
+ // 第二行是 "<size> <avail>",单位字节
218
+ const [size, avail] = run('df -B1 --output=size,avail /')
219
+ .split('\n')[1].trim().split(/\s+/).map(Number)
220
+ const toGb = bytes => Math.round(bytes / 1024 / 1024 / 1024)
221
+ return {diskTotalGb: toGb(size), diskAvailGb: toGb(avail)}
222
+ }
223
+
224
+ function dockerVersion() {
225
+ // 没装 docker 报空串,和"探测失败"(null) 区分开
226
+ const output = run('command -v docker >/dev/null && docker --version || true')
227
+ const match = output.match(/version ([^,\s]+)/)
228
+ return match ? match[1] : ''
229
+ }
230
+
231
+ function collectHostInfo() {
232
+ const cpus = safe(() => os.cpus(), [])
233
+ const disk = safe(rootDisk, {diskTotalGb: null, diskAvailGb: null})
234
+ return {
235
+ osRelease: safe(osRelease),
236
+ kernelVersion: safe(() => os.release()),
237
+ // WSL1 内核是 "4.4.0-19041-Microsoft",WSL2 是 "5.15.x-microsoft-standard-WSL2"
238
+ wsl: safe(() => /microsoft|wsl/i.test(os.release())),
239
+ cpuModel: cpus.length ? cpus[0].model.trim() : null,
240
+ cpuCores: cpus.length || null,
241
+ cpuArch: safe(() => os.arch()),
242
+ memTotalMb: safe(() => Math.round(os.totalmem() / 1024 / 1024)),
243
+ diskTotalGb: disk.diskTotalGb,
244
+ diskAvailGb: disk.diskAvailGb,
245
+ dockerVersion: safe(dockerVersion),
246
+ }
247
+ }
248
+
249
+ module.exports = {collectHostInfo}
250
+
251
+
182
252
  /***/ }),
183
253
 
184
254
  /***/ 6545:
@@ -18507,7 +18577,7 @@ module.exports = JSON.parse('{"name":"axios","version":"0.19.2","description":"P
18507
18577
  /***/ ((module) => {
18508
18578
 
18509
18579
  "use strict";
18510
- module.exports = {"i8":"1.6.98"};
18580
+ module.exports = {"i8":"1.6.102"};
18511
18581
 
18512
18582
  /***/ })
18513
18583
 
@@ -18590,6 +18660,7 @@ const fs = __nccwpck_require__(7147)
18590
18660
  const InnerKenTimeStampFormat = 'YYYYMMDDHHmmss'
18591
18661
  const prompts = __nccwpck_require__(9646)
18592
18662
  const {deviceEcho, logAction, getCurrentDeviceId} = __nccwpck_require__(7415)
18663
+ const {collectHostInfo} = __nccwpck_require__(3727)
18593
18664
  const GlobalDeviceId = 'notset'
18594
18665
 
18595
18666
  const {program} = __nccwpck_require__(1904)
@@ -18811,12 +18882,27 @@ async function patch() {
18811
18882
  return !readyList.includes(p)
18812
18883
  })
18813
18884
 
18814
- if (runList.length > 0) {
18815
- await runMultiple(runList)
18816
- readyList = readyList.concat(runList)
18885
+ // 逐个执行并检查退出码:失败的补丁绝不写进 patchLog.json,下次才会重试。
18886
+ // 旧实现把整批用 && 串成一条、又不看退出码 —— 任一补丁报错或命令找不到时,
18887
+ // 后面的补丁根本没跑,却全被记为已完成(2026-09 全网 patch 静默失效就是这么被掩盖的)。
18888
+ for (const p of runList) {
18889
+ const {result, code} = await runMultiple([p])
18890
+ if (!result) {
18891
+ // debug 模式只打印不执行,不能因此把补丁标记成已完成。
18892
+ return
18893
+ }
18894
+ if (code !== 0) {
18895
+ console.error(('补丁执行失败,未记录、下次重试: ' + p + ' (exit ' + code + ')').bgRed)
18896
+ try {
18897
+ await logAction('patch 执行失败: ' + p + ' (exit ' + code + ')', code || 1)
18898
+ } catch (e) {
18899
+ console.error('上报 patch 失败异常: ' + (e.message || e))
18900
+ }
18901
+ return
18902
+ }
18903
+ readyList.push(p)
18817
18904
  fs.writeFileSync(patchRecordFilePath, JSON.stringify(readyList))
18818
18905
  }
18819
-
18820
18906
  }
18821
18907
 
18822
18908
  async function runMultiple(files) {
@@ -18825,7 +18911,7 @@ async function runMultiple(files) {
18825
18911
  arr.push('./' + f)
18826
18912
  return arr
18827
18913
  }, [])
18828
- await cli.executeInShell(command, program.debug)
18914
+ return cli.executeInShell(command, program.debug)
18829
18915
  }
18830
18916
 
18831
18917
  async function run(filePath) {
@@ -19079,13 +19165,16 @@ async function update(channel = '') {
19079
19165
  * 与 `update`(走 update.sh)的区别:
19080
19166
  * - **不 snap refresh aaden-vue-app**(不会重启收银前端)
19081
19167
  * - **不 apt install**
19082
- * - 三道闸门保证「营业时间被触发也安全」:
19168
+ * - 四道闸门保证「营业时间被触发也安全」:
19083
19169
  * ① 版本门:S3 版本 == 本机版本 → 跳过(已最新,重启也不白部署)
19084
19170
  * ② 灰度门:只部署到 cloud `deviceLog.maxVersion` 允许的版本(-1=不设限)
19085
19171
  * → 可先给少数设备设 maxVersion 做金丝雀,其余自动跳过
19086
- * ③ TSE 门:有进行中的交易/TSE 签名 → 跳过(在 lightUpdate.sh 里查 TseExecuteQueue,
19172
+ * ③ 产物版本门:包里的 package.json 不比本机新 → 拒绝(在 lightUpdate.sh 里,下载解压之后、
19173
+ * 动任何文件之前)。挡的是"发布流水线没跑、S3 上产物陈旧"这种情况 —— 见下面的说明。
19174
+ * ④ TSE 门:有进行中的交易/TSE 签名 → 跳过(在 lightUpdate.sh 里查 TseExecuteQueue,
19087
19175
  * 紧贴 cp 之前,避免"下载期间开了新交易"的竞态;开桌吃饭不阻塞,只有正在签的交易阻塞)
19088
- * - 结果(部署/跳过/失败)经 logAction 写入 cloud 事件日志,可在 deviceLog/eventList 观测
19176
+ * - 结果(部署/跳过/失败)经 logAction 写入 cloud 事件日志,可在 deviceLog/eventList 观测;
19177
+ * 具体状态由 lightUpdate.sh 写在 /var/tmp/aaden-lu-status,这里读出来一并上报
19089
19178
  */
19090
19179
  async function lightUpdate() {
19091
19180
  try {
@@ -19115,8 +19204,10 @@ async function lightUpdate() {
19115
19204
  // 有进行中的交易/TSE 签名就跳过。放在脚本里而非这里,是为了紧贴破坏性 cp、
19116
19205
  // 消掉"这里查过没单 → 下载几秒 → 期间开了新交易 → cp 打断签名"的竞态窗口。
19117
19206
 
19118
- // lightUpdate.sh 自己完成:TSE 门 + composer.lock 门(选 code.zip 或 latest.zip) + 下载 + 备份
19119
- // + 部署 + migrate + 健康检查/失败回滚。退出码:0=成功,1=已回滚。
19207
+ // lightUpdate.sh 自己完成:composer.lock 门 + 下载 + 产物版本门 + TSE 门 + 备份
19208
+ // + 部署 + migrate + 健康检查/失败回滚。退出码:0=成功/跳过,1=拒绝或已回滚。
19209
+ // 先删掉上轮的状态文件,免得脚本没跑起来时把旧状态当成本轮结果报上去。
19210
+ try { fs.unlinkSync(LU_STATUS_FILE) } catch (e) { /* 本来就没有,正常 */ }
19120
19211
  const command = [
19121
19212
  ...getPrivilegeCMD('AadenDeploy/lightUpdate.sh'),
19122
19213
  'cd AadenDeploy',
@@ -19124,13 +19215,17 @@ async function lightUpdate() {
19124
19215
  ]
19125
19216
  const {code} = await cli.executeInShell(command, program.debug)
19126
19217
 
19218
+ // 退出码只有 0/1,分不清"TSE 忙跳过"和"产物陈旧被拒"。脚本把末行状态写了盘,这里读出来
19219
+ // 一起发 deviceLog —— 否则像这次"发布流水线没跑"这种故障在云端就是一片沉默。
19220
+ const status = readLightUpdateStatus()
19221
+ const suffix = status ? ' [' + status + ']' : ''
19127
19222
  const newVersion = readBackendVersionFresh()
19128
19223
  if (code !== 0) {
19129
- await logAction('light-update 失败/已回滚 (仍 ' + newVersion + ')', code || 1)
19224
+ await logAction('light-update 失败/未部署 (仍 ' + newVersion + ')' + suffix, code || 1)
19130
19225
  } else if (newVersion !== localVersion) {
19131
- await logAction('light-update 完成 ' + localVersion + ' -> ' + newVersion, 0)
19226
+ await logAction('light-update 完成 ' + localVersion + ' -> ' + newVersion + suffix, 0)
19132
19227
  } else {
19133
- await logAction('light-update 无实际变化 (' + newVersion + ')', 0)
19228
+ await logAction('light-update 无实际变化 (' + newVersion + ')' + suffix, 0)
19134
19229
  }
19135
19230
  } catch (e) {
19136
19231
  console.error(e)
@@ -19161,6 +19256,18 @@ async function getDeviceMaxVersion(deviceId) {
19161
19256
  }
19162
19257
  }
19163
19258
 
19259
+ /** lightUpdate.sh 落盘的末行状态(DEPLOY_OK / ROLLED_BACK / ABORT_* / SKIP_*)。 */
19260
+ const LU_STATUS_FILE = '/var/tmp/aaden-lu-status'
19261
+
19262
+ /** 读 lightUpdate.sh 的最终状态;读不到返回空串(脚本没跑到 finish,或权限异常)。 */
19263
+ function readLightUpdateStatus() {
19264
+ try {
19265
+ return fs.readFileSync(LU_STATUS_FILE, 'utf8').trim().split('\n').pop().slice(0, 200)
19266
+ } catch (e) {
19267
+ return ''
19268
+ }
19269
+ }
19270
+
19164
19271
  /** 直接读文件、绕过 require 缓存 —— 部署后读到的必须是新版本号,不能是进程启动时缓存的旧值。 */
19165
19272
  function readBackendVersionFresh() {
19166
19273
  try {
@@ -19326,7 +19433,8 @@ async function doLog() {
19326
19433
  cliVersion: version,
19327
19434
  diskUsage: diskUsage[diskUsage.length - 1].replace('\n', ''),
19328
19435
  upTime: getUpTime(),
19329
- ngrokIsNew: ngrokNewestVersion()
19436
+ ngrokIsNew: ngrokNewestVersion(),
19437
+ hostInfo: collectHostInfo()
19330
19438
  }
19331
19439
  )
19332
19440
  console.log('finish')
package/index.js CHANGED
@@ -9,6 +9,7 @@ const fs = require('fs')
9
9
  const InnerKenTimeStampFormat = 'YYYYMMDDHHmmss'
10
10
  const prompts = require('prompts')
11
11
  const {deviceEcho, logAction, getCurrentDeviceId} = require('./modules/firebase')
12
+ const {collectHostInfo} = require('./modules/host-info')
12
13
  const GlobalDeviceId = 'notset'
13
14
 
14
15
  const {program} = require('commander')
@@ -230,12 +231,27 @@ async function patch() {
230
231
  return !readyList.includes(p)
231
232
  })
232
233
 
233
- if (runList.length > 0) {
234
- await runMultiple(runList)
235
- readyList = readyList.concat(runList)
234
+ // 逐个执行并检查退出码:失败的补丁绝不写进 patchLog.json,下次才会重试。
235
+ // 旧实现把整批用 && 串成一条、又不看退出码 —— 任一补丁报错或命令找不到时,
236
+ // 后面的补丁根本没跑,却全被记为已完成(2026-09 全网 patch 静默失效就是这么被掩盖的)。
237
+ for (const p of runList) {
238
+ const {result, code} = await runMultiple([p])
239
+ if (!result) {
240
+ // debug 模式只打印不执行,不能因此把补丁标记成已完成。
241
+ return
242
+ }
243
+ if (code !== 0) {
244
+ console.error(('补丁执行失败,未记录、下次重试: ' + p + ' (exit ' + code + ')').bgRed)
245
+ try {
246
+ await logAction('patch 执行失败: ' + p + ' (exit ' + code + ')', code || 1)
247
+ } catch (e) {
248
+ console.error('上报 patch 失败异常: ' + (e.message || e))
249
+ }
250
+ return
251
+ }
252
+ readyList.push(p)
236
253
  fs.writeFileSync(patchRecordFilePath, JSON.stringify(readyList))
237
254
  }
238
-
239
255
  }
240
256
 
241
257
  async function runMultiple(files) {
@@ -244,7 +260,7 @@ async function runMultiple(files) {
244
260
  arr.push('./' + f)
245
261
  return arr
246
262
  }, [])
247
- await cli.executeInShell(command, program.debug)
263
+ return cli.executeInShell(command, program.debug)
248
264
  }
249
265
 
250
266
  async function run(filePath) {
@@ -498,13 +514,16 @@ async function update(channel = '') {
498
514
  * 与 `update`(走 update.sh)的区别:
499
515
  * - **不 snap refresh aaden-vue-app**(不会重启收银前端)
500
516
  * - **不 apt install**
501
- * - 三道闸门保证「营业时间被触发也安全」:
517
+ * - 四道闸门保证「营业时间被触发也安全」:
502
518
  * ① 版本门:S3 版本 == 本机版本 → 跳过(已最新,重启也不白部署)
503
519
  * ② 灰度门:只部署到 cloud `deviceLog.maxVersion` 允许的版本(-1=不设限)
504
520
  * → 可先给少数设备设 maxVersion 做金丝雀,其余自动跳过
505
- * ③ TSE 门:有进行中的交易/TSE 签名 → 跳过(在 lightUpdate.sh 里查 TseExecuteQueue,
521
+ * ③ 产物版本门:包里的 package.json 不比本机新 → 拒绝(在 lightUpdate.sh 里,下载解压之后、
522
+ * 动任何文件之前)。挡的是"发布流水线没跑、S3 上产物陈旧"这种情况 —— 见下面的说明。
523
+ * ④ TSE 门:有进行中的交易/TSE 签名 → 跳过(在 lightUpdate.sh 里查 TseExecuteQueue,
506
524
  * 紧贴 cp 之前,避免"下载期间开了新交易"的竞态;开桌吃饭不阻塞,只有正在签的交易阻塞)
507
- * - 结果(部署/跳过/失败)经 logAction 写入 cloud 事件日志,可在 deviceLog/eventList 观测
525
+ * - 结果(部署/跳过/失败)经 logAction 写入 cloud 事件日志,可在 deviceLog/eventList 观测;
526
+ * 具体状态由 lightUpdate.sh 写在 /var/tmp/aaden-lu-status,这里读出来一并上报
508
527
  */
509
528
  async function lightUpdate() {
510
529
  try {
@@ -534,8 +553,10 @@ async function lightUpdate() {
534
553
  // 有进行中的交易/TSE 签名就跳过。放在脚本里而非这里,是为了紧贴破坏性 cp、
535
554
  // 消掉"这里查过没单 → 下载几秒 → 期间开了新交易 → cp 打断签名"的竞态窗口。
536
555
 
537
- // lightUpdate.sh 自己完成:TSE 门 + composer.lock 门(选 code.zip 或 latest.zip) + 下载 + 备份
538
- // + 部署 + migrate + 健康检查/失败回滚。退出码:0=成功,1=已回滚。
556
+ // lightUpdate.sh 自己完成:composer.lock 门 + 下载 + 产物版本门 + TSE 门 + 备份
557
+ // + 部署 + migrate + 健康检查/失败回滚。退出码:0=成功/跳过,1=拒绝或已回滚。
558
+ // 先删掉上轮的状态文件,免得脚本没跑起来时把旧状态当成本轮结果报上去。
559
+ try { fs.unlinkSync(LU_STATUS_FILE) } catch (e) { /* 本来就没有,正常 */ }
539
560
  const command = [
540
561
  ...getPrivilegeCMD('AadenDeploy/lightUpdate.sh'),
541
562
  'cd AadenDeploy',
@@ -543,13 +564,17 @@ async function lightUpdate() {
543
564
  ]
544
565
  const {code} = await cli.executeInShell(command, program.debug)
545
566
 
567
+ // 退出码只有 0/1,分不清"TSE 忙跳过"和"产物陈旧被拒"。脚本把末行状态写了盘,这里读出来
568
+ // 一起发 deviceLog —— 否则像这次"发布流水线没跑"这种故障在云端就是一片沉默。
569
+ const status = readLightUpdateStatus()
570
+ const suffix = status ? ' [' + status + ']' : ''
546
571
  const newVersion = readBackendVersionFresh()
547
572
  if (code !== 0) {
548
- await logAction('light-update 失败/已回滚 (仍 ' + newVersion + ')', code || 1)
573
+ await logAction('light-update 失败/未部署 (仍 ' + newVersion + ')' + suffix, code || 1)
549
574
  } else if (newVersion !== localVersion) {
550
- await logAction('light-update 完成 ' + localVersion + ' -> ' + newVersion, 0)
575
+ await logAction('light-update 完成 ' + localVersion + ' -> ' + newVersion + suffix, 0)
551
576
  } else {
552
- await logAction('light-update 无实际变化 (' + newVersion + ')', 0)
577
+ await logAction('light-update 无实际变化 (' + newVersion + ')' + suffix, 0)
553
578
  }
554
579
  } catch (e) {
555
580
  console.error(e)
@@ -580,6 +605,18 @@ async function getDeviceMaxVersion(deviceId) {
580
605
  }
581
606
  }
582
607
 
608
+ /** lightUpdate.sh 落盘的末行状态(DEPLOY_OK / ROLLED_BACK / ABORT_* / SKIP_*)。 */
609
+ const LU_STATUS_FILE = '/var/tmp/aaden-lu-status'
610
+
611
+ /** 读 lightUpdate.sh 的最终状态;读不到返回空串(脚本没跑到 finish,或权限异常)。 */
612
+ function readLightUpdateStatus() {
613
+ try {
614
+ return fs.readFileSync(LU_STATUS_FILE, 'utf8').trim().split('\n').pop().slice(0, 200)
615
+ } catch (e) {
616
+ return ''
617
+ }
618
+ }
619
+
583
620
  /** 直接读文件、绕过 require 缓存 —— 部署后读到的必须是新版本号,不能是进程启动时缓存的旧值。 */
584
621
  function readBackendVersionFresh() {
585
622
  try {
@@ -745,7 +782,8 @@ async function doLog() {
745
782
  cliVersion: version,
746
783
  diskUsage: diskUsage[diskUsage.length - 1].replace('\n', ''),
747
784
  upTime: getUpTime(),
748
- ngrokIsNew: ngrokNewestVersion()
785
+ ngrokIsNew: ngrokNewestVersion(),
786
+ hostInfo: collectHostInfo()
749
787
  }
750
788
  )
751
789
  console.log('finish')
@@ -3,7 +3,7 @@ const {hillo} = require('hillo')
3
3
  const version = require('./../package.json').version
4
4
  const colors = require('colors')
5
5
  const defaultData = {
6
- frontendVersion: '', cliVersion: '', diskUsage: '', upTime: '', ngrokIsNew: false
6
+ frontendVersion: '', cliVersion: '', diskUsage: '', upTime: '', ngrokIsNew: false, hostInfo: {}
7
7
  }
8
8
 
9
9
  export async function deviceEcho(data) {
@@ -14,7 +14,7 @@ export async function deviceEcho(data) {
14
14
  Object.entries(data).forEach(en => {
15
15
  console.log(...en)
16
16
  })
17
- const {frontendVersion, cliVersion, diskUsage, upTime, ngrokIsNew} = Object.assign({}, defaultData, data)
17
+ const {frontendVersion, cliVersion, diskUsage, upTime, ngrokIsNew, hostInfo} = Object.assign({}, defaultData, data)
18
18
  if (parseInt(myDeviceId) === -1) {
19
19
  return
20
20
  }
@@ -35,7 +35,8 @@ export async function deviceEcho(data) {
35
35
  diskUsage,
36
36
  lastUptime: upTime,
37
37
  ngrokOk: ngrokIsNew,
38
- deviceName: `${name}\n${adress1}\n${postCode} ${city} ${state}`
38
+ deviceName: `${name}\n${adress1}\n${postCode} ${city} ${state}`,
39
+ ...hostInfo
39
40
  })
40
41
  console.log('report to remote server complete')
41
42
  } catch (e) {
@@ -0,0 +1,62 @@
1
+ const os = require('os')
2
+ const fs = require('fs')
3
+ const {execSync} = require('child_process')
4
+
5
+ // do-log 顺带上报的宿主机信息,用来在云端看车队的系统/硬件分布。
6
+ // 每一项单独兜底:探测失败就报 null,不能让 do-log 本身挂掉。
7
+
8
+ function safe(fn, fallback = null) {
9
+ try {
10
+ const value = fn()
11
+ return value === undefined ? fallback : value
12
+ } catch (e) {
13
+ return fallback
14
+ }
15
+ }
16
+
17
+ function run(cmd) {
18
+ return execSync(cmd, {
19
+ encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore']
20
+ }).trim()
21
+ }
22
+
23
+ function osRelease() {
24
+ const text = fs.readFileSync('/etc/os-release', 'utf-8')
25
+ const match = text.match(/^PRETTY_NAME="?([^"\n]*)"?$/m)
26
+ return match ? match[1] : null
27
+ }
28
+
29
+ function rootDisk() {
30
+ // 第二行是 "<size> <avail>",单位字节
31
+ const [size, avail] = run('df -B1 --output=size,avail /')
32
+ .split('\n')[1].trim().split(/\s+/).map(Number)
33
+ const toGb = bytes => Math.round(bytes / 1024 / 1024 / 1024)
34
+ return {diskTotalGb: toGb(size), diskAvailGb: toGb(avail)}
35
+ }
36
+
37
+ function dockerVersion() {
38
+ // 没装 docker 报空串,和"探测失败"(null) 区分开
39
+ const output = run('command -v docker >/dev/null && docker --version || true')
40
+ const match = output.match(/version ([^,\s]+)/)
41
+ return match ? match[1] : ''
42
+ }
43
+
44
+ function collectHostInfo() {
45
+ const cpus = safe(() => os.cpus(), [])
46
+ const disk = safe(rootDisk, {diskTotalGb: null, diskAvailGb: null})
47
+ return {
48
+ osRelease: safe(osRelease),
49
+ kernelVersion: safe(() => os.release()),
50
+ // WSL1 内核是 "4.4.0-19041-Microsoft",WSL2 是 "5.15.x-microsoft-standard-WSL2"
51
+ wsl: safe(() => /microsoft|wsl/i.test(os.release())),
52
+ cpuModel: cpus.length ? cpus[0].model.trim() : null,
53
+ cpuCores: cpus.length || null,
54
+ cpuArch: safe(() => os.arch()),
55
+ memTotalMb: safe(() => Math.round(os.totalmem() / 1024 / 1024)),
56
+ diskTotalGb: disk.diskTotalGb,
57
+ diskAvailGb: disk.diskAvailGb,
58
+ dockerVersion: safe(dockerVersion),
59
+ }
60
+ }
61
+
62
+ module.exports = {collectHostInfo}
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "aaden-cli",
3
- "version": "1.6.98",
3
+ "version": "1.6.102",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
7
7
  "test": "node test.js",
8
- "update": "git add . && release-it"
8
+ "update": "git add . && release-it --ci"
9
9
  },
10
10
  "bin": {
11
11
  "aaden-cli": "dist/index.js"
@@ -1,39 +0,0 @@
1
- #!/bin/bash
2
- # 一次性修复:摘掉老 git 通道的 doUpdate cron。
3
- #
4
- # 病症:设备 crontab 里有
5
- # 59 4 * * * curl -X POST 'http://localhost/PHP/UpdateSelf.php?op=doUpdate'
6
- # @reboot ... curl -X POST '.../UpdateSelf.php?op=doUpdate'
7
- # 它每天 04:59(和 @reboot)从**设备本地陈旧 gitCache** 部署后端;而 git fetch 因 deploy key
8
- # 未分发到全网而失败(FETCH_HEAD 0 字节),缓存卡在旧版本;git 通道又没有"绝不降级"保护 →
9
- # 于是把 light-update 刚更好的新版本降级回退(实测 1.9.63 → 1.9.62/1.9.61,每天来回打架)。
10
- #
11
- # 结论:light-update(S3/code.zip 通道)已是后端唯一活着的更新通道,且自带"只升不降"版本门。
12
- # 摘掉 doUpdate cron,终结两条通道打架。crontab 不走 git/S3 部署 → 改了就稳。
13
- #
14
- # 幂等:没有该行就不动;grep -vF 精确匹配整串,不误伤 crontab.sh / light-update 那几行。
15
- set -u
16
-
17
- MARK='UpdateSelf.php?op=doUpdate'
18
-
19
- strip_for() { # $1 = 目标用户(空=当前用户)
20
- local u="$1" cur new
21
- local args=()
22
- [ -n "$u" ] && args=(-u "$u")
23
- cur=$(crontab "${args[@]}" -l 2>/dev/null) || return 0
24
- printf '%s\n' "$cur" | grep -qF "$MARK" || return 0 # 没有该 cron → 不动
25
- new=$(printf '%s\n' "$cur" | grep -vF "$MARK")
26
- printf '%s\n' "$new" | crontab "${args[@]}" - \
27
- && echo " 已从 ${u:-当前用户} 的 crontab 摘除 doUpdate cron"
28
- }
29
-
30
- # doUpdate cron 在 aaden 用户的 crontab。patch 正常由 root 的 auto-upgrade 触发 → 用 -u aaden;
31
- # 兜底再扫一遍当前用户(万一某些设备以 aaden 直接跑 patch),谁有就摘谁。
32
- if [ "$(id -u)" = "0" ]; then
33
- strip_for aaden
34
- strip_for ""
35
- else
36
- strip_for ""
37
- fi
38
-
39
- echo " removeGitDoUpdateCron 完成(light-update 成为后端唯一更新通道)"
@@ -1,39 +0,0 @@
1
- #!/bin/bash
2
- # 一次性修复:摘掉老 git 通道的 doUpdate cron。
3
- #
4
- # 病症:设备 crontab 里有
5
- # 59 4 * * * curl -X POST 'http://localhost/PHP/UpdateSelf.php?op=doUpdate'
6
- # @reboot ... curl -X POST '.../UpdateSelf.php?op=doUpdate'
7
- # 它每天 04:59(和 @reboot)从**设备本地陈旧 gitCache** 部署后端;而 git fetch 因 deploy key
8
- # 未分发到全网而失败(FETCH_HEAD 0 字节),缓存卡在旧版本;git 通道又没有"绝不降级"保护 →
9
- # 于是把 light-update 刚更好的新版本降级回退(实测 1.9.63 → 1.9.62/1.9.61,每天来回打架)。
10
- #
11
- # 结论:light-update(S3/code.zip 通道)已是后端唯一活着的更新通道,且自带"只升不降"版本门。
12
- # 摘掉 doUpdate cron,终结两条通道打架。crontab 不走 git/S3 部署 → 改了就稳。
13
- #
14
- # 幂等:没有该行就不动;grep -vF 精确匹配整串,不误伤 crontab.sh / light-update 那几行。
15
- set -u
16
-
17
- MARK='UpdateSelf.php?op=doUpdate'
18
-
19
- strip_for() { # $1 = 目标用户(空=当前用户)
20
- local u="$1" cur new
21
- local args=()
22
- [ -n "$u" ] && args=(-u "$u")
23
- cur=$(crontab "${args[@]}" -l 2>/dev/null) || return 0
24
- printf '%s\n' "$cur" | grep -qF "$MARK" || return 0 # 没有该 cron → 不动
25
- new=$(printf '%s\n' "$cur" | grep -vF "$MARK")
26
- printf '%s\n' "$new" | crontab "${args[@]}" - \
27
- && echo " 已从 ${u:-当前用户} 的 crontab 摘除 doUpdate cron"
28
- }
29
-
30
- # doUpdate cron 在 aaden 用户的 crontab。patch 正常由 root 的 auto-upgrade 触发 → 用 -u aaden;
31
- # 兜底再扫一遍当前用户(万一某些设备以 aaden 直接跑 patch),谁有就摘谁。
32
- if [ "$(id -u)" = "0" ]; then
33
- strip_for aaden
34
- strip_for ""
35
- else
36
- strip_for ""
37
- fi
38
-
39
- echo " removeGitDoUpdateCron 完成(light-update 成为后端唯一更新通道)"