aaden-cli 1.6.101 → 1.6.103-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ name: Publish to npm
2
+
3
+ # 推 tag 后发布到 npm,走 npm Trusted Publishing(OIDC):不需要 npm token,也不需要 OTP。
4
+ # npmjs.com 上 aaden-cli 的 Trusted Publisher 必须登记这个文件名(publish.yml)并允许 npm publish,改名要同步改那边。
5
+ #
6
+ # - `x.y.z`(`npm run update` 打的)→ npm `latest`。
7
+ # ⚠️ 全网约 650 台设备每天 04:55 `npm install aaden-cli -g` 装 latest:发出去就等于全网发布。
8
+ # - `x.y.z-任意后缀`(如 1.6.103-rc.0,任何分支都可以打)→ npm `next`,设备不会自动装。
9
+ # 用来灰度:在某台设备上 `sudo npm i -g aaden-cli@next` 先试;那台设备第二天 04:55 会自己回到 latest。
10
+ on:
11
+ push:
12
+ tags:
13
+ - '[0-9]+.[0-9]+.[0-9]+'
14
+ - '[0-9]+.[0-9]+.[0-9]+-*'
15
+
16
+ permissions:
17
+ contents: write # 发布成功后建 GitHub Release
18
+ id-token: write # Trusted Publishing 用 OIDC 换发布权限
19
+
20
+ concurrency:
21
+ group: npm-publish
22
+ cancel-in-progress: false
23
+
24
+ jobs:
25
+ publish:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/checkout@v6
29
+ with:
30
+ fetch-depth: 0
31
+
32
+ - uses: actions/setup-node@v6
33
+ with:
34
+ node-version: '24'
35
+ registry-url: 'https://registry.npmjs.org'
36
+ package-manager-cache: false
37
+
38
+ - name: 决定发布渠道
39
+ id: channel
40
+ run: |
41
+ if [[ "$GITHUB_REF_NAME" == *-* ]]; then
42
+ echo "dist_tag=next" >> "$GITHUB_OUTPUT"
43
+ else
44
+ echo "dist_tag=latest" >> "$GITHUB_OUTPUT"
45
+ fi
46
+
47
+ - name: 校验 tag(latest)
48
+ if: steps.channel.outputs.dist_tag == 'latest'
49
+ run: |
50
+ VERSION=$(node -p "require('./package.json').version")
51
+ if [ "$GITHUB_REF_NAME" != "$VERSION" ]; then
52
+ echo "::error::tag $GITHUB_REF_NAME 和 package.json 的版本 $VERSION 不一致"
53
+ exit 1
54
+ fi
55
+ # 只发 master 上的提交:从别的分支推个 tag 不能直接发到全网
56
+ if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/master; then
57
+ echo "::error::tag $GITHUB_REF_NAME 指向的提交不在 master 上"
58
+ exit 1
59
+ fi
60
+
61
+ # 预发布的版本号以 tag 为准,不用为了灰度去提交一次版本号
62
+ - name: 写入预发布版本号(next)
63
+ if: steps.channel.outputs.dist_tag == 'next'
64
+ run: npm version "$GITHUB_REF_NAME" --no-git-tag-version
65
+
66
+ - run: npm ci
67
+
68
+ - name: 打包
69
+ run: npx ncc build index.js -o dist -C
70
+
71
+ - name: 冒烟检查
72
+ run: |
73
+ OUT=$(node dist/index.js --version | tail -n 1)
74
+ if [ "$OUT" != "$GITHUB_REF_NAME" ]; then
75
+ echo "::error::dist/index.js --version 输出「$OUT」,期望 $GITHUB_REF_NAME"
76
+ exit 1
77
+ fi
78
+ npm pack --dry-run 2>&1 | grep -q 'dist/index.js' || {
79
+ echo "::error::npm 包里没有 dist/index.js"
80
+ exit 1
81
+ }
82
+
83
+ # 私有仓库不支持 provenance,显式关掉免得 publish 失败
84
+ - name: 发布到 npm
85
+ env:
86
+ NPM_CONFIG_PROVENANCE: 'false'
87
+ run: npm publish --tag "${{ steps.channel.outputs.dist_tag }}"
88
+
89
+ - name: GitHub Release
90
+ if: steps.channel.outputs.dist_tag == 'latest'
91
+ env:
92
+ GH_TOKEN: ${{ github.token }}
93
+ run: |
94
+ gh release create "$GITHUB_REF_NAME" \
95
+ --title "aaden-cli: release v$GITHUB_REF_NAME" \
96
+ --generate-notes \
97
+ --verify-tag
@@ -0,0 +1,166 @@
1
+ #!/bin/bash
2
+ # AppCenter 前端同步(aaden-cli appcenter-sync 的执行体)。产物由 aaden-php-backend scripts/publish-appcenter-artifact.sh 发布:
3
+ # <base>/<channel>/manifest.json + <base>/blobs/<App>-<sha>.zip
4
+ #
5
+ # light-update 只发 PHP/,AppCenter 以前只能跟 465MB 的 latest.zip 走;这里单独按应用同步。
6
+ # - 灰度门:manifest 的 rollout = {"devices":[...], "percent":N},本机在名单里或 deviceId%100 < N 才动;没有 rollout = 全量
7
+ # - ensureNoCache:manifest 打开时先确保 AppCenter 的 index.html 带 no-cache(ensureAppCenterNoCache.sh)
8
+ # - manifest 门:本机 .appcenter-manifest 的 sha256 == manifest 的 treeSha256 且 `sha256sum -c` 通过 → 什么都不做,
9
+ # 每天只花一次几 KB 的请求。被 latest.zip 全量覆盖过、文件对不上 → 校验失败 → 重新铺一遍(自愈)
10
+ # - 先下载并校验(zipSha256 + 解压后 sha256sum -c),确认无误才动线上目录
11
+ # - 保留上一代:旧目录里「旧 index.html 用得到、新版没有」的文件拷进新目录(appcenterRefs.py),
12
+ # 正开着的旧页面懒加载 chunk 时不会 404;更早手工覆盖堆下来的历史文件就此清掉
13
+ # - 整目录 rename 替换,旧目录留一份在 BACKUP;健康检查失败就换回去
14
+ # - 不碰 PHP、不碰数据库,所以不需要 TSE 门
15
+ #
16
+ # 用法: appCenterSync.sh [channel]
17
+ # APPCENTER_BASE 产物根地址,默认 S3;测试可用 file:///var/tmp/...(curl 都支持)
18
+ # APPCENTER_APPS 只同步这些应用(空格分隔);不传 = manifest 里列出的全部应用,发哪些由发布端决定
19
+ # APPCENTER_DEVICE_ID 本机 deviceId(CLI 会传);不传就问本机 PHP
20
+ # APPCENTER_RESULT 结果行另存的文件(CLI 会传)
21
+ # 结果行(同时写进 $RESULT 给 CLI 上报):APP_OK <App> <sha12> | APP_UP_TO_DATE <App> | APP_SKIP <App> <原因> |
22
+ # APP_ROLLED_BACK <App> | APP_ABORT_* <App> | NOCACHE_* | SKIP_ROLLOUT | SKIP_DEPS | SKIP_LOCKED | ABORT_*
23
+ # 退出码:0=全部成功、无需更新或不在灰度内;1=有失败(失败的已回滚或未动线上目录)
24
+ set -uo pipefail
25
+ BASE="${APPCENTER_BASE:-https://aaden-backend.s3.eu-central-1.amazonaws.com/appcenter}"
26
+ CHANNEL="${1:-stable}"
27
+ WEB=/var/www/html/AppCenter
28
+ STAGE=/var/tmp/aaden-appcenter-stage
29
+ BACKUP=/var/lib/aaden-appcenter-backup
30
+ LOCK=/var/tmp/aaden-appcenter.lock
31
+ RESULT="${APPCENTER_RESULT:-/var/tmp/aaden-appcenter-result.txt}" # CLI 传一个自己建的临时文件,读回来上报
32
+ HERE="$(cd "$(dirname "$0")" && pwd)"
33
+ PW='Innerken322.'
34
+ sudoS(){ echo "$PW" | sudo -S bash -c "$*"; }
35
+ res(){ echo "$*"; echo "$*" >> "$RESULT"; }
36
+
37
+ : > "$RESULT" 2>/dev/null || { RESULT=$(mktemp); : > "$RESULT"; }
38
+
39
+ MISSING=""
40
+ for c in curl python3 unzip sha256sum flock tar cut comm; do command -v "$c" >/dev/null || MISSING="$MISSING $c"; done
41
+ if [ -n "$MISSING" ]; then res "SKIP_DEPS$MISSING"; exit 0; fi
42
+ if ! echo "$PW" | sudo -S true 2>/dev/null; then res "ABORT_SUDO_AUTH"; exit 1; fi
43
+
44
+ # 同一时间只跑一个(auto-upgrade 与手动触发可能撞上)。只读打开即可 flock,root/aaden 谁先建文件都行
45
+ [ -e "$LOCK" ] || touch "$LOCK"
46
+ exec 9<"$LOCK"
47
+ flock -n 9 || { res "SKIP_LOCKED"; exit 0; }
48
+
49
+ MANIFEST=$(curl -fsSL --max-time 30 "$BASE/$CHANNEL/manifest.json") \
50
+ || { echo "取不到 $BASE/$CHANNEL/manifest.json"; res "ABORT_MANIFEST"; exit 1; }
51
+ # 读 manifest:mf apps.Admin.zip / mf ensureNoCache / mf eligible <deviceId>
52
+ mf(){
53
+ printf '%s' "$MANIFEST" | python3 -c '
54
+ import json, sys
55
+ m = json.load(sys.stdin)
56
+ if sys.argv[1] == "eligible":
57
+ r = m.get("rollout")
58
+ if r is None:
59
+ print("yes"); sys.exit()
60
+ try:
61
+ d = int(sys.argv[2])
62
+ except ValueError:
63
+ print("no"); sys.exit()
64
+ ok = d in [int(x) for x in r.get("devices", [])] or d % 100 < int(r.get("percent", 0))
65
+ print("yes" if ok else "no"); sys.exit()
66
+ v = m
67
+ for k in sys.argv[1].split("."):
68
+ v = v.get(k) if isinstance(v, dict) else None
69
+ print("" if v is None else (str(v).lower() if isinstance(v, bool) else v))
70
+ ' "$@"
71
+ }
72
+
73
+ # ---- 灰度门 ----
74
+ DEVICE_ID="${APPCENTER_DEVICE_ID:-}"
75
+ if [ -z "$DEVICE_ID" ] || [ "$DEVICE_ID" = "-1" ]; then
76
+ DEVICE_ID=$(curl -s --max-time 10 "http://localhost/PHP/AccessLog.php?op=deviceId" \
77
+ | python3 -c 'import json,sys; print(json.load(sys.stdin).get("content",""))' 2>/dev/null)
78
+ fi
79
+ if [ "$(mf eligible "${DEVICE_ID:-x}")" != "yes" ]; then
80
+ res "SKIP_ROLLOUT device=${DEVICE_ID:-?}"; exit 0
81
+ fi
82
+
83
+ FAILED=0
84
+ # ---- index.html no-cache(先于换前端:让之后拿到的 index.html 都不再被启发式缓存)----
85
+ if [ "$(mf ensureNoCache)" = "true" ]; then
86
+ NC=$(bash "$HERE/ensureAppCenterNoCache.sh"); NC_CODE=$?
87
+ printf '%s\n' "$NC" | sed '$d'
88
+ res "$(printf '%s\n' "$NC" | tail -1)"
89
+ [ "$NC_CODE" = 0 ] || FAILED=1
90
+ fi
91
+
92
+ # 清单第 67 列起是路径(64 位 hash + 两个空格)
93
+ paths(){ cut -c67- "$1" | LC_ALL=C sort; }
94
+ tree_ok(){ # $1=目录 $2=期望 treeSha256
95
+ [ -f "$1/.appcenter-manifest" ] \
96
+ && [ "$(sha256sum < "$1/.appcenter-manifest" | cut -d' ' -f1)" = "$2" ] \
97
+ && (cd "$1" && sha256sum -c --quiet --strict .appcenter-manifest >/dev/null 2>&1)
98
+ }
99
+
100
+ sync_app(){
101
+ local APP="$1" LIVE="$WEB/$1" NEW="$STAGE/$1" TREE ZIP ZSHA
102
+ TREE=$(mf "apps.$APP.treeSha256"); ZIP=$(mf "apps.$APP.zip"); ZSHA=$(mf "apps.$APP.zipSha256")
103
+ if [ -z "$TREE" ]; then res "APP_SKIP $APP 不在manifest里"; return 0; fi
104
+ # manifest 来自网络:只接受预期格式,防止路径穿越
105
+ if ! [[ "$TREE" =~ ^[0-9a-f]{64}$ && "$ZSHA" =~ ^[0-9a-f]{64}$ && "$ZIP" =~ ^blobs/[A-Za-z0-9._-]+\.zip$ ]]; then
106
+ res "APP_ABORT_BAD_MANIFEST $APP"; return 1
107
+ fi
108
+
109
+ # ---- manifest 门 ----
110
+ if tree_ok "$LIVE" "$TREE"; then res "APP_UP_TO_DATE $APP"; return 0; fi
111
+ echo "$APP: 本机与 manifest 不一致 → 同步到 ${TREE:0:12}"
112
+
113
+ # ---- 下载 + 校验(此时还没动线上目录)----
114
+ sudoS "rm -rf '$NEW' '$NEW.zip' '$NEW.failed'"
115
+ curl -fsSL --max-time 1200 "$BASE/$ZIP" -o "$NEW.zip" || { res "APP_ABORT_DOWNLOAD $APP"; return 1; }
116
+ [ "$(sha256sum "$NEW.zip" | cut -d' ' -f1)" = "$ZSHA" ] || { res "APP_ABORT_CHECKSUM $APP"; rm -f "$NEW.zip"; return 1; }
117
+ mkdir -p "$NEW" && unzip -q -o "$NEW.zip" -d "$NEW"; rm -f "$NEW.zip"
118
+ if ! { [ -f "$NEW/index.html" ] && tree_ok "$NEW" "$TREE"; }; then
119
+ res "APP_ABORT_BAD_ARTIFACT $APP"; rm -rf "$NEW"; return 1
120
+ fi
121
+ # zip 里带的是打包时的旧 mtime;浏览器按 Last-Modified 的年龄启发式缓存,所以改成现在(只改 mtime 不改内容,不影响校验)
122
+ touch "$NEW/index.html"
123
+
124
+ # ---- 保留上一代 ----
125
+ local CARRIED=0
126
+ if [ -d "$LIVE" ]; then
127
+ python3 "$HERE/appcenterRefs.py" "$LIVE" | LC_ALL=C sort > "$NEW.old" \
128
+ || { res "APP_ABORT_REFS $APP"; rm -rf "$NEW" "$NEW.old"; return 1; }
129
+ paths "$NEW/.appcenter-manifest" > "$NEW.new"
130
+ LC_ALL=C comm -23 "$NEW.old" "$NEW.new" > "$NEW.carry"
131
+ CARRIED=$(wc -l < "$NEW.carry" | tr -d ' ')
132
+ # 用 tar 而不是 rsync:老设备不一定装了 rsync;清单里的文件都确实存在(appcenterRefs 只列存在的)
133
+ [ "$CARRIED" -gt 0 ] && tar -C "$LIVE" -cf - -T "$NEW.carry" | tar -C "$NEW" -xf -
134
+ rm -f "$NEW.old" "$NEW.new" "$NEW.carry"
135
+ fi
136
+ echo "$APP: 保留上一代文件 $CARRIED 个"
137
+
138
+ # ---- 替换:两次 rename,中间只有毫秒级空窗 ----
139
+ sudoS "mkdir -p '$BACKUP' && rm -rf '$BACKUP/$APP' && chown -R www-data:www-data '$NEW'"
140
+ [ -d "$LIVE" ] && sudoS "mv '$LIVE' '$BACKUP/$APP'"
141
+ sudoS "mv '$NEW' '$LIVE'"
142
+
143
+ # ---- 健康检查:首页、入口 js、文件校验 ----
144
+ local HEALTHY=1 MAIN c
145
+ MAIN=$(grep -oE 'src=["'"'"']?[^"'"'"' >]+\.js' "$LIVE/index.html" | grep -vE '//' | head -1 | sed -E 's/^src=["'"'"']?(\.\/)?//')
146
+ for p in "" "$MAIN"; do
147
+ c=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost/AppCenter/$APP/$p" --max-time 15)
148
+ echo " 健康检查 /AppCenter/$APP/$p -> $c"; [ "$c" = "200" ] || HEALTHY=0
149
+ done
150
+ tree_ok "$LIVE" "$TREE" || { echo " 线上目录校验失败"; HEALTHY=0; }
151
+
152
+ if [ "$HEALTHY" != "1" ]; then
153
+ sudoS "mv '$LIVE' '$NEW.failed'; [ -d '$BACKUP/$APP' ] && mv '$BACKUP/$APP' '$LIVE'"
154
+ res "APP_ROLLED_BACK $APP"; return 1
155
+ fi
156
+ res "APP_OK $APP ${TREE:0:12} carried=$CARRIED"
157
+ }
158
+
159
+ # 不指定就同步 manifest 里的全部应用(营业中一直开着的 App/KDS 等,发布端先别放进 manifest)
160
+ APPS="${APPCENTER_APPS:-$(printf '%s' "$MANIFEST" | python3 -c 'import json, sys; print(" ".join(sorted(json.load(sys.stdin).get("apps", {}))))')}"
161
+ sudoS "mkdir -p '$STAGE' && chown $(id -un):$(id -gn) '$STAGE'"
162
+ for APP in $APPS; do
163
+ if ! [[ "$APP" =~ ^[A-Za-z0-9_-]+$ ]]; then res "APP_SKIP $APP 名字不合法"; continue; fi
164
+ sync_app "$APP" || FAILED=1
165
+ done
166
+ exit $FAILED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env python3
2
+ """门店前端目录的实际访问量(aaden-cli appcenter-usage 的执行体)。只读 Apache access.log,结果 JSON 打到 stdout。
3
+
4
+ 统计两类目录,应用名用 docroot 下的相对路径:
5
+ - AppCenter 下的应用 → "AppCenter/Admin"、"AppCenter/App" …
6
+ - docroot 根目录下带 index.html 的前端(老的 /Admin、/App 等)→ "Admin"、"App" …
7
+ 一个请求归到某个目录,满足其一即可:
8
+ - 请求路径在该目录下(页面和静态资源)
9
+ - Referer 指向该目录(页面里发出的 PHP 接口请求;index.html 被浏览器缓存时只能靠这个看出有人在用)
10
+ 来源按 Referer 主机名分:ngrok(远程。经 ik%04d.ngrok 进来的客户端 IP 都是 127.0.0.1,只能看 Referer)、
11
+ local(localhost/127.x,收银机本机)、lan(局域网 IP,平板等)、none(没有 Referer,一般是直接打开页面)。
12
+ 只统计真实存在的目录,扫描器乱打的路径不算。
13
+
14
+ 用法: appCenterUsage.py [日志目录,默认 /var/log/apache2];需要能读 access.log*(root 或 adm 组)。
15
+ """
16
+ import glob
17
+ import gzip
18
+ import json
19
+ import os
20
+ import re
21
+ import sys
22
+ from collections import defaultdict
23
+
24
+ LOG_DIR = sys.argv[1] if len(sys.argv) > 1 else '/var/log/apache2'
25
+ DOCROOT = '/var/www/html'
26
+ APPCENTER = os.path.join(DOCROOT, 'AppCenter')
27
+ # 根目录下不是前端的目录
28
+ NOT_FRONTEND = {'AppCenter', 'PHP', 'Resource', 'TseHost', 'docs', 'scripts', 'ngrok', 'vendor', 'node_modules'}
29
+ LINE = re.compile(
30
+ r'^(?P<ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] "(?P<method>[A-Z]+) (?P<path>\S+)[^"]*" (?P<status>\d{3}) \S+ '
31
+ r'"(?P<ref>[^"]*)" "(?P<ua>[^"]*)"'
32
+ )
33
+ # /AppCenter/<App>/... 或 /<根目录前端>/...;第一组是 "AppCenter/"(可空),第二组是目录名
34
+ APP_IN_PATH = re.compile(r'^/(AppCenter/)?([^/?#]+)')
35
+ APP_IN_REF = re.compile(r'^https?://([^/]+)/(AppCenter/)?([^/?#]+)')
36
+ MONTHS = {m: i + 1 for i, m in enumerate('Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split())}
37
+
38
+
39
+ def source_of(ref_host):
40
+ if ref_host is None:
41
+ return 'none'
42
+ host = ref_host.split(':')[0].lower()
43
+ if 'ngrok' in host:
44
+ return 'ngrok'
45
+ if host == 'localhost' or host.startswith('127.'):
46
+ return 'local'
47
+ return 'lan'
48
+
49
+
50
+ 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}'
55
+ return day, f'{day}T{hms}'
56
+
57
+
58
+ def main():
59
+ installed = []
60
+ if os.path.isdir(APPCENTER):
61
+ installed += ['AppCenter/' + d for d in os.listdir(APPCENTER) if os.path.isdir(os.path.join(APPCENTER, d))]
62
+ if os.path.isdir(DOCROOT):
63
+ installed += [
64
+ d for d in os.listdir(DOCROOT)
65
+ if d not in NOT_FRONTEND and not d.startswith('.') and os.path.isfile(os.path.join(DOCROOT, d, 'index.html'))
66
+ ]
67
+ installed.sort()
68
+ known = set(installed)
69
+ # 快速预筛:行里得出现某个已知目录的路径段,绝大多数 PHP 轮询行直接跳过
70
+ roots = sorted({k.split('/')[0] for k in known}, key=len, reverse=True)
71
+ prefilter = re.compile('/(?:' + '|'.join(re.escape(r) for r in roots) + ')[/?# "]') if roots else None
72
+ stats = defaultdict(lambda: {
73
+ 'pageLoads': 0, 'requests': 0, 'clients': set(),
74
+ 'sources': {'none': 0, 'local': 0, 'lan': 0, 'ngrok': 0}, 'lastSeen': '',
75
+ })
76
+ log_from = log_to = None
77
+ total = 0
78
+ for f in sorted(glob.glob(os.path.join(LOG_DIR, 'access.log*'))):
79
+ opener = gzip.open if f.endswith('.gz') else open
80
+ try:
81
+ fh = opener(f, 'rt', errors='replace')
82
+ except OSError:
83
+ continue
84
+ with fh:
85
+ for line in fh:
86
+ total += 1
87
+ # 时间范围按所有行算,统计只看和 AppCenter 有关的行
88
+ lb = line.find('[')
89
+ if lb > 0:
90
+ day, at = parse_ts(line[lb + 1:lb + 21])
91
+ if log_from is None or at < log_from:
92
+ log_from = at
93
+ if log_to is None or at > log_to:
94
+ log_to = at
95
+ if prefilter is None or not prefilter.search(line):
96
+ continue
97
+ m = LINE.match(line)
98
+ if not m:
99
+ continue
100
+ path, ref = m['path'], m['ref']
101
+ pm = APP_IN_PATH.match(path)
102
+ rm = APP_IN_REF.match(ref) if ref and ref != '-' else None
103
+ path_app = (pm.group(1) or '') + pm.group(2) if pm else None
104
+ ref_app = (rm.group(2) or '') + rm.group(3) if rm else None
105
+ if path_app not in known:
106
+ path_app = None
107
+ if ref_app not in known:
108
+ ref_app, rm = None, None
109
+ app = path_app or ref_app
110
+ if not app:
111
+ continue
112
+ day, at = parse_ts(m['ts'])
113
+ s = stats[(app, day)]
114
+ s['requests'] += 1
115
+ bare = path.split('?')[0].split('#')[0]
116
+ if path_app and m['method'] == 'GET' and m['status'] in ('200', '304') and bare in (
117
+ f'/{app}', f'/{app}/', f'/{app}/index.html'):
118
+ s['pageLoads'] += 1
119
+ ref_host = rm.group(1) if rm else None
120
+ s['clients'].add((m['ip'], ref_host or '-', m['ua'][:120]))
121
+ s['sources'][source_of(ref_host)] += 1
122
+ if at > s['lastSeen']:
123
+ s['lastSeen'] = at
124
+ out = {
125
+ 'logFrom': log_from,
126
+ 'logTo': log_to,
127
+ 'totalRequests': total,
128
+ 'installedApps': installed,
129
+ 'usage': [
130
+ {
131
+ 'app': app, 'day': day, 'pageLoads': s['pageLoads'], 'requests': s['requests'],
132
+ 'clients': len(s['clients']), 'sources': s['sources'], 'lastSeen': s['lastSeen'],
133
+ }
134
+ for (app, day), s in sorted(stats.items())
135
+ ],
136
+ }
137
+ json.dump(out, sys.stdout, ensure_ascii=False, separators=(',', ':'))
138
+
139
+
140
+ if __name__ == '__main__':
141
+ main()
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 列出一个 AppCenter 应用目录里「当前这版 index.html 实际用得到」的文件(相对路径,一行一个,只列存在的)。
4
+
5
+ appCenterSync.sh 用它保留上一代:替换目录前,把旧目录里这些文件中新版没有的拷进新目录,
6
+ 正开着的旧页面懒加载 chunk 时就不会 404。再往前历次手工覆盖堆下来的文件不在这个闭包里,顺带清掉。
7
+
8
+ 闭包算法与 aaden-php-backend scripts/appcenter_artifact.py 的 referenced() 相同,改一边记得改另一边:
9
+ index.html 的 src/href → webpack runtime 的 chunk→hash 表 → js/css 文本里的 hash 资源路径。
10
+ """
11
+ import os
12
+ import re
13
+ import sys
14
+
15
+ ATTR = re.compile(r'''(?:src|href)=["']?([^"'\s>]+)''')
16
+ RUNTIME = re.compile(r'(js|css)/"\+\(\{([^}]*)\}\[\w+\]\|\|\w+\)\+"\."\+\{([^}]*)\}')
17
+ PAIR = re.compile(r'"?([\w\-]+)"?:"([\w\-]+)"')
18
+ ASSET_REF = re.compile(r'(?<![\w\-])((?:js|css|img|fonts|media)/[\w\-.@]+\.[0-9a-f]{8}\.[A-Za-z0-9]+)')
19
+ PRECACHE = re.compile(r'precache-manifest\.[0-9a-f]+\.js')
20
+
21
+
22
+ def norm(ref):
23
+ ref = ref.split('?')[0].split('#')[0]
24
+ if re.match(r'^[a-z]+:|^//', ref):
25
+ return None
26
+ while ref.startswith('./'):
27
+ ref = ref[2:]
28
+ return ref.lstrip('/') or None
29
+
30
+
31
+ def read_text(path):
32
+ with open(path, 'rb') as f:
33
+ return f.read().decode('utf-8', 'replace')
34
+
35
+
36
+ def main(root):
37
+ if not os.path.isfile(os.path.join(root, 'index.html')):
38
+ return
39
+ seen, found = set(), []
40
+ queue = [r for r in (norm(m) for m in ATTR.findall(read_text(os.path.join(root, 'index.html')))) if r]
41
+ queue.append('index.html')
42
+ sw = os.path.join(root, 'service-worker.js')
43
+ if os.path.exists(sw):
44
+ queue.append('service-worker.js')
45
+ queue += PRECACHE.findall(read_text(sw))
46
+ while queue:
47
+ ref = queue.pop()
48
+ if ref in seen:
49
+ continue
50
+ seen.add(ref)
51
+ path = os.path.join(root, ref)
52
+ if not os.path.isfile(path):
53
+ continue
54
+ found.append(ref)
55
+ if not ref.endswith(('.js', '.css')):
56
+ continue
57
+ text = read_text(path)
58
+ for kind, names, hashes in RUNTIME.findall(text):
59
+ name_of = dict(PAIR.findall(names))
60
+ for chunk, h in PAIR.findall(hashes):
61
+ queue.append('%s/%s.%s.%s' % (kind, name_of.get(chunk, chunk), h, kind))
62
+ queue += ASSET_REF.findall(text)
63
+ sys.stdout.write(''.join(r + '\n' for r in sorted(found)))
64
+
65
+
66
+ if __name__ == '__main__':
67
+ main(sys.argv[1])
@@ -0,0 +1,84 @@
1
+ #!/bin/bash
2
+ # 确保 AppCenter 下各前端的 index.html 带 `Cache-Control: no-cache`。由 appCenterSync.sh 在 manifest 打开
3
+ # ensureNoCache 时调用(受 manifest 的灰度门控制);幂等,已生效时只做几个文件比较就返回。
4
+ #
5
+ # 病症:设备 Apache 默认没开 mod_headers,index.html 只带 Last-Modified/ETag,浏览器按「文件年龄 × 10%」自行缓存,
6
+ # 这段时间里同一个 URL 直接用缓存、不问服务器。aaden-vue-app 打开后台带的 chaos= 是收银程序启动时间,
7
+ # 程序不重启 URL 就不变 → appcenter-sync 换了新版也看不到;期间若又发一版,缓存里的旧 index.html
8
+ # 去请求已被清掉的旧 chunk → 白屏。js/css 文件名带内容 hash,不受影响,所以只管 index.html。
9
+ # no-cache = 每次用之前先向服务器确认,不是不缓存。(mod_deflate 给 ETag 加了 -gzip,条件请求对不上,
10
+ # 实际每次回 200 约 3KB 而不是 304,可忽略。)只对之后的新响应生效,已缓存的旧响应要等它自己过期。
11
+ #
12
+ # 风险控制(配置写坏时 reload 会被 configtest 拦住、继续跑旧配置,但下次重启 Apache 就起不来、收银全停):
13
+ # - 动手前先 configtest:本来就过不了的设备一律不碰
14
+ # - 配置包在 <IfModule> 里;写完立刻 configtest,不过就原样撤回(删配置、关掉本次才开的模块)
15
+ # - 只 graceful reload,不 restart:正在处理的请求(打印、TSE 签名)不被打断
16
+ # 末行输出:NOCACHE_OK(已生效)| NOCACHE_APPLIED | NOCACHE_SKIP_<原因> | NOCACHE_REVERTED | NOCACHE_FAILED
17
+ # 退出码:只有 NOCACHE_FAILED(撤回后配置仍坏 / reload 失败)为 1
18
+ # 撤销:a2disconf aaden-appcenter-cache && systemctl reload apache2
19
+ set -u
20
+ NAME=aaden-appcenter-cache
21
+ CONF=/etc/apache2/conf-available/$NAME.conf
22
+ PW='Innerken322.'
23
+ sudoS(){ echo "$PW" | sudo -S bash -c "$*"; }
24
+ configtest(){ sudoS "apache2ctl configtest" >/dev/null 2>&1; }
25
+
26
+ if ! command -v apache2ctl >/dev/null || ! command -v a2enmod >/dev/null; then
27
+ echo " 没有 apache2ctl/a2enmod(不是 apt 装的 Apache?),不碰"; echo "NOCACHE_SKIP_NO_APACHE"; exit 0
28
+ fi
29
+
30
+ TMP=$(mktemp)
31
+ trap 'rm -f "$TMP" "$TMP.old"' EXIT
32
+ cat > "$TMP" <<'EOF'
33
+ # 由 aaden-cli AadenDeploy/ensureAppCenterNoCache.sh 维护,手改会被覆盖。
34
+ # AppCenter 前端的入口页每次先向服务器确认,避免更新后还在用缓存里的旧 index.html。
35
+ # 只匹配 /var/www/html/AppCenter 下的 index.html;PHP 接口、js/css、根目录 /App/ /Admin/ 不受影响。
36
+ <IfModule mod_headers.c>
37
+ <Directory /var/www/html/AppCenter>
38
+ <FilesMatch "^index\.html$">
39
+ Header set Cache-Control "no-cache"
40
+ </FilesMatch>
41
+ </Directory>
42
+ </IfModule>
43
+ EOF
44
+
45
+ MOD_WAS_ON=0; [ -e /etc/apache2/mods-enabled/headers.load ] && MOD_WAS_ON=1
46
+ CONF_WAS_ON=0; [ -e "/etc/apache2/conf-enabled/$NAME.conf" ] && CONF_WAS_ON=1
47
+ if [ "$MOD_WAS_ON" = 1 ] && [ "$CONF_WAS_ON" = 1 ] && cmp -s "$TMP" "$CONF"; then
48
+ echo "NOCACHE_OK"; exit 0
49
+ fi
50
+ if ! configtest; then
51
+ echo " Apache 配置本来就过不了 configtest,不碰(这台设备需要人工处理)"; echo "NOCACHE_SKIP_CONFIG_BROKEN"; exit 0
52
+ fi
53
+ HAD_CONF=0; [ -f "$CONF" ] && { HAD_CONF=1; cp "$CONF" "$TMP.old"; }
54
+
55
+ # 撤回到动手前的状态:只关本次才开的模块/配置,原来就有的不碰
56
+ revert(){
57
+ [ "$CONF_WAS_ON" = 0 ] && sudoS "a2disconf -q $NAME" >/dev/null 2>&1
58
+ if [ "$HAD_CONF" = 1 ]; then sudoS "cp '$TMP.old' '$CONF'"; else sudoS "rm -f '$CONF'"; fi
59
+ [ "$MOD_WAS_ON" = 0 ] && sudoS "a2dismod -q headers" >/dev/null 2>&1
60
+ if configtest; then echo " 已撤回,Apache 配置恢复正常"; return 0; fi
61
+ echo " !!! 撤回后 configtest 仍失败,需要人工处理(重启前务必修好)"; return 1
62
+ }
63
+
64
+ sudoS "cp '$TMP' '$CONF' && chmod 644 '$CONF'"
65
+ [ "$MOD_WAS_ON" = 1 ] || sudoS "a2enmod -q headers" >/dev/null
66
+ [ "$CONF_WAS_ON" = 1 ] || sudoS "a2enconf -q $NAME" >/dev/null
67
+
68
+ if ! configtest; then
69
+ echo " 写入后 configtest 失败(未 reload,线上仍是旧配置),撤回"
70
+ if revert; then echo "NOCACHE_REVERTED"; exit 0; fi
71
+ echo "NOCACHE_FAILED"; exit 1
72
+ fi
73
+ if ! sudoS "systemctl reload apache2 || apache2ctl graceful"; then
74
+ echo " reload 失败,撤回"
75
+ revert; echo "NOCACHE_FAILED"; exit 1
76
+ fi
77
+
78
+ # graceful 后新配置由新起的子进程接手,稍等再验
79
+ sleep 2
80
+ if [ -f /var/www/html/AppCenter/Admin/index.html ]; then
81
+ H=$(curl -sI --max-time 10 http://localhost/AppCenter/Admin/ | tr -d '\r' | grep -i '^cache-control:')
82
+ echo " 验证 /AppCenter/Admin/ -> ${H:-(没有 Cache-Control 头!)}"
83
+ fi
84
+ echo "NOCACHE_APPLIED"
File without changes
File without changes
File without changes
@@ -17,7 +17,14 @@ sudo npm i aaden-cli -g
17
17
 
18
18
  aaden-cli patch
19
19
 
20
+ # 前端目录访问量上报(AppCenter 各应用 + 根目录老前端;只读 Apache 日志,20 小时内报过就空跑)。放在 light-update 前面,后端部署出问题也不影响统计
21
+ aaden-cli appcenter-usage
22
+
20
23
  # 后端也走 aaden-cli 通道自愈:light-update 自带 版本门/灰度门/活动单门,
21
24
  # 已最新则空跑,营业时间被 @reboot 触发也不会打断收银(不 snap refresh 前端、不 apt)。
22
25
  # 这条让"后端自动更新"重新依赖活着的 npm+S3 管线,而不是失效的 git token。
23
26
  aaden-cli light-update
27
+
28
+ # AppCenter 前端(Admin 等)单独同步:light-update 不发 AppCenter。灰度由 S3 manifest 的 rollout 控制,
29
+ # 不在灰度内 / 已最新时空跑(一次几 KB 的 manifest 请求);不碰 PHP 和数据库,营业时间触发也安全。
30
+ aaden-cli appcenter-sync
package/README.md CHANGED
@@ -35,3 +35,23 @@ Commands:
35
35
  help [command] display help for command
36
36
 
37
37
  ```
38
+
39
+ ## 发布
40
+
41
+ 1. 在 master 上运行 `npm run update`:release-it 把版本号升一个 patch,提交 `aaden-cli: release vX.Y.Z`,打 tag `X.Y.Z` 并推上去。本地不再发布 npm。
42
+ 要在普通 clone 里运行:release-it 13 认不出 `git worktree`,在 worktree 里只会改版本号,不会提交、打 tag。
43
+ 2. tag 推上去后,GitHub Actions 的 `.github/workflows/publish.yml` 自动执行:
44
+ - 校验 tag 和 `package.json` 的版本一致,并且 tag 指向的提交在 master 上;
45
+ - 从这个 tag 对应的提交 `npm ci` 并用 ncc 打包,冒烟检查 `dist/index.js --version`;
46
+ - 用 npm Trusted Publishing(OIDC)执行 `npm publish`,不需要 npm token 或 OTP;
47
+ - 创建 GitHub Release。
48
+ 3. 全网设备在第二天 04:55 自动 `npm install aaden-cli -g` 装上新版本。
49
+
50
+ ⚠️ `latest` 一发布就是全网发布。要先在个别设备上试,用预发布通道:
51
+
52
+ - 在任意分支的提交上打 `x.y.z-后缀` 形式的 tag(如 `1.6.103-rc.0`)并推上去,CI 会把这个提交按 tag 的版本号发到 npm 的 `next`(不需要提交版本号)。
53
+ - 设备每天只装 `latest`,不会自动装 `next`。要试的设备上手动运行 `sudo npm i -g aaden-cli@next`;这台设备第二天 04:55 会自动回到 `latest`。
54
+
55
+ CI 发布失败时的手动兜底:在 tag 对应的提交上运行 `npx ncc build index.js -o dist -C && npm publish`(需要 npm 2FA)。
56
+
57
+ 一次性配置:npmjs.com → aaden-cli → Settings → Trusted Publisher → GitHub Actions,填组织 `innerken-gmbh`、仓库 `aaden-cli`、workflow `publish.yml`;允许的操作要勾上 `npm publish`(2026-09-03 之后新建的配置默认只允许 `npm stage publish`,直接 publish 会报 403 "OIDC permission denied for this action")。
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  const util = __nccwpck_require__(3837)
9
9
  const colors = __nccwpck_require__(3045)
10
- const {spawnSync, spawn} = __nccwpck_require__(8493)
10
+ const {spawnSync, spawn} = __nccwpck_require__(2081)
11
11
 
12
12
  function getLine(char, lineLength = 20) {
13
13
  return char.repeat(lineLength)
@@ -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__(2081)
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:
@@ -3079,7 +3149,7 @@ module.exports = {
3079
3149
  */
3080
3150
 
3081
3151
  const EventEmitter = (__nccwpck_require__(2361).EventEmitter);
3082
- const spawn = (__nccwpck_require__(8493).spawn);
3152
+ const spawn = (__nccwpck_require__(2081).spawn);
3083
3153
  const path = __nccwpck_require__(1017);
3084
3154
  const fs = __nccwpck_require__(7147);
3085
3155
 
@@ -6851,7 +6921,7 @@ module.exports = (flag, argv = process.argv) => {
6851
6921
 
6852
6922
  /***/ }),
6853
6923
 
6854
- /***/ 2081:
6924
+ /***/ 7299:
6855
6925
  /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
6856
6926
 
6857
6927
  "use strict";
@@ -7024,7 +7094,7 @@ Object.defineProperty(exports, "__esModule", ({
7024
7094
  }));
7025
7095
  exports["default"] = void 0;
7026
7096
 
7027
- var _http = __nccwpck_require__(2081);
7097
+ var _http = __nccwpck_require__(7299);
7028
7098
 
7029
7099
  var _sweetalert = _interopRequireDefault(__nccwpck_require__(6091));
7030
7100
 
@@ -7213,7 +7283,7 @@ exports["default"] = exports.hillo = void 0;
7213
7283
 
7214
7284
  var _request = _interopRequireDefault(__nccwpck_require__(7798));
7215
7285
 
7216
- var _http = __nccwpck_require__(2081);
7286
+ var _http = __nccwpck_require__(7299);
7217
7287
 
7218
7288
  function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
7219
7289
 
@@ -18383,7 +18453,7 @@ module.exports = require("assert");
18383
18453
 
18384
18454
  /***/ }),
18385
18455
 
18386
- /***/ 8493:
18456
+ /***/ 2081:
18387
18457
  /***/ ((module) => {
18388
18458
 
18389
18459
  "use strict";
@@ -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.101"};
18580
+ module.exports = JSON.parse('{"i8":"1.6.103-rc.0"}');
18511
18581
 
18512
18582
  /***/ })
18513
18583
 
@@ -18587,9 +18657,13 @@ var __webpack_exports__ = {};
18587
18657
  const dayjs = __nccwpck_require__(7401)
18588
18658
  const colors = __nccwpck_require__(3045)
18589
18659
  const fs = __nccwpck_require__(7147)
18660
+ const path = __nccwpck_require__(1017)
18661
+ const {execSync} = __nccwpck_require__(2081)
18662
+ const os = __nccwpck_require__(2037)
18590
18663
  const InnerKenTimeStampFormat = 'YYYYMMDDHHmmss'
18591
18664
  const prompts = __nccwpck_require__(9646)
18592
18665
  const {deviceEcho, logAction, getCurrentDeviceId} = __nccwpck_require__(7415)
18666
+ const {collectHostInfo} = __nccwpck_require__(3727)
18593
18667
  const GlobalDeviceId = 'notset'
18594
18668
 
18595
18669
  const {program} = __nccwpck_require__(1904)
@@ -18610,6 +18684,8 @@ program
18610
18684
  .option('-d, --debug', 'use Debug Out put')
18611
18685
  program.command('update [channel]').action(update)
18612
18686
  program.command('light-update').action(lightUpdate) // 轻量+带闸门的后端部署,供 auto-upgrade/patch 调用
18687
+ program.command('appcenter-usage [force]').action(appCenterUsage) // 门店前端目录(AppCenter 各应用 + 根目录老前端)访问量上报云端,供 auto-upgrade 每天调一次
18688
+ program.command('appcenter-sync [channel]').action(appCenterSync) // AppCenter 前端按应用同步,供 auto-upgrade 调用
18613
18689
  program.command('rollback').action(rollback)
18614
18690
  program
18615
18691
  .command('install <deviceId>')
@@ -19162,6 +19238,78 @@ async function lightUpdate() {
19162
19238
  }
19163
19239
  }
19164
19240
 
19241
+ /**
19242
+ * 门店前端目录的实际访问量 → cloud(ERP「租户管理」里看全网哪些目录还有人用、从哪儿访问)。
19243
+ * 包括 AppCenter 下各应用("AppCenter/Admin")和 docroot 根目录的老前端("Admin"、"App")。
19244
+ * 统计在 AadenDeploy/appCenterUsage.py(只读 Apache access.log,按 目录×天 汇总)。
19245
+ * Apache 日志保留 14 天,所以每次都把整个窗口重报一遍,云端按 设备×应用×天 覆盖;新装上的第一次就能带回两周历史。
19246
+ * 每天随 auto-upgrade 跑一次,20 小时内报过就跳过(@reboot 也会触发 auto-upgrade),传 force 跳过这道门。
19247
+ * 最低 CPU/IO 优先级;失败只打印,不写 deviceLog 事件(免得 650 台每天各刷一条)。
19248
+ */
19249
+ async function appCenterUsage(force) {
19250
+ const stamp = '/var/tmp/aaden-appcenter-usage.last'
19251
+ try {
19252
+ if (force !== 'force' && fs.existsSync(stamp) && Date.now() - fs.statSync(stamp).mtimeMs < 20 * 3600 * 1000) {
19253
+ console.log('appcenter-usage: 20 小时内已上报,跳过')
19254
+ return
19255
+ }
19256
+ const deviceId = await getCurrentDeviceId()
19257
+ if (parseInt(deviceId) === -1) {
19258
+ return
19259
+ }
19260
+ const script = path.join(findMe(), '..', 'AadenDeploy', 'appCenterUsage.py')
19261
+ const output = execSync(
19262
+ `echo "Innerken322." | sudo -S nice -n 19 $(command -v ionice >/dev/null && echo ionice -c3) python3 ${script}`,
19263
+ {encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024, timeout: 15 * 60 * 1000, stdio: ['ignore', 'pipe', 'ignore']}
19264
+ )
19265
+ const report = JSON.parse(output)
19266
+ // 和 do-log 一样按 deviceId 错开,别让全网同一秒打云端
19267
+ await new Promise(resolve => setTimeout(resolve, (parseInt(deviceId) % 30) * 1000))
19268
+ await hillo.jsonPost('https://cloud-v2.aaden.io/deviceLog/appUsage', {deviceId, cliVersion: version, ...report})
19269
+ try {
19270
+ fs.writeFileSync(stamp, new Date().toISOString())
19271
+ } catch (_) {}
19272
+ console.log(`appcenter-usage: 已上报 ${report.usage.length} 条(${report.logFrom} ~ ${report.logTo})`)
19273
+ } catch (e) {
19274
+ console.error('appcenter-usage 失败:', e.message || e)
19275
+ }
19276
+ }
19277
+
19278
+ /**
19279
+ * AppCenter 前端同步 —— light-update 只发 PHP/,AppCenter 以前只能跟 465MB 的 latest.zip 走。
19280
+ * 每天随 auto-upgrade 跑一次;不跟后端版本门绑定(只改前端时后端版本不变,绑了就永远不跑)。
19281
+ *
19282
+ * 全部逻辑在 AadenDeploy/appCenterSync.sh:灰度门(S3 manifest 的 rollout)、index.html no-cache、
19283
+ * treeSha256 门(一致就空跑)、下载校验、保留上一代、换目录、健康检查失败回滚。
19284
+ * 这里只负责传 deviceId、把有动作的结果写进 cloud 事件日志 —— 空跑(已最新 / 不在灰度内)不上报,
19285
+ * 免得 650 台每天各刷一条。
19286
+ */
19287
+ async function appCenterSync(channel = 'stable') {
19288
+ const resultFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'aaden-appcenter-')), 'result.txt')
19289
+ try {
19290
+ const deviceId = await getCurrentDeviceId()
19291
+ const command = [
19292
+ ...getPrivilegeCMD('AadenDeploy/appCenterSync.sh'),
19293
+ 'cd AadenDeploy',
19294
+ `APPCENTER_DEVICE_ID=${deviceId} APPCENTER_RESULT=${resultFile} ./appCenterSync.sh ${channel}`
19295
+ ]
19296
+ const {code} = await cli.executeInShell(command, program.debug)
19297
+ let lines = []
19298
+ try {
19299
+ lines = fs.readFileSync(resultFile, 'utf8').split('\n').filter(l => l.trim())
19300
+ } catch (_) {}
19301
+ const quiet = /^(APP_UP_TO_DATE|SKIP_ROLLOUT|SKIP_LOCKED|NOCACHE_OK)\b/
19302
+ if (code !== 0 || lines.some(l => !quiet.test(l))) {
19303
+ await logAction('appcenter-sync: ' + (lines.join(' | ') || '无结果输出'), code || 0)
19304
+ }
19305
+ } catch (e) {
19306
+ console.error(e)
19307
+ try { await logAction('appcenter-sync 异常: ' + (e.message || e), 1) } catch (_) {}
19308
+ } finally {
19309
+ try { fs.rmSync(path.dirname(resultFile), {recursive: true, force: true}) } catch (_) {}
19310
+ }
19311
+ }
19312
+
19165
19313
  /** 比较点分数字版本,a > b 返回 true。'1.9.61' > '1.7.908' === true。 */
19166
19314
  function versionGt(a, b) {
19167
19315
  const pa = String(a).split('.').map(n => parseInt(n) || 0)
@@ -19362,7 +19510,8 @@ async function doLog() {
19362
19510
  cliVersion: version,
19363
19511
  diskUsage: diskUsage[diskUsage.length - 1].replace('\n', ''),
19364
19512
  upTime: getUpTime(),
19365
- ngrokIsNew: ngrokNewestVersion()
19513
+ ngrokIsNew: ngrokNewestVersion(),
19514
+ hostInfo: collectHostInfo()
19366
19515
  }
19367
19516
  )
19368
19517
  console.log('finish')
package/index.js CHANGED
@@ -6,9 +6,13 @@
6
6
  const dayjs = require('dayjs')
7
7
  const colors = require('colors')
8
8
  const fs = require('fs')
9
+ const path = require('path')
10
+ const {execSync} = require('child_process')
11
+ const os = require('os')
9
12
  const InnerKenTimeStampFormat = 'YYYYMMDDHHmmss'
10
13
  const prompts = require('prompts')
11
14
  const {deviceEcho, logAction, getCurrentDeviceId} = require('./modules/firebase')
15
+ const {collectHostInfo} = require('./modules/host-info')
12
16
  const GlobalDeviceId = 'notset'
13
17
 
14
18
  const {program} = require('commander')
@@ -29,6 +33,8 @@ program
29
33
  .option('-d, --debug', 'use Debug Out put')
30
34
  program.command('update [channel]').action(update)
31
35
  program.command('light-update').action(lightUpdate) // 轻量+带闸门的后端部署,供 auto-upgrade/patch 调用
36
+ program.command('appcenter-usage [force]').action(appCenterUsage) // 门店前端目录(AppCenter 各应用 + 根目录老前端)访问量上报云端,供 auto-upgrade 每天调一次
37
+ program.command('appcenter-sync [channel]').action(appCenterSync) // AppCenter 前端按应用同步,供 auto-upgrade 调用
32
38
  program.command('rollback').action(rollback)
33
39
  program
34
40
  .command('install <deviceId>')
@@ -581,6 +587,78 @@ async function lightUpdate() {
581
587
  }
582
588
  }
583
589
 
590
+ /**
591
+ * 门店前端目录的实际访问量 → cloud(ERP「租户管理」里看全网哪些目录还有人用、从哪儿访问)。
592
+ * 包括 AppCenter 下各应用("AppCenter/Admin")和 docroot 根目录的老前端("Admin"、"App")。
593
+ * 统计在 AadenDeploy/appCenterUsage.py(只读 Apache access.log,按 目录×天 汇总)。
594
+ * Apache 日志保留 14 天,所以每次都把整个窗口重报一遍,云端按 设备×应用×天 覆盖;新装上的第一次就能带回两周历史。
595
+ * 每天随 auto-upgrade 跑一次,20 小时内报过就跳过(@reboot 也会触发 auto-upgrade),传 force 跳过这道门。
596
+ * 最低 CPU/IO 优先级;失败只打印,不写 deviceLog 事件(免得 650 台每天各刷一条)。
597
+ */
598
+ async function appCenterUsage(force) {
599
+ const stamp = '/var/tmp/aaden-appcenter-usage.last'
600
+ try {
601
+ if (force !== 'force' && fs.existsSync(stamp) && Date.now() - fs.statSync(stamp).mtimeMs < 20 * 3600 * 1000) {
602
+ console.log('appcenter-usage: 20 小时内已上报,跳过')
603
+ return
604
+ }
605
+ const deviceId = await getCurrentDeviceId()
606
+ if (parseInt(deviceId) === -1) {
607
+ return
608
+ }
609
+ const script = path.join(findMe(), '..', 'AadenDeploy', 'appCenterUsage.py')
610
+ const output = execSync(
611
+ `echo "Innerken322." | sudo -S nice -n 19 $(command -v ionice >/dev/null && echo ionice -c3) python3 ${script}`,
612
+ {encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024, timeout: 15 * 60 * 1000, stdio: ['ignore', 'pipe', 'ignore']}
613
+ )
614
+ const report = JSON.parse(output)
615
+ // 和 do-log 一样按 deviceId 错开,别让全网同一秒打云端
616
+ await new Promise(resolve => setTimeout(resolve, (parseInt(deviceId) % 30) * 1000))
617
+ await hillo.jsonPost('https://cloud-v2.aaden.io/deviceLog/appUsage', {deviceId, cliVersion: version, ...report})
618
+ try {
619
+ fs.writeFileSync(stamp, new Date().toISOString())
620
+ } catch (_) {}
621
+ console.log(`appcenter-usage: 已上报 ${report.usage.length} 条(${report.logFrom} ~ ${report.logTo})`)
622
+ } catch (e) {
623
+ console.error('appcenter-usage 失败:', e.message || e)
624
+ }
625
+ }
626
+
627
+ /**
628
+ * AppCenter 前端同步 —— light-update 只发 PHP/,AppCenter 以前只能跟 465MB 的 latest.zip 走。
629
+ * 每天随 auto-upgrade 跑一次;不跟后端版本门绑定(只改前端时后端版本不变,绑了就永远不跑)。
630
+ *
631
+ * 全部逻辑在 AadenDeploy/appCenterSync.sh:灰度门(S3 manifest 的 rollout)、index.html no-cache、
632
+ * treeSha256 门(一致就空跑)、下载校验、保留上一代、换目录、健康检查失败回滚。
633
+ * 这里只负责传 deviceId、把有动作的结果写进 cloud 事件日志 —— 空跑(已最新 / 不在灰度内)不上报,
634
+ * 免得 650 台每天各刷一条。
635
+ */
636
+ async function appCenterSync(channel = 'stable') {
637
+ const resultFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'aaden-appcenter-')), 'result.txt')
638
+ try {
639
+ const deviceId = await getCurrentDeviceId()
640
+ const command = [
641
+ ...getPrivilegeCMD('AadenDeploy/appCenterSync.sh'),
642
+ 'cd AadenDeploy',
643
+ `APPCENTER_DEVICE_ID=${deviceId} APPCENTER_RESULT=${resultFile} ./appCenterSync.sh ${channel}`
644
+ ]
645
+ const {code} = await cli.executeInShell(command, program.debug)
646
+ let lines = []
647
+ try {
648
+ lines = fs.readFileSync(resultFile, 'utf8').split('\n').filter(l => l.trim())
649
+ } catch (_) {}
650
+ const quiet = /^(APP_UP_TO_DATE|SKIP_ROLLOUT|SKIP_LOCKED|NOCACHE_OK)\b/
651
+ if (code !== 0 || lines.some(l => !quiet.test(l))) {
652
+ await logAction('appcenter-sync: ' + (lines.join(' | ') || '无结果输出'), code || 0)
653
+ }
654
+ } catch (e) {
655
+ console.error(e)
656
+ try { await logAction('appcenter-sync 异常: ' + (e.message || e), 1) } catch (_) {}
657
+ } finally {
658
+ try { fs.rmSync(path.dirname(resultFile), {recursive: true, force: true}) } catch (_) {}
659
+ }
660
+ }
661
+
584
662
  /** 比较点分数字版本,a > b 返回 true。'1.9.61' > '1.7.908' === true。 */
585
663
  function versionGt(a, b) {
586
664
  const pa = String(a).split('.').map(n => parseInt(n) || 0)
@@ -781,7 +859,8 @@ async function doLog() {
781
859
  cliVersion: version,
782
860
  diskUsage: diskUsage[diskUsage.length - 1].replace('\n', ''),
783
861
  upTime: getUpTime(),
784
- ngrokIsNew: ngrokNewestVersion()
862
+ ngrokIsNew: ngrokNewestVersion(),
863
+ hostInfo: collectHostInfo()
785
864
  }
786
865
  )
787
866
  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,6 +1,6 @@
1
1
  {
2
2
  "name": "aaden-cli",
3
- "version": "1.6.101",
3
+ "version": "1.6.103-rc.0",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -21,7 +21,12 @@
21
21
  "hooks": {
22
22
  "after:bump": "ncc build index.js -o dist -C"
23
23
  },
24
- "github.release": true,
24
+ "npm": {
25
+ "publish": false
26
+ },
27
+ "github": {
28
+ "release": false
29
+ },
25
30
  "git": {
26
31
  "commitMessage": "aaden-cli: release v${version}",
27
32
  "requireCleanWorkingDir": false
File without changes