aaden-cli 1.6.111 → 1.6.113

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.
@@ -47,11 +47,18 @@ def source_of(ref_host):
47
47
  return 'lan'
48
48
 
49
49
 
50
+ TS = re.compile(r'^(\d{2})/([A-Z][a-z]{2})/(\d{4}):(\d{2}:\d{2}:\d{2})')
51
+
52
+
50
53
  def parse_ts(ts):
51
- """'03/Oct/2026:12:20:40 +0200' → ('2026-10-03', '2026-10-03T12:20:40'),比 strptime 快得多"""
52
- d, mon, rest = ts[0:2], ts[3:6], ts[7:]
53
- year, hms = rest[0:4], rest[5:13]
54
- day = f'{year}-{MONTHS.get(mon, 0):02d}-{d}'
54
+ """'03/Oct/2026:12:20:40 +0200' → ('2026-10-03', '2026-10-03T12:20:40'),比 strptime 快得多。
55
+ 格式不对返回 (None, None):日志里有乱码行(扫描器发的二进制请求),以前照样切片,
56
+ 切出 '��-00-�T…' 这种时间,还因为按字符串比较排到最大成了 logTo,云端解析失败、整份上报被拒。"""
57
+ m = TS.match(ts)
58
+ if not m or m.group(2) not in MONTHS:
59
+ return None, None
60
+ d, mon, year, hms = m.groups()
61
+ day = f'{year}-{MONTHS[mon]:02d}-{d}'
55
62
  return day, f'{day}T{hms}'
56
63
 
57
64
 
@@ -86,8 +93,8 @@ def main():
86
93
  total += 1
87
94
  # 时间范围按所有行算,统计只看和 AppCenter 有关的行
88
95
  lb = line.find('[')
89
- if lb > 0:
90
- day, at = parse_ts(line[lb + 1:lb + 21])
96
+ at = parse_ts(line[lb + 1:lb + 21])[1] if lb > 0 else None
97
+ if at:
91
98
  if log_from is None or at < log_from:
92
99
  log_from = at
93
100
  if log_to is None or at > log_to:
@@ -110,6 +117,8 @@ def main():
110
117
  if not app:
111
118
  continue
112
119
  day, at = parse_ts(m['ts'])
120
+ if not day:
121
+ continue
113
122
  s = stats[(app, day)]
114
123
  s['requests'] += 1
115
124
  bare = path.split('?')[0].split('#')[0]
@@ -20,6 +20,14 @@ rm -f "$STATUS_FILE" 2>/dev/null || true
20
20
  # 每条退出路径都经 finish:状态既打到 stdout(人看日志),也落盘(index.js 读了发 deviceLog)。
21
21
  finish(){ echo "$1"; echo "$1" > "$STATUS_FILE" 2>/dev/null || true; exit "$2"; }
22
22
 
23
+ # 同一时间只跑一个:04:59 cron、开机、do-log 补跑、云端命令、远程触发可能撞在一起,两个同时 cp/migrate 会互相踩。
24
+ # 锁文件谁先建都要让另一个用户也能打开(root 和 aaden 都会跑),所以 666;flock 只要读权限。
25
+ LOCK=/var/tmp/aaden-lu.lock
26
+ [ -e "$LOCK" ] || { touch "$LOCK" && chmod 666 "$LOCK"; } 2>/dev/null
27
+ if exec 9<"$LOCK" 2>/dev/null; then
28
+ flock -n 9 || finish "SKIP_BUSY (另一个 light-update 正在跑)" 0
29
+ fi
30
+
23
31
  # 点分数字版本比较:a > b 返回 0。与 index.js 的 versionGt 同一套语义('1.9.61' > '1.7.908')。
24
32
  verGt(){ python3 -c "
25
33
  import sys
@@ -55,8 +63,28 @@ sudoS "rm -rf $STAGE && mkdir -p $STAGE && chown $(id -un):$(id -gn) $STAGE"
55
63
  # 用 curl 而非 axel:axel 在设备上是 snap 包,受限写不进 /var/tmp。
56
64
  # code.zip 仅 ~1MB,curl 瞬间完成;latest.zip 全量走 curl 稍慢但那是罕见 fallback。
57
65
  ZIP="$STAGE/$ARTIFACT"
58
- curl -fsSL "$S3/$ARTIFACT" -o "$ZIP" --max-time 1200
59
- if [ ! -s "$ZIP" ]; then echo "下载失败或为空: $ARTIFACT"; finish "ABORT_DOWNLOAD ($ARTIFACT)" 1; fi
66
+ # 断点续传:网慢的设备 20 分钟下不完 500MB 的 latest.zip(10-07/08 的 9002 每天 1200 秒超时、白下一遍)。
67
+ # 半截文件放在 STAGE 外面跨次保留,下次 curl -C - 接着下;远端 ETag 变了(发了新版)就作废重下。
68
+ # root(cron)和 aaden(补跑 / 云端命令)都会跑,目录 777、文件归当前用户。
69
+ DL=/var/tmp/aaden-lu-dl
70
+ PART="$DL/$ARTIFACT.part"
71
+ sudoS "mkdir -p $DL && chmod 777 $DL && { chown -f $(id -un):$(id -gn) $DL/* 2>/dev/null; true; }"
72
+ HEAD=$(curl -sfI "$S3/$ARTIFACT" --max-time 30 | tr -d '\r')
73
+ ETAG=$(printf '%s\n' "$HEAD" | awk 'tolower($1)=="etag:"{print $2}' | tr -d '"')
74
+ SIZE=$(printf '%s\n' "$HEAD" | awk 'tolower($1)=="content-length:"{print $2}')
75
+ if [ -f "$PART" ] && [ "$(cat "$DL/$ARTIFACT.etag" 2>/dev/null)" != "$ETAG" ]; then
76
+ echo "远端 $ARTIFACT 换了(ETag 变化),丢掉上次下了一半的"; rm -f "$PART"
77
+ fi
78
+ echo "$ETAG" > "$DL/$ARTIFACT.etag"
79
+ [ -f "$PART" ] && echo "接着上次续传:已有 $(( $(stat -c %s "$PART") / 1048576 )) MB"
80
+ curl -fsSL -C - "$S3/$ARTIFACT" -o "$PART" --max-time 1200
81
+ GOT=$(stat -c %s "$PART" 2>/dev/null || echo 0)
82
+ if [ -n "$SIZE" ] && [ "$GOT" -lt "$SIZE" ]; then
83
+ # 没下完:半截留着下次接着下,这次什么都不动
84
+ finish "SKIP_DOWNLOAD_PARTIAL ($ARTIFACT 已下 $((GOT / 1048576))/$((SIZE / 1048576)) MB,下次续传)" 0
85
+ fi
86
+ if [ ! -s "$PART" ]; then echo "下载失败或为空: $ARTIFACT"; finish "ABORT_DOWNLOAD ($ARTIFACT)" 1; fi
87
+ mv -f "$PART" "$ZIP" && rm -f "$DL/$ARTIFACT.etag"
60
88
  unzip -q -o "$ZIP" -d "$STAGE" && rm -f "$ZIP"
61
89
 
62
90
  # ---- 关键:破坏任何东西之前,先确认源 PHP 代码确实在 ----
@@ -0,0 +1,51 @@
1
+ #!/bin/bash
2
+ # 重启 ngrok 隧道。云端发现「设备还在上报、ngrok 却断了 30 分钟以上」时自动下发(RESTART_NGROK 命令),
3
+ # ERP「设备命令」里也能手动下发。输出整段回传云端,ERP 里能看到 ngrok 日志末尾。
4
+ # 退出码:ngrok 进程起来了 = 0,否则 1。
5
+ sudoS() { echo "Innerken322." | sudo -S -p '' "$@"; }
6
+ SVC=aaden-ngrok-v3
7
+ NGROK=/etc/aaden/ngrok
8
+ CONF=/etc/aaden/ngrok-config.yml
9
+ LOG=/etc/aaden/ngrok.log
10
+
11
+ if [ -d /run/systemd/system ] && [ -f "/etc/systemd/system/$SVC.service" ]; then
12
+ echo "重启 systemd 服务 $SVC"
13
+ sudoS systemctl restart "$SVC"
14
+ elif [ -x "$NGROK" ] && [ -f "$CONF" ]; then
15
+ # 没有 systemd 的老 WSL:直接重启进程
16
+ echo "没有 systemd 服务,直接重启 ngrok 进程"
17
+ sudoS pkill -f "$NGROK " 2>/dev/null
18
+ sleep 2
19
+ sudoS sh -c "nohup $NGROK -config=$CONF start-all --log=$LOG >/dev/null 2>&1 &"
20
+ else
21
+ echo "ngrok 没装好(缺 $NGROK 或 $CONF),跑完整修复 fix-ngrok"
22
+ aaden-cli fix-ngrok
23
+ fi
24
+
25
+ report() {
26
+ echo "--- 状态"
27
+ [ -d /run/systemd/system ] && echo "systemd: $(systemctl is-active "$SVC" 2>&1)"
28
+ echo "--- ngrok 配置"
29
+ sudoS grep -E "server_addr|remote_port|hostname|subdomain" "$CONF" 2>/dev/null
30
+ echo "--- $LOG 最后 15 行"
31
+ sudoS tail -n 15 "$LOG" 2>/dev/null
32
+ if [ -d /run/systemd/system ]; then
33
+ echo "--- 服务日志"
34
+ sudoS journalctl -u "$SVC" -n 15 --no-pager -q 2>/dev/null
35
+ fi
36
+ echo "--- 进程"
37
+ pgrep -a -f "$NGROK "
38
+ }
39
+
40
+ sleep 20
41
+ report && exit 0
42
+
43
+ # 重启了还起不来(10-08 的 1233:systemd 一直 activating、进程不在)多半是二进制或服务文件坏了:
44
+ # 跑一次完整修复(重新下载 ngrok、重写服务),再看一次
45
+ echo
46
+ echo "ngrok 进程不在,跑完整修复 fix-ngrok"
47
+ aaden-cli fix-ngrok
48
+ sleep 20
49
+ report && exit 0
50
+ echo "ngrok 进程仍然不在"
51
+ exit 1
@@ -9,6 +9,11 @@
9
9
  # 写死 /usr/local/bin/aaden-cli 在那种设备上会失败,而保留 "$PATH" 两者都能命中。
10
10
  export PATH="/usr/local/bin:$PATH"
11
11
 
12
+ # 当前目录切到 /:auto-upgrade 是在包目录(.../node_modules/aaden-cli/AadenDeploy)里起的本脚本,
13
+ # 而 npm 重装会把整个包目录换掉——当前目录没了,之后的 npm 一启动就 ENOENT uv_cwd(10-08 的 1541/1333/1171:
14
+ # npm=1 npm2=1 "syscall: 'uv_cwd'")。下面所有步骤都不依赖当前目录。
15
+ cd / || true
16
+
12
17
  # 每一步的退出码和耗时写进 $AADEN_UPGRADE_STEPS(auto-upgrade 读出来随「自动执行了更新aaden-cli」事件上报),
13
18
  # 失败的那步再带上输出末尾。起因:2026-10 约 35 台在 cron 里 auto-upgrade 3 秒跑完、后端从不更新,
14
19
  # 同样的命令 SSH 进去跑却正常,而 /var/log/aaden-auto-upgrade.log 远程读不到。
@@ -31,13 +36,15 @@ step() {
31
36
  return "$code"
32
37
  }
33
38
 
34
- step config sh -c 'mkdir -p /etc/aaden/config && chmod -R 777 /etc/aaden/config'
35
39
 
36
40
  # 和 aaden-cli 其他地方一样用 -S 从 stdin 喂密码。aaden 没有免密 sudo 的设备上,裸 sudo 没有终端读不了密码、直接失败
37
41
  # (10-05 步骤上报:clean=1 npm=1 "a terminal is required to read the password"),npm 永远装不上,CLI 卡在 1.6.101。
38
42
  # root 跑(cron)时 sudo 不要密码,多喂的一行没人读,无害。
39
43
  sudoS() { echo "Innerken322." | sudo -S "$@"; }
40
44
 
45
+ # 以 aaden 跑(远程 selfUpgrade、云端命令、do-log 补跑)时改不了 root 建的目录权限,也要 sudo
46
+ step config sudoS sh -c 'mkdir -p /etc/aaden/config && chmod -R 777 /etc/aaden/config'
47
+
41
48
  # 别的 aaden-cli npm 安装还在跑时,不能动手:
42
49
  # crontab 里 04:55 和开机 sleep 30 各有一条单独的 `npm install aaden-cli -g`,慢的设备上要跑好几分钟,
43
50
  # 正好撞上 04:59 / 开机 sleep 60 的这里。两个 npm 同时重装同一个包 —— 我们这个 1 秒内 ENOTEMPTY(退出码 217),
package/dist/index.js CHANGED
@@ -80,6 +80,318 @@ module.exports = {
80
80
  }
81
81
 
82
82
 
83
+ /***/ }),
84
+
85
+ /***/ 5363:
86
+ /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => {
87
+
88
+ const fs = __nccwpck_require__(7147)
89
+ const os = __nccwpck_require__(2037)
90
+ const path = __nccwpck_require__(1017)
91
+ const http = __nccwpck_require__(3685)
92
+ const https = __nccwpck_require__(5687)
93
+ const {spawn, execFileSync} = __nccwpck_require__(2081)
94
+
95
+ // 拉取式设备命令 + 自动补跑。
96
+ //
97
+ // 以前云端要让设备做事,只能经 ngrok POST 门店 PHP(UpdateSelf.php),ngrok 一断就够不着;
98
+ // 而 WSL 设备的宿主机夜里睡眠会错过 04:59、又不重启,@reboot 也不触发,自动更新可能几天都不跑。
99
+ // 设备每 5 分钟的 do-log 是唯一一直在跑的东西,所以把两件事都挂在它上面:
100
+ // ① do-log 上报时声明 acceptCommands,云端在回复里带上这台设备待执行的命令(ERP 下发或云端自动下发)
101
+ // → 起一个后台进程逐条认领、执行,输出每 10 秒回传一次(ERP 里能看到回显),最后报退出码。
102
+ // ② 超过 20 小时没跑过 auto-upgrade 就自己补跑一次(避开午市晚市和刚开机)。
103
+ //
104
+ // 只执行下面写死的几种命令,不执行云端发来的任意 shell。
105
+ // 注意:门店设备上的 node 最老到 10,这里不能用 ?. / ?? 等新语法。
106
+
107
+ // 只为本地联调留的口子;设备上从不设置
108
+ const CLOUD = process.env.AADEN_CLOUD_URL || 'https://cloud-v2.aaden.io'
109
+
110
+ const COMMANDS = {
111
+ AUTO_UPGRADE: () => 'aaden-cli auto-upgrade',
112
+ LIGHT_UPDATE: () => 'aaden-cli light-update',
113
+ APPCENTER_SYNC: () => 'aaden-cli appcenter-sync',
114
+ PATCH: () => 'aaden-cli patch',
115
+ RESTART_NGROK: root => 'bash ' + JSON.stringify(path.join(root, 'AadenDeploy/restartNgrok.sh')),
116
+ // 诊断脚本在门店后端里(≥ 1.9.80):do-log 坏了的设备由云端经 ngrok 触发同一个脚本
117
+ DIAGNOSE: () => 'S=/var/www/html/PHP/scripts/diagnose.sh; if [ -f "$S" ]; then bash "$S"; ' +
118
+ 'else echo "后端低于 1.9.80,没有诊断脚本:先下发「只更新后端」"; exit 2; fi',
119
+ }
120
+
121
+ /** 每条命令最多跑多久;latest.zip 全量在慢网络上要 20 分钟 */
122
+ const COMMAND_TIMEOUT_MS = 45 * 60 * 1000
123
+ const REPORT_INTERVAL_MS = 10 * 1000
124
+ const MAX_OUTPUT = 60 * 1024
125
+
126
+ /** 每次 auto-upgrade 开始时写;cron(root)和补跑(aaden)都要能写,所以 chmod 666 */
127
+ const AUTO_UPGRADE_STAMP = '/etc/aaden/config/lastAutoUpgrade'
128
+ const CATCH_UP_AFTER_MS = 20 * 3600 * 1000
129
+ /** 午市、晚市不补跑(设备本地时间,分钟) */
130
+ const PEAK_WINDOWS = [[11 * 60 + 30, 14 * 60 + 30], [17 * 60 + 30, 21 * 60 + 30]]
131
+ /** 开机 10 分钟内不补跑:@reboot 的 npm + auto-upgrade 会自己跑 */
132
+ const MIN_UPTIME_S = 10 * 60
133
+
134
+ function userName() {
135
+ try {
136
+ return os.userInfo().username
137
+ } catch (e) {
138
+ return String(process.getuid ? process.getuid() : 'x')
139
+ }
140
+ }
141
+
142
+ function tmpFile(name) {
143
+ return path.join(os.tmpdir(), name + '-' + userName())
144
+ }
145
+
146
+ function postJson(url, body, timeoutMs) {
147
+ return new Promise((resolve, reject) => {
148
+ const data = Buffer.from(JSON.stringify(body))
149
+ const req = (url.indexOf('http://') === 0 ? http : https).request(url, {
150
+ method: 'POST',
151
+ headers: {'Content-Type': 'application/json', 'Content-Length': data.length},
152
+ timeout: timeoutMs || 20000,
153
+ }, res => {
154
+ const chunks = []
155
+ res.on('data', c => chunks.push(c))
156
+ res.on('end', () => {
157
+ const text = Buffer.concat(chunks).toString('utf8')
158
+ let json = null
159
+ try {
160
+ json = JSON.parse(text)
161
+ } catch (e) { /* 非 JSON */ }
162
+ if (res.statusCode >= 200 && res.statusCode < 300) resolve(json)
163
+ else reject(Object.assign(new Error('HTTP ' + res.statusCode + ' ' + text.slice(0, 200)), {status: res.statusCode}))
164
+ })
165
+ })
166
+ req.on('timeout', () => req.destroy(new Error('timeout')))
167
+ req.on('error', reject)
168
+ req.end(data)
169
+ })
170
+ }
171
+
172
+ /** 后台起一个 aaden-cli 子命令,do-log 退出后它照样跑;输出追加进 /tmp 下的日志 */
173
+ function spawnDetached(args, logName, env) {
174
+ let fd = 'ignore'
175
+ try {
176
+ fd = fs.openSync(tmpFile(logName) + '.log', 'a')
177
+ } catch (e) { /* 写不了日志也照跑 */ }
178
+ const child = spawn(process.execPath, [process.argv[1]].concat(args), {
179
+ detached: true,
180
+ stdio: ['ignore', fd, fd],
181
+ env: Object.assign({}, process.env, env || {}),
182
+ })
183
+ child.unref()
184
+ return child.pid
185
+ }
186
+
187
+ // ------------------------------------------------------------------ ① 命令
188
+
189
+ /** do-log 拿到云端回复后调用:有命令就起后台进程去执行 */
190
+ function handleOffers(deviceId, reply) {
191
+ const body = reply && reply.data && reply.data.commands ? reply.data : reply
192
+ const commands = (body && Array.isArray(body.commands) ? body.commands : [])
193
+ .filter(c => c && c.id && c.command)
194
+ if (commands.length === 0) return []
195
+ const items = commands.map(c => c.id + ':' + c.command)
196
+ console.log('云端有待执行命令:', items.join(' '))
197
+ spawnDetached(['run-commands', String(deviceId)].concat(items), 'aaden-command-runner')
198
+ return commands
199
+ }
200
+
201
+ function lockFile() {
202
+ return tmpFile('aaden-command-runner') + '.pid'
203
+ }
204
+
205
+ /** 同一时间只有一个执行进程;上一个还在跑就退出,没领到的命令下次 do-log 会再给 */
206
+ function acquireLock() {
207
+ const file = lockFile()
208
+ try {
209
+ const pid = parseInt(fs.readFileSync(file, 'utf8'), 10)
210
+ if (pid && pid !== process.pid) {
211
+ try {
212
+ process.kill(pid, 0)
213
+ return false
214
+ } catch (e) {
215
+ if (e.code === 'EPERM') return false
216
+ }
217
+ }
218
+ } catch (e) { /* 没有锁文件 */ }
219
+ try {
220
+ fs.writeFileSync(file, String(process.pid))
221
+ return true
222
+ } catch (e) {
223
+ return false
224
+ }
225
+ }
226
+
227
+ function releaseLock() {
228
+ try {
229
+ if (parseInt(fs.readFileSync(lockFile(), 'utf8'), 10) === process.pid) fs.unlinkSync(lockFile())
230
+ } catch (e) { /* 已经没了 */ }
231
+ }
232
+
233
+ function stripAnsi(s) {
234
+ return s.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r(?!\n)/g, '\n')
235
+ }
236
+
237
+ // aaden-cli 打印要执行的命令时会带上 sudo 密码;输出要存云端、ERP 里能看到,存之前抹掉
238
+ const SECRETS = ['Innerken322.']
239
+
240
+ function redact(s) {
241
+ return SECRETS.reduce((acc, secret) => acc.split(secret).join('***'), s)
242
+ }
243
+
244
+ async function report(id, deviceId, output, exitCode) {
245
+ const body = {deviceId: String(deviceId), output: output}
246
+ if (exitCode !== undefined) body.exitCode = exitCode
247
+ // 最终结果多试几次;中途的回报丢了无所谓,下一次会整段覆盖
248
+ const attempts = exitCode === undefined ? 1 : 3
249
+ for (let i = 0; i < attempts; i++) {
250
+ try {
251
+ await postJson(CLOUD + '/device-command/' + id + '/report', body)
252
+ return true
253
+ } catch (e) {
254
+ console.log('回报命令 #' + id + ' 失败:', e.message)
255
+ if (e.status === 409 || e.status === 403 || e.status === 404) return false
256
+ await new Promise(r => setTimeout(r, 5000))
257
+ }
258
+ }
259
+ return false
260
+ }
261
+
262
+ function runOne(id, deviceId, shellCmd) {
263
+ return new Promise(resolve => {
264
+ let output = ''
265
+ let dirty = false
266
+ const append = chunk => {
267
+ output += redact(stripAnsi(chunk.toString('utf8')))
268
+ if (output.length > MAX_OUTPUT) output = output.slice(output.length - MAX_OUTPUT)
269
+ dirty = true
270
+ }
271
+ // 登录 shell:cron 下 PATH 只有 /usr/bin:/bin,aaden-cli 在 /usr/local/bin
272
+ const child = spawn('bash', ['-lc', 'export PATH="/usr/local/bin:$PATH"; ' + shellCmd], {
273
+ detached: true,
274
+ stdio: ['ignore', 'pipe', 'pipe'],
275
+ // AADEN_PW 给诊断脚本用 sudo(它在公网可读的 docroot 下,不能写密码);输出回传前会打码
276
+ env: Object.assign({}, process.env, {AADEN_UPGRADE_TRIGGER: '命令 #' + id, AADEN_PW: SECRETS[0]}),
277
+ })
278
+ child.stdout.on('data', append)
279
+ child.stderr.on('data', append)
280
+ const timer = setInterval(() => {
281
+ if (!dirty) return
282
+ dirty = false
283
+ report(id, deviceId, output)
284
+ }, REPORT_INTERVAL_MS)
285
+ const killer = setTimeout(() => {
286
+ append('\n[aaden-cli] 超过 ' + (COMMAND_TIMEOUT_MS / 60000) + ' 分钟,终止\n')
287
+ try {
288
+ process.kill(-child.pid, 'SIGTERM')
289
+ } catch (e) { /* 已经退出 */ }
290
+ }, COMMAND_TIMEOUT_MS)
291
+ child.on('error', e => append('\n[aaden-cli] 启动失败: ' + e.message + '\n'))
292
+ child.on('close', (code, signal) => {
293
+ clearInterval(timer)
294
+ clearTimeout(killer)
295
+ const exitCode = typeof code === 'number' ? code : 128
296
+ if (signal) append('\n[aaden-cli] 被信号 ' + signal + ' 终止\n')
297
+ resolve({exitCode: exitCode, output: output})
298
+ })
299
+ })
300
+ }
301
+
302
+ /** `aaden-cli run-commands <deviceId> <id:COMMAND>...`:由 do-log 在后台起,逐条认领、执行、回报 */
303
+ async function runCommands(rootDir, version, deviceId, items) {
304
+ if (!acquireLock()) {
305
+ console.log('已有命令执行进程在跑,这次不领')
306
+ return
307
+ }
308
+ try {
309
+ for (const item of items) {
310
+ const parts = String(item).split(':')
311
+ const id = parseInt(parts[0], 10)
312
+ const name = parts[1]
313
+ if (!id || !name) continue
314
+ try {
315
+ await postJson(CLOUD + '/device-command/' + id + '/start', {deviceId: String(deviceId), cliVersion: version})
316
+ } catch (e) {
317
+ console.log('认领命令 #' + id + ' 失败(可能已被领走或取消):', e.message)
318
+ continue
319
+ }
320
+ const build = COMMANDS[name]
321
+ if (!build) {
322
+ await report(id, deviceId, '这个版本的 aaden-cli (' + version + ') 不认识命令 ' + name, 2)
323
+ continue
324
+ }
325
+ const cmd = build(rootDir)
326
+ console.log('执行命令 #' + id + ' ' + name + ': ' + cmd)
327
+ const result = await runOne(id, deviceId, cmd)
328
+ await report(id, deviceId, '$ ' + cmd + '\n' + result.output, result.exitCode)
329
+ }
330
+ } finally {
331
+ releaseLock()
332
+ }
333
+ }
334
+
335
+ // ------------------------------------------------------------------ ② 自动补跑
336
+
337
+ function markAutoUpgradeStarted() {
338
+ try {
339
+ fs.writeFileSync(AUTO_UPGRADE_STAMP, String(Date.now()))
340
+ fs.chmodSync(AUTO_UPGRADE_STAMP, 0o666)
341
+ } catch (e) { /* 写不了就靠补跑自己的时间戳兜底 */ }
342
+ }
343
+
344
+ function readStamp(file) {
345
+ try {
346
+ const n = parseInt(fs.readFileSync(file, 'utf8'), 10)
347
+ if (n > 0) return n
348
+ return fs.statSync(file).mtimeMs
349
+ } catch (e) {
350
+ return 0
351
+ }
352
+ }
353
+
354
+ function catchUpStampFile() {
355
+ return path.join(os.homedir(), '.aaden-catchup')
356
+ }
357
+
358
+ function autoUpgradeRunning() {
359
+ try {
360
+ // 不经 shell 直接调 pgrep:经 sh -c 的话 sh 自己的命令行里就有这串字,会把自己当成「正在跑」
361
+ const pids = execFileSync('pgrep', ['-f', 'aaden-cli auto-upgrade'], {encoding: 'utf8', timeout: 5000})
362
+ .split('\n').map(s => parseInt(s, 10)).filter(pid => pid && pid !== process.pid)
363
+ return pids.length > 0
364
+ } catch (e) {
365
+ return false
366
+ }
367
+ }
368
+
369
+ /**
370
+ * do-log 每次上报后调用:超过 20 小时没跑过 auto-upgrade 就在后台补跑一次。
371
+ * 返回没补跑的原因(或 'started'),只用于日志。
372
+ */
373
+ function maybeCatchUpAutoUpgrade() {
374
+ const now = new Date()
375
+ const minutes = now.getHours() * 60 + now.getMinutes()
376
+ if (PEAK_WINDOWS.some(w => minutes >= w[0] && minutes < w[1])) return 'peak'
377
+ if (os.uptime() < MIN_UPTIME_S) return 'boot'
378
+ const last = Math.max(readStamp(AUTO_UPGRADE_STAMP), readStamp(catchUpStampFile()))
379
+ if (Date.now() - last < CATCH_UP_AFTER_MS) return 'recent'
380
+ if (autoUpgradeRunning()) return 'running'
381
+ // 先把补跑时间戳写上:写不上就不补跑,免得每 5 分钟起一次
382
+ try {
383
+ fs.writeFileSync(catchUpStampFile(), String(Date.now()))
384
+ } catch (e) {
385
+ return 'no-stamp'
386
+ }
387
+ const hours = last ? Math.round((Date.now() - last) / 3600000) + ' 小时' : '很久'
388
+ spawnDetached(['auto-upgrade'], 'aaden-catchup', {AADEN_UPGRADE_TRIGGER: '补跑:' + hours + '没跑过自动更新'})
389
+ return 'started'
390
+ }
391
+
392
+ module.exports = {handleOffers, runCommands, markAutoUpgradeStarted, maybeCatchUpAutoUpgrade}
393
+
394
+
83
395
  /***/ }),
84
396
 
85
397
  /***/ 7415:
@@ -112,7 +424,7 @@ async function deviceEcho(data) {
112
424
  })
113
425
  const {frontendVersion, cliVersion, diskUsage, upTime, ngrokIsNew, hostInfo, tseInfo} = Object.assign({}, defaultData, data)
114
426
  if (parseInt(myDeviceId) === -1) {
115
- return
427
+ return null
116
428
  }
117
429
  const {name, adress1, postCode, city, state} = await getNiceRestaurantInfo()
118
430
  const waitSecond = (parseInt(myDeviceId) % 30) * 1000
@@ -121,7 +433,7 @@ async function deviceEcho(data) {
121
433
  resolve()
122
434
  }, waitSecond))
123
435
  try {
124
- await hillo.jsonPost('https://cloud-v2.aaden.io/deviceLog/create', {
436
+ const reply = await hillo.jsonPost('https://cloud-v2.aaden.io/deviceLog/create', {
125
437
  deviceId: myDeviceId,
126
438
  frontendVersion: frontendVersion,
127
439
  cliVersion,
@@ -133,13 +445,17 @@ async function deviceEcho(data) {
133
445
  ngrokOk: ngrokIsNew,
134
446
  deviceName: `${name}\n${adress1}\n${postCode} ${city} ${state}`,
135
447
  ...hostInfo,
136
- ...tseInfo
448
+ ...tseInfo,
449
+ // 回复里要带待执行命令(见 modules/device-commands.js);老云端忽略这个字段
450
+ acceptCommands: true
137
451
  })
138
452
  console.log('report to remote server complete')
453
+ return {deviceId: myDeviceId, reply}
139
454
  } catch (e) {
140
455
  console.log(e.message)
141
456
  console.log('report to remote server failed')
142
457
  }
458
+ return null
143
459
 
144
460
 
145
461
  }
@@ -18863,7 +19179,7 @@ module.exports = JSON.parse('{"name":"axios","version":"0.19.2","description":"P
18863
19179
  /***/ ((module) => {
18864
19180
 
18865
19181
  "use strict";
18866
- module.exports = {"i8":"1.6.111"};
19182
+ module.exports = {"i8":"1.6.113"};
18867
19183
 
18868
19184
  /***/ })
18869
19185
 
@@ -18951,6 +19267,7 @@ const prompts = __nccwpck_require__(9646)
18951
19267
  const {deviceEcho, logAction, getCurrentDeviceId} = __nccwpck_require__(7415)
18952
19268
  const {collectHostInfo} = __nccwpck_require__(3727)
18953
19269
  const {collectTseInfo} = __nccwpck_require__(6677)
19270
+ const deviceCommands = __nccwpck_require__(5363)
18954
19271
  const GlobalDeviceId = 'notset'
18955
19272
 
18956
19273
  const {program} = __nccwpck_require__(1904)
@@ -19010,6 +19327,9 @@ program.command('auto-send-tea-maker-task').action(autoSendTeaMakerTask)
19010
19327
  program.command('export-tse').action(exportTse)
19011
19328
  program.command('fix-ngrok').action(fixNgrok)
19012
19329
  program.command('do-log').action(doLog)
19330
+ // do-log 领到云端命令后在后台起的执行进程(见 modules/device-commands.js),不手动调
19331
+ program.command('run-commands <deviceId> [items...]').action((deviceId, items) =>
19332
+ deviceCommands.runCommands(path.resolve(findMe(), '..'), version, deviceId, items || []))
19013
19333
  program.command('clean-backup-space').action(doCompress)
19014
19334
  program.command('ensure-print-log-rotate').action(ensurePrintLogRotate)
19015
19335
 
@@ -19516,6 +19836,8 @@ async function lightUpdate() {
19516
19836
  const suffix = status ? ' [' + status + ']' : ''
19517
19837
  const newVersion = readBackendVersionFresh()
19518
19838
  if (code !== 0) {
19839
+ // 退出码带出去:upgrade.sh 的 lu 步骤和云端命令的结果才看得出 light-update 失败了
19840
+ process.exitCode = code || 1
19519
19841
  await logAction('light-update 失败/未部署 (仍 ' + newVersion + ')' + suffix, code || 1)
19520
19842
  } else if (newVersion !== localVersion) {
19521
19843
  await logAction('light-update 完成 ' + localVersion + ' -> ' + newVersion + suffix, 0)
@@ -19700,11 +20022,18 @@ function getMyDeviceId() {
19700
20022
  }
19701
20023
 
19702
20024
  async function autoUpgrade() {
20025
+ // 记下这次开始的时间:do-log 据此判断要不要补跑(超过 20 小时没跑过)
20026
+ deviceCommands.markAutoUpgradeStarted()
19703
20027
  if (!ngrokNewestVersion()) {
19704
20028
  await fixNgrok()
19705
20029
  }
19706
20030
  const summary = await upgrade()
19707
- await logAction("自动执行了更新aaden-cli" + summary)
20031
+ // 谁触发的:cron / 开机不带;do-log 补跑、云端命令会带上(AADEN_UPGRADE_TRIGGER)
20032
+ const trigger = process.env.AADEN_UPGRADE_TRIGGER ? ' (' + process.env.AADEN_UPGRADE_TRIGGER + ')' : ''
20033
+ console.log('自动执行了更新aaden-cli' + trigger + summary)
20034
+ await logAction("自动执行了更新aaden-cli" + trigger + summary)
20035
+ // 有步骤失败就非 0 退出,云端命令的结果才看得出来
20036
+ if (/=[1-9]|upgrade\.sh 没跑起来/.test(summary)) process.exitCode = 1
19708
20037
  }
19709
20038
 
19710
20039
  /**
@@ -19834,20 +20163,32 @@ function getUpTime() {
19834
20163
 
19835
20164
  }
19836
20165
 
20166
+ /** 单项探测失败(命令卡住、输出不对)就报空值,不能让整个 do-log 挂掉 */
20167
+ function doLogField(fn) {
20168
+ try {
20169
+ return fn() || ''
20170
+ } catch (e) {
20171
+ console.log('do-log 探测失败:', e.message)
20172
+ return ''
20173
+ }
20174
+ }
20175
+
19837
20176
  async function doLog() {
19838
- const res = cli.executeInShellSync('snap info aaden')
19839
- const diskUsage =
19840
- cli.executeInShellSync('df -h --total | command grep total | df -h --total | command grep total | head -c-3 && echo \'\'',)
19841
- .output.split(' ')
19842
- const log = res.output
19843
- const list = log.split('\n')
19844
- const frontendVersion = list[list.length - 2].replace(/\s+/g, ' ')
19845
- .split(' ')[1]
19846
-
19847
- await deviceEcho({
20177
+ // snap info 加超时:snapd 刷新卡住时它会一直不返回,do-log 就再也报不上来,
20178
+ // 设备在云端看就是「失联」,云端命令也领不到
20179
+ const frontendVersion = doLogField(() => {
20180
+ const list = (cli.executeInShellSync('timeout 20 snap info aaden').output || '').split('\n')
20181
+ return (list[list.length - 2] || '').replace(/\s+/g, ' ').split(' ')[1]
20182
+ })
20183
+ const diskUsage = doLogField(() => {
20184
+ const parts = (cli.executeInShellSync('timeout 20 df -h --total | command grep total | head -c-3 && echo \'\'').output || '').split(' ')
20185
+ return parts[parts.length - 1].replace('\n', '')
20186
+ })
20187
+
20188
+ const echo = await deviceEcho({
19848
20189
  frontendVersion,
19849
20190
  cliVersion: version,
19850
- diskUsage: diskUsage[diskUsage.length - 1].replace('\n', ''),
20191
+ diskUsage,
19851
20192
  upTime: getUpTime(),
19852
20193
  ngrokIsNew: ngrokNewestVersion(),
19853
20194
  hostInfo: collectHostInfo(),
@@ -19856,6 +20197,25 @@ async function doLog() {
19856
20197
  )
19857
20198
  console.log('finish')
19858
20199
 
20200
+ // 云端回复里的待执行命令:起后台进程去执行(do-log 自己马上退出)
20201
+ let offered = []
20202
+ if (echo && echo.reply) {
20203
+ try {
20204
+ offered = deviceCommands.handleOffers(echo.deviceId, echo.reply)
20205
+ } catch (e) {
20206
+ console.log('处理云端命令失败:', e.message)
20207
+ }
20208
+ }
20209
+ // 超过 20 小时没跑过 auto-upgrade 就补跑(WSL 宿主机夜里睡眠错过 04:59 等);这一轮已经有命令要跑就先不补
20210
+ if (echo && offered.length === 0) {
20211
+ try {
20212
+ const r = deviceCommands.maybeCatchUpAutoUpgrade()
20213
+ console.log('auto-upgrade 补跑检查:', r)
20214
+ if (r === 'started') await logAction('超过 20 小时没跑过自动更新,后台补跑一次')
20215
+ } catch (e) {
20216
+ console.log('补跑检查失败:', e.message)
20217
+ }
20218
+ }
19859
20219
 
19860
20220
  process.exit(0)
19861
20221
  }
@@ -0,0 +1,42 @@
1
+ #!/bin/bash
2
+ # 一次性修复:aaden 的 crontab 里 do-log(每 5 分钟上报 + 领云端命令 + 自动补跑)那一行
3
+ # 缺了、或者指向的 aaden-cli 不存在时,改成标准写法 /usr/local/bin/aaden-cli do-log。
4
+ # 起因:10-07 1541 在 npm 并发重装之后 CLI 已经修好、远程更新也都成功,do-log 却再没上报过;
5
+ # 老设备上这一行有写裸 aaden-cli(cron 的 PATH 只有 /usr/bin:/bin)或 /usr/bin/aaden-cli 的。
6
+ # 只动这一行,其他行原样保留;改了的话把原来那行报给云端,方便事后看是哪种情况。
7
+ sudoS() { echo "Innerken322." | sudo -S -p '' "$@"; }
8
+ CLI=/usr/local/bin/aaden-cli
9
+ WANT="*/5 * * * * $CLI do-log"
10
+
11
+ [ -x "$CLI" ] || { echo "没有 $CLI,跳过"; exit 0; }
12
+ # 有的老设备 do-log 写在 root 的 crontab 或 /etc/cron.d 里:那样就别再往 aaden 这边加,不然每 5 分钟报两次
13
+ if sudoS crontab -l -u root 2>/dev/null | grep -v '^[[:space:]]*#' | grep -q 'do-log' \
14
+ || grep -rqs --exclude='*.dpkg-*' 'aaden-cli do-log' /etc/cron.d 2>/dev/null; then
15
+ echo "do-log 在 root 的 crontab 或 /etc/cron.d 里,不动 aaden 的"; exit 0
16
+ fi
17
+ CUR=$(sudoS crontab -l -u aaden 2>/dev/null) || { echo "aaden 没有 crontab,跳过(整套定时任务缺失要 aaden-cli fix-cronjob)"; exit 0; }
18
+
19
+ LINE=$(printf '%s\n' "$CUR" | grep -v '^[[:space:]]*#' | grep -m1 'do-log')
20
+ if [ -n "$LINE" ]; then
21
+ # 第 6 个字段起是命令;@reboot 之类的特殊写法只占 1 个字段
22
+ case "$LINE" in
23
+ @*) CMD=$(printf '%s' "$LINE" | awk '{print $2}') ;;
24
+ *) CMD=$(printf '%s' "$LINE" | awk '{print $6}') ;;
25
+ esac
26
+ case "$CMD" in
27
+ "$CLI") echo "do-log 已是标准写法,不动"; exit 0 ;;
28
+ /*) [ -x "$CMD" ] && { echo "do-log 指向 $CMD,存在,不动"; exit 0; } ;;
29
+ *) [ -x "/usr/bin/$CMD" ] || [ -x "/bin/$CMD" ] && { echo "do-log 用 $CMD,cron 的 PATH 里找得到,不动"; exit 0; } ;;
30
+ esac
31
+ NEW=$(printf '%s\n' "$CUR" | awk -v line="$LINE" -v want="$WANT" '$0 == line { print want; next } { print }')
32
+ WHAT="do-log 那一行指向的命令不存在,已改成标准写法。原来是:$LINE"
33
+ else
34
+ NEW=$(printf '%s\n%s\n' "$CUR" "$WANT")
35
+ WHAT="aaden 的 crontab 里没有 do-log,已补上"
36
+ fi
37
+
38
+ printf '%s\n' "$NEW" | sudoS crontab -u aaden - || { echo "写 crontab 失败"; exit 1; }
39
+ echo "$WHAT"
40
+ DEVICE_ID=$(grep -e DeviceId /var/www/html/Resource/config/config.ini 2>/dev/null | cut -d = -f 2 | tr -d '" \r')
41
+ [ -n "$DEVICE_ID" ] && python3 -c 'import json, sys, urllib.request; urllib.request.urlopen(urllib.request.Request("https://cloud-v2.aaden.io/deviceLog/createEvent", json.dumps({"deviceId": sys.argv[1], "content": sys.argv[2] + " result:0", "type": "系统生成", "operator": "patch ensureDoLogCron"}).encode(), {"Content-Type": "application/json"}), timeout=10)' "$DEVICE_ID" "$WHAT" 2>/dev/null
42
+ exit 0
package/index.js CHANGED
@@ -14,6 +14,7 @@ const prompts = require('prompts')
14
14
  const {deviceEcho, logAction, getCurrentDeviceId} = require('./modules/firebase')
15
15
  const {collectHostInfo} = require('./modules/host-info')
16
16
  const {collectTseInfo} = require('./modules/tse-info')
17
+ const deviceCommands = require('./modules/device-commands')
17
18
  const GlobalDeviceId = 'notset'
18
19
 
19
20
  const {program} = require('commander')
@@ -73,6 +74,9 @@ program.command('auto-send-tea-maker-task').action(autoSendTeaMakerTask)
73
74
  program.command('export-tse').action(exportTse)
74
75
  program.command('fix-ngrok').action(fixNgrok)
75
76
  program.command('do-log').action(doLog)
77
+ // do-log 领到云端命令后在后台起的执行进程(见 modules/device-commands.js),不手动调
78
+ program.command('run-commands <deviceId> [items...]').action((deviceId, items) =>
79
+ deviceCommands.runCommands(path.resolve(findMe(), '..'), version, deviceId, items || []))
76
80
  program.command('clean-backup-space').action(doCompress)
77
81
  program.command('ensure-print-log-rotate').action(ensurePrintLogRotate)
78
82
 
@@ -579,6 +583,8 @@ async function lightUpdate() {
579
583
  const suffix = status ? ' [' + status + ']' : ''
580
584
  const newVersion = readBackendVersionFresh()
581
585
  if (code !== 0) {
586
+ // 退出码带出去:upgrade.sh 的 lu 步骤和云端命令的结果才看得出 light-update 失败了
587
+ process.exitCode = code || 1
582
588
  await logAction('light-update 失败/未部署 (仍 ' + newVersion + ')' + suffix, code || 1)
583
589
  } else if (newVersion !== localVersion) {
584
590
  await logAction('light-update 完成 ' + localVersion + ' -> ' + newVersion + suffix, 0)
@@ -763,11 +769,18 @@ function getMyDeviceId() {
763
769
  }
764
770
 
765
771
  async function autoUpgrade() {
772
+ // 记下这次开始的时间:do-log 据此判断要不要补跑(超过 20 小时没跑过)
773
+ deviceCommands.markAutoUpgradeStarted()
766
774
  if (!ngrokNewestVersion()) {
767
775
  await fixNgrok()
768
776
  }
769
777
  const summary = await upgrade()
770
- await logAction("自动执行了更新aaden-cli" + summary)
778
+ // 谁触发的:cron / 开机不带;do-log 补跑、云端命令会带上(AADEN_UPGRADE_TRIGGER)
779
+ const trigger = process.env.AADEN_UPGRADE_TRIGGER ? ' (' + process.env.AADEN_UPGRADE_TRIGGER + ')' : ''
780
+ console.log('自动执行了更新aaden-cli' + trigger + summary)
781
+ await logAction("自动执行了更新aaden-cli" + trigger + summary)
782
+ // 有步骤失败就非 0 退出,云端命令的结果才看得出来
783
+ if (/=[1-9]|upgrade\.sh 没跑起来/.test(summary)) process.exitCode = 1
771
784
  }
772
785
 
773
786
  /**
@@ -897,20 +910,32 @@ function getUpTime() {
897
910
 
898
911
  }
899
912
 
913
+ /** 单项探测失败(命令卡住、输出不对)就报空值,不能让整个 do-log 挂掉 */
914
+ function doLogField(fn) {
915
+ try {
916
+ return fn() || ''
917
+ } catch (e) {
918
+ console.log('do-log 探测失败:', e.message)
919
+ return ''
920
+ }
921
+ }
922
+
900
923
  async function doLog() {
901
- const res = cli.executeInShellSync('snap info aaden')
902
- const diskUsage =
903
- cli.executeInShellSync('df -h --total | command grep total | df -h --total | command grep total | head -c-3 && echo \'\'',)
904
- .output.split(' ')
905
- const log = res.output
906
- const list = log.split('\n')
907
- const frontendVersion = list[list.length - 2].replace(/\s+/g, ' ')
908
- .split(' ')[1]
909
-
910
- await deviceEcho({
924
+ // snap info 加超时:snapd 刷新卡住时它会一直不返回,do-log 就再也报不上来,
925
+ // 设备在云端看就是「失联」,云端命令也领不到
926
+ const frontendVersion = doLogField(() => {
927
+ const list = (cli.executeInShellSync('timeout 20 snap info aaden').output || '').split('\n')
928
+ return (list[list.length - 2] || '').replace(/\s+/g, ' ').split(' ')[1]
929
+ })
930
+ const diskUsage = doLogField(() => {
931
+ const parts = (cli.executeInShellSync('timeout 20 df -h --total | command grep total | head -c-3 && echo \'\'').output || '').split(' ')
932
+ return parts[parts.length - 1].replace('\n', '')
933
+ })
934
+
935
+ const echo = await deviceEcho({
911
936
  frontendVersion,
912
937
  cliVersion: version,
913
- diskUsage: diskUsage[diskUsage.length - 1].replace('\n', ''),
938
+ diskUsage,
914
939
  upTime: getUpTime(),
915
940
  ngrokIsNew: ngrokNewestVersion(),
916
941
  hostInfo: collectHostInfo(),
@@ -919,6 +944,25 @@ async function doLog() {
919
944
  )
920
945
  console.log('finish')
921
946
 
947
+ // 云端回复里的待执行命令:起后台进程去执行(do-log 自己马上退出)
948
+ let offered = []
949
+ if (echo && echo.reply) {
950
+ try {
951
+ offered = deviceCommands.handleOffers(echo.deviceId, echo.reply)
952
+ } catch (e) {
953
+ console.log('处理云端命令失败:', e.message)
954
+ }
955
+ }
956
+ // 超过 20 小时没跑过 auto-upgrade 就补跑(WSL 宿主机夜里睡眠错过 04:59 等);这一轮已经有命令要跑就先不补
957
+ if (echo && offered.length === 0) {
958
+ try {
959
+ const r = deviceCommands.maybeCatchUpAutoUpgrade()
960
+ console.log('auto-upgrade 补跑检查:', r)
961
+ if (r === 'started') await logAction('超过 20 小时没跑过自动更新,后台补跑一次')
962
+ } catch (e) {
963
+ console.log('补跑检查失败:', e.message)
964
+ }
965
+ }
922
966
 
923
967
  process.exit(0)
924
968
  }
@@ -0,0 +1,305 @@
1
+ const fs = require('fs')
2
+ const os = require('os')
3
+ const path = require('path')
4
+ const http = require('http')
5
+ const https = require('https')
6
+ const {spawn, execFileSync} = require('child_process')
7
+
8
+ // 拉取式设备命令 + 自动补跑。
9
+ //
10
+ // 以前云端要让设备做事,只能经 ngrok POST 门店 PHP(UpdateSelf.php),ngrok 一断就够不着;
11
+ // 而 WSL 设备的宿主机夜里睡眠会错过 04:59、又不重启,@reboot 也不触发,自动更新可能几天都不跑。
12
+ // 设备每 5 分钟的 do-log 是唯一一直在跑的东西,所以把两件事都挂在它上面:
13
+ // ① do-log 上报时声明 acceptCommands,云端在回复里带上这台设备待执行的命令(ERP 下发或云端自动下发)
14
+ // → 起一个后台进程逐条认领、执行,输出每 10 秒回传一次(ERP 里能看到回显),最后报退出码。
15
+ // ② 超过 20 小时没跑过 auto-upgrade 就自己补跑一次(避开午市晚市和刚开机)。
16
+ //
17
+ // 只执行下面写死的几种命令,不执行云端发来的任意 shell。
18
+ // 注意:门店设备上的 node 最老到 10,这里不能用 ?. / ?? 等新语法。
19
+
20
+ // 只为本地联调留的口子;设备上从不设置
21
+ const CLOUD = process.env.AADEN_CLOUD_URL || 'https://cloud-v2.aaden.io'
22
+
23
+ const COMMANDS = {
24
+ AUTO_UPGRADE: () => 'aaden-cli auto-upgrade',
25
+ LIGHT_UPDATE: () => 'aaden-cli light-update',
26
+ APPCENTER_SYNC: () => 'aaden-cli appcenter-sync',
27
+ PATCH: () => 'aaden-cli patch',
28
+ RESTART_NGROK: root => 'bash ' + JSON.stringify(path.join(root, 'AadenDeploy/restartNgrok.sh')),
29
+ // 诊断脚本在门店后端里(≥ 1.9.80):do-log 坏了的设备由云端经 ngrok 触发同一个脚本
30
+ DIAGNOSE: () => 'S=/var/www/html/PHP/scripts/diagnose.sh; if [ -f "$S" ]; then bash "$S"; ' +
31
+ 'else echo "后端低于 1.9.80,没有诊断脚本:先下发「只更新后端」"; exit 2; fi',
32
+ }
33
+
34
+ /** 每条命令最多跑多久;latest.zip 全量在慢网络上要 20 分钟 */
35
+ const COMMAND_TIMEOUT_MS = 45 * 60 * 1000
36
+ const REPORT_INTERVAL_MS = 10 * 1000
37
+ const MAX_OUTPUT = 60 * 1024
38
+
39
+ /** 每次 auto-upgrade 开始时写;cron(root)和补跑(aaden)都要能写,所以 chmod 666 */
40
+ const AUTO_UPGRADE_STAMP = '/etc/aaden/config/lastAutoUpgrade'
41
+ const CATCH_UP_AFTER_MS = 20 * 3600 * 1000
42
+ /** 午市、晚市不补跑(设备本地时间,分钟) */
43
+ const PEAK_WINDOWS = [[11 * 60 + 30, 14 * 60 + 30], [17 * 60 + 30, 21 * 60 + 30]]
44
+ /** 开机 10 分钟内不补跑:@reboot 的 npm + auto-upgrade 会自己跑 */
45
+ const MIN_UPTIME_S = 10 * 60
46
+
47
+ function userName() {
48
+ try {
49
+ return os.userInfo().username
50
+ } catch (e) {
51
+ return String(process.getuid ? process.getuid() : 'x')
52
+ }
53
+ }
54
+
55
+ function tmpFile(name) {
56
+ return path.join(os.tmpdir(), name + '-' + userName())
57
+ }
58
+
59
+ function postJson(url, body, timeoutMs) {
60
+ return new Promise((resolve, reject) => {
61
+ const data = Buffer.from(JSON.stringify(body))
62
+ const req = (url.indexOf('http://') === 0 ? http : https).request(url, {
63
+ method: 'POST',
64
+ headers: {'Content-Type': 'application/json', 'Content-Length': data.length},
65
+ timeout: timeoutMs || 20000,
66
+ }, res => {
67
+ const chunks = []
68
+ res.on('data', c => chunks.push(c))
69
+ res.on('end', () => {
70
+ const text = Buffer.concat(chunks).toString('utf8')
71
+ let json = null
72
+ try {
73
+ json = JSON.parse(text)
74
+ } catch (e) { /* 非 JSON */ }
75
+ if (res.statusCode >= 200 && res.statusCode < 300) resolve(json)
76
+ else reject(Object.assign(new Error('HTTP ' + res.statusCode + ' ' + text.slice(0, 200)), {status: res.statusCode}))
77
+ })
78
+ })
79
+ req.on('timeout', () => req.destroy(new Error('timeout')))
80
+ req.on('error', reject)
81
+ req.end(data)
82
+ })
83
+ }
84
+
85
+ /** 后台起一个 aaden-cli 子命令,do-log 退出后它照样跑;输出追加进 /tmp 下的日志 */
86
+ function spawnDetached(args, logName, env) {
87
+ let fd = 'ignore'
88
+ try {
89
+ fd = fs.openSync(tmpFile(logName) + '.log', 'a')
90
+ } catch (e) { /* 写不了日志也照跑 */ }
91
+ const child = spawn(process.execPath, [process.argv[1]].concat(args), {
92
+ detached: true,
93
+ stdio: ['ignore', fd, fd],
94
+ env: Object.assign({}, process.env, env || {}),
95
+ })
96
+ child.unref()
97
+ return child.pid
98
+ }
99
+
100
+ // ------------------------------------------------------------------ ① 命令
101
+
102
+ /** do-log 拿到云端回复后调用:有命令就起后台进程去执行 */
103
+ function handleOffers(deviceId, reply) {
104
+ const body = reply && reply.data && reply.data.commands ? reply.data : reply
105
+ const commands = (body && Array.isArray(body.commands) ? body.commands : [])
106
+ .filter(c => c && c.id && c.command)
107
+ if (commands.length === 0) return []
108
+ const items = commands.map(c => c.id + ':' + c.command)
109
+ console.log('云端有待执行命令:', items.join(' '))
110
+ spawnDetached(['run-commands', String(deviceId)].concat(items), 'aaden-command-runner')
111
+ return commands
112
+ }
113
+
114
+ function lockFile() {
115
+ return tmpFile('aaden-command-runner') + '.pid'
116
+ }
117
+
118
+ /** 同一时间只有一个执行进程;上一个还在跑就退出,没领到的命令下次 do-log 会再给 */
119
+ function acquireLock() {
120
+ const file = lockFile()
121
+ try {
122
+ const pid = parseInt(fs.readFileSync(file, 'utf8'), 10)
123
+ if (pid && pid !== process.pid) {
124
+ try {
125
+ process.kill(pid, 0)
126
+ return false
127
+ } catch (e) {
128
+ if (e.code === 'EPERM') return false
129
+ }
130
+ }
131
+ } catch (e) { /* 没有锁文件 */ }
132
+ try {
133
+ fs.writeFileSync(file, String(process.pid))
134
+ return true
135
+ } catch (e) {
136
+ return false
137
+ }
138
+ }
139
+
140
+ function releaseLock() {
141
+ try {
142
+ if (parseInt(fs.readFileSync(lockFile(), 'utf8'), 10) === process.pid) fs.unlinkSync(lockFile())
143
+ } catch (e) { /* 已经没了 */ }
144
+ }
145
+
146
+ function stripAnsi(s) {
147
+ return s.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').replace(/\r(?!\n)/g, '\n')
148
+ }
149
+
150
+ // aaden-cli 打印要执行的命令时会带上 sudo 密码;输出要存云端、ERP 里能看到,存之前抹掉
151
+ const SECRETS = ['Innerken322.']
152
+
153
+ function redact(s) {
154
+ return SECRETS.reduce((acc, secret) => acc.split(secret).join('***'), s)
155
+ }
156
+
157
+ async function report(id, deviceId, output, exitCode) {
158
+ const body = {deviceId: String(deviceId), output: output}
159
+ if (exitCode !== undefined) body.exitCode = exitCode
160
+ // 最终结果多试几次;中途的回报丢了无所谓,下一次会整段覆盖
161
+ const attempts = exitCode === undefined ? 1 : 3
162
+ for (let i = 0; i < attempts; i++) {
163
+ try {
164
+ await postJson(CLOUD + '/device-command/' + id + '/report', body)
165
+ return true
166
+ } catch (e) {
167
+ console.log('回报命令 #' + id + ' 失败:', e.message)
168
+ if (e.status === 409 || e.status === 403 || e.status === 404) return false
169
+ await new Promise(r => setTimeout(r, 5000))
170
+ }
171
+ }
172
+ return false
173
+ }
174
+
175
+ function runOne(id, deviceId, shellCmd) {
176
+ return new Promise(resolve => {
177
+ let output = ''
178
+ let dirty = false
179
+ const append = chunk => {
180
+ output += redact(stripAnsi(chunk.toString('utf8')))
181
+ if (output.length > MAX_OUTPUT) output = output.slice(output.length - MAX_OUTPUT)
182
+ dirty = true
183
+ }
184
+ // 登录 shell:cron 下 PATH 只有 /usr/bin:/bin,aaden-cli 在 /usr/local/bin
185
+ const child = spawn('bash', ['-lc', 'export PATH="/usr/local/bin:$PATH"; ' + shellCmd], {
186
+ detached: true,
187
+ stdio: ['ignore', 'pipe', 'pipe'],
188
+ // AADEN_PW 给诊断脚本用 sudo(它在公网可读的 docroot 下,不能写密码);输出回传前会打码
189
+ env: Object.assign({}, process.env, {AADEN_UPGRADE_TRIGGER: '命令 #' + id, AADEN_PW: SECRETS[0]}),
190
+ })
191
+ child.stdout.on('data', append)
192
+ child.stderr.on('data', append)
193
+ const timer = setInterval(() => {
194
+ if (!dirty) return
195
+ dirty = false
196
+ report(id, deviceId, output)
197
+ }, REPORT_INTERVAL_MS)
198
+ const killer = setTimeout(() => {
199
+ append('\n[aaden-cli] 超过 ' + (COMMAND_TIMEOUT_MS / 60000) + ' 分钟,终止\n')
200
+ try {
201
+ process.kill(-child.pid, 'SIGTERM')
202
+ } catch (e) { /* 已经退出 */ }
203
+ }, COMMAND_TIMEOUT_MS)
204
+ child.on('error', e => append('\n[aaden-cli] 启动失败: ' + e.message + '\n'))
205
+ child.on('close', (code, signal) => {
206
+ clearInterval(timer)
207
+ clearTimeout(killer)
208
+ const exitCode = typeof code === 'number' ? code : 128
209
+ if (signal) append('\n[aaden-cli] 被信号 ' + signal + ' 终止\n')
210
+ resolve({exitCode: exitCode, output: output})
211
+ })
212
+ })
213
+ }
214
+
215
+ /** `aaden-cli run-commands <deviceId> <id:COMMAND>...`:由 do-log 在后台起,逐条认领、执行、回报 */
216
+ async function runCommands(rootDir, version, deviceId, items) {
217
+ if (!acquireLock()) {
218
+ console.log('已有命令执行进程在跑,这次不领')
219
+ return
220
+ }
221
+ try {
222
+ for (const item of items) {
223
+ const parts = String(item).split(':')
224
+ const id = parseInt(parts[0], 10)
225
+ const name = parts[1]
226
+ if (!id || !name) continue
227
+ try {
228
+ await postJson(CLOUD + '/device-command/' + id + '/start', {deviceId: String(deviceId), cliVersion: version})
229
+ } catch (e) {
230
+ console.log('认领命令 #' + id + ' 失败(可能已被领走或取消):', e.message)
231
+ continue
232
+ }
233
+ const build = COMMANDS[name]
234
+ if (!build) {
235
+ await report(id, deviceId, '这个版本的 aaden-cli (' + version + ') 不认识命令 ' + name, 2)
236
+ continue
237
+ }
238
+ const cmd = build(rootDir)
239
+ console.log('执行命令 #' + id + ' ' + name + ': ' + cmd)
240
+ const result = await runOne(id, deviceId, cmd)
241
+ await report(id, deviceId, '$ ' + cmd + '\n' + result.output, result.exitCode)
242
+ }
243
+ } finally {
244
+ releaseLock()
245
+ }
246
+ }
247
+
248
+ // ------------------------------------------------------------------ ② 自动补跑
249
+
250
+ function markAutoUpgradeStarted() {
251
+ try {
252
+ fs.writeFileSync(AUTO_UPGRADE_STAMP, String(Date.now()))
253
+ fs.chmodSync(AUTO_UPGRADE_STAMP, 0o666)
254
+ } catch (e) { /* 写不了就靠补跑自己的时间戳兜底 */ }
255
+ }
256
+
257
+ function readStamp(file) {
258
+ try {
259
+ const n = parseInt(fs.readFileSync(file, 'utf8'), 10)
260
+ if (n > 0) return n
261
+ return fs.statSync(file).mtimeMs
262
+ } catch (e) {
263
+ return 0
264
+ }
265
+ }
266
+
267
+ function catchUpStampFile() {
268
+ return path.join(os.homedir(), '.aaden-catchup')
269
+ }
270
+
271
+ function autoUpgradeRunning() {
272
+ try {
273
+ // 不经 shell 直接调 pgrep:经 sh -c 的话 sh 自己的命令行里就有这串字,会把自己当成「正在跑」
274
+ const pids = execFileSync('pgrep', ['-f', 'aaden-cli auto-upgrade'], {encoding: 'utf8', timeout: 5000})
275
+ .split('\n').map(s => parseInt(s, 10)).filter(pid => pid && pid !== process.pid)
276
+ return pids.length > 0
277
+ } catch (e) {
278
+ return false
279
+ }
280
+ }
281
+
282
+ /**
283
+ * do-log 每次上报后调用:超过 20 小时没跑过 auto-upgrade 就在后台补跑一次。
284
+ * 返回没补跑的原因(或 'started'),只用于日志。
285
+ */
286
+ function maybeCatchUpAutoUpgrade() {
287
+ const now = new Date()
288
+ const minutes = now.getHours() * 60 + now.getMinutes()
289
+ if (PEAK_WINDOWS.some(w => minutes >= w[0] && minutes < w[1])) return 'peak'
290
+ if (os.uptime() < MIN_UPTIME_S) return 'boot'
291
+ const last = Math.max(readStamp(AUTO_UPGRADE_STAMP), readStamp(catchUpStampFile()))
292
+ if (Date.now() - last < CATCH_UP_AFTER_MS) return 'recent'
293
+ if (autoUpgradeRunning()) return 'running'
294
+ // 先把补跑时间戳写上:写不上就不补跑,免得每 5 分钟起一次
295
+ try {
296
+ fs.writeFileSync(catchUpStampFile(), String(Date.now()))
297
+ } catch (e) {
298
+ return 'no-stamp'
299
+ }
300
+ const hours = last ? Math.round((Date.now() - last) / 3600000) + ' 小时' : '很久'
301
+ spawnDetached(['auto-upgrade'], 'aaden-catchup', {AADEN_UPGRADE_TRIGGER: '补跑:' + hours + '没跑过自动更新'})
302
+ return 'started'
303
+ }
304
+
305
+ module.exports = {handleOffers, runCommands, markAutoUpgradeStarted, maybeCatchUpAutoUpgrade}
@@ -16,7 +16,7 @@ export async function deviceEcho(data) {
16
16
  })
17
17
  const {frontendVersion, cliVersion, diskUsage, upTime, ngrokIsNew, hostInfo, tseInfo} = Object.assign({}, defaultData, data)
18
18
  if (parseInt(myDeviceId) === -1) {
19
- return
19
+ return null
20
20
  }
21
21
  const {name, adress1, postCode, city, state} = await getNiceRestaurantInfo()
22
22
  const waitSecond = (parseInt(myDeviceId) % 30) * 1000
@@ -25,7 +25,7 @@ export async function deviceEcho(data) {
25
25
  resolve()
26
26
  }, waitSecond))
27
27
  try {
28
- await hillo.jsonPost('https://cloud-v2.aaden.io/deviceLog/create', {
28
+ const reply = await hillo.jsonPost('https://cloud-v2.aaden.io/deviceLog/create', {
29
29
  deviceId: myDeviceId,
30
30
  frontendVersion: frontendVersion,
31
31
  cliVersion,
@@ -37,13 +37,17 @@ export async function deviceEcho(data) {
37
37
  ngrokOk: ngrokIsNew,
38
38
  deviceName: `${name}\n${adress1}\n${postCode} ${city} ${state}`,
39
39
  ...hostInfo,
40
- ...tseInfo
40
+ ...tseInfo,
41
+ // 回复里要带待执行命令(见 modules/device-commands.js);老云端忽略这个字段
42
+ acceptCommands: true
41
43
  })
42
44
  console.log('report to remote server complete')
45
+ return {deviceId: myDeviceId, reply}
43
46
  } catch (e) {
44
47
  console.log(e.message)
45
48
  console.log('report to remote server failed')
46
49
  }
50
+ return null
47
51
 
48
52
 
49
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aaden-cli",
3
- "version": "1.6.111",
3
+ "version": "1.6.113",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -0,0 +1,42 @@
1
+ #!/bin/bash
2
+ # 一次性修复:aaden 的 crontab 里 do-log(每 5 分钟上报 + 领云端命令 + 自动补跑)那一行
3
+ # 缺了、或者指向的 aaden-cli 不存在时,改成标准写法 /usr/local/bin/aaden-cli do-log。
4
+ # 起因:10-07 1541 在 npm 并发重装之后 CLI 已经修好、远程更新也都成功,do-log 却再没上报过;
5
+ # 老设备上这一行有写裸 aaden-cli(cron 的 PATH 只有 /usr/bin:/bin)或 /usr/bin/aaden-cli 的。
6
+ # 只动这一行,其他行原样保留;改了的话把原来那行报给云端,方便事后看是哪种情况。
7
+ sudoS() { echo "Innerken322." | sudo -S -p '' "$@"; }
8
+ CLI=/usr/local/bin/aaden-cli
9
+ WANT="*/5 * * * * $CLI do-log"
10
+
11
+ [ -x "$CLI" ] || { echo "没有 $CLI,跳过"; exit 0; }
12
+ # 有的老设备 do-log 写在 root 的 crontab 或 /etc/cron.d 里:那样就别再往 aaden 这边加,不然每 5 分钟报两次
13
+ if sudoS crontab -l -u root 2>/dev/null | grep -v '^[[:space:]]*#' | grep -q 'do-log' \
14
+ || grep -rqs --exclude='*.dpkg-*' 'aaden-cli do-log' /etc/cron.d 2>/dev/null; then
15
+ echo "do-log 在 root 的 crontab 或 /etc/cron.d 里,不动 aaden 的"; exit 0
16
+ fi
17
+ CUR=$(sudoS crontab -l -u aaden 2>/dev/null) || { echo "aaden 没有 crontab,跳过(整套定时任务缺失要 aaden-cli fix-cronjob)"; exit 0; }
18
+
19
+ LINE=$(printf '%s\n' "$CUR" | grep -v '^[[:space:]]*#' | grep -m1 'do-log')
20
+ if [ -n "$LINE" ]; then
21
+ # 第 6 个字段起是命令;@reboot 之类的特殊写法只占 1 个字段
22
+ case "$LINE" in
23
+ @*) CMD=$(printf '%s' "$LINE" | awk '{print $2}') ;;
24
+ *) CMD=$(printf '%s' "$LINE" | awk '{print $6}') ;;
25
+ esac
26
+ case "$CMD" in
27
+ "$CLI") echo "do-log 已是标准写法,不动"; exit 0 ;;
28
+ /*) [ -x "$CMD" ] && { echo "do-log 指向 $CMD,存在,不动"; exit 0; } ;;
29
+ *) [ -x "/usr/bin/$CMD" ] || [ -x "/bin/$CMD" ] && { echo "do-log 用 $CMD,cron 的 PATH 里找得到,不动"; exit 0; } ;;
30
+ esac
31
+ NEW=$(printf '%s\n' "$CUR" | awk -v line="$LINE" -v want="$WANT" '$0 == line { print want; next } { print }')
32
+ WHAT="do-log 那一行指向的命令不存在,已改成标准写法。原来是:$LINE"
33
+ else
34
+ NEW=$(printf '%s\n%s\n' "$CUR" "$WANT")
35
+ WHAT="aaden 的 crontab 里没有 do-log,已补上"
36
+ fi
37
+
38
+ printf '%s\n' "$NEW" | sudoS crontab -u aaden - || { echo "写 crontab 失败"; exit 1; }
39
+ echo "$WHAT"
40
+ DEVICE_ID=$(grep -e DeviceId /var/www/html/Resource/config/config.ini 2>/dev/null | cut -d = -f 2 | tr -d '" \r')
41
+ [ -n "$DEVICE_ID" ] && python3 -c 'import json, sys, urllib.request; urllib.request.urlopen(urllib.request.Request("https://cloud-v2.aaden.io/deviceLog/createEvent", json.dumps({"deviceId": sys.argv[1], "content": sys.argv[2] + " result:0", "type": "系统生成", "operator": "patch ensureDoLogCron"}).encode(), {"Content-Type": "application/json"}), timeout=10)' "$DEVICE_ID" "$WHAT" 2>/dev/null
42
+ exit 0