aaden-cli 1.6.103-rc.2 → 1.6.103

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,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"
@@ -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/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)
@@ -187,7 +187,7 @@ async function getNiceRestaurantInfo() {
187
187
 
188
188
  const os = __nccwpck_require__(2037)
189
189
  const fs = __nccwpck_require__(7147)
190
- const {execSync} = __nccwpck_require__(8493)
190
+ const {execSync} = __nccwpck_require__(2081)
191
191
 
192
192
  // do-log 顺带上报的宿主机信息,用来在云端看车队的系统/硬件分布。
193
193
  // 每一项单独兜底:探测失败就报 null,不能让 do-log 本身挂掉。
@@ -3149,7 +3149,7 @@ module.exports = {
3149
3149
  */
3150
3150
 
3151
3151
  const EventEmitter = (__nccwpck_require__(2361).EventEmitter);
3152
- const spawn = (__nccwpck_require__(8493).spawn);
3152
+ const spawn = (__nccwpck_require__(2081).spawn);
3153
3153
  const path = __nccwpck_require__(1017);
3154
3154
  const fs = __nccwpck_require__(7147);
3155
3155
 
@@ -6921,7 +6921,7 @@ module.exports = (flag, argv = process.argv) => {
6921
6921
 
6922
6922
  /***/ }),
6923
6923
 
6924
- /***/ 2081:
6924
+ /***/ 7299:
6925
6925
  /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
6926
6926
 
6927
6927
  "use strict";
@@ -7094,7 +7094,7 @@ Object.defineProperty(exports, "__esModule", ({
7094
7094
  }));
7095
7095
  exports["default"] = void 0;
7096
7096
 
7097
- var _http = __nccwpck_require__(2081);
7097
+ var _http = __nccwpck_require__(7299);
7098
7098
 
7099
7099
  var _sweetalert = _interopRequireDefault(__nccwpck_require__(6091));
7100
7100
 
@@ -7283,7 +7283,7 @@ exports["default"] = exports.hillo = void 0;
7283
7283
 
7284
7284
  var _request = _interopRequireDefault(__nccwpck_require__(7798));
7285
7285
 
7286
- var _http = __nccwpck_require__(2081);
7286
+ var _http = __nccwpck_require__(7299);
7287
7287
 
7288
7288
  function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
7289
7289
 
@@ -18453,7 +18453,7 @@ module.exports = require("assert");
18453
18453
 
18454
18454
  /***/ }),
18455
18455
 
18456
- /***/ 8493:
18456
+ /***/ 2081:
18457
18457
  /***/ ((module) => {
18458
18458
 
18459
18459
  "use strict";
@@ -18577,7 +18577,7 @@ module.exports = JSON.parse('{"name":"axios","version":"0.19.2","description":"P
18577
18577
  /***/ ((module) => {
18578
18578
 
18579
18579
  "use strict";
18580
- module.exports = JSON.parse('{"i8":"1.6.103-rc.2"}');
18580
+ module.exports = {"i8":"1.6.103"};
18581
18581
 
18582
18582
  /***/ })
18583
18583
 
@@ -18657,6 +18657,9 @@ var __webpack_exports__ = {};
18657
18657
  const dayjs = __nccwpck_require__(7401)
18658
18658
  const colors = __nccwpck_require__(3045)
18659
18659
  const fs = __nccwpck_require__(7147)
18660
+ const path = __nccwpck_require__(1017)
18661
+ const {execSync} = __nccwpck_require__(2081)
18662
+ const os = __nccwpck_require__(2037)
18660
18663
  const InnerKenTimeStampFormat = 'YYYYMMDDHHmmss'
18661
18664
  const prompts = __nccwpck_require__(9646)
18662
18665
  const {deviceEcho, logAction, getCurrentDeviceId} = __nccwpck_require__(7415)
@@ -18681,6 +18684,8 @@ program
18681
18684
  .option('-d, --debug', 'use Debug Out put')
18682
18685
  program.command('update [channel]').action(update)
18683
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 调用
18684
18689
  program.command('rollback').action(rollback)
18685
18690
  program
18686
18691
  .command('install <deviceId>')
@@ -19233,6 +19238,78 @@ async function lightUpdate() {
19233
19238
  }
19234
19239
  }
19235
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
+
19236
19313
  /** 比较点分数字版本,a > b 返回 true。'1.9.61' > '1.7.908' === true。 */
19237
19314
  function versionGt(a, b) {
19238
19315
  const pa = String(a).split('.').map(n => parseInt(n) || 0)
package/index.js CHANGED
@@ -6,6 +6,9 @@
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')
@@ -30,6 +33,8 @@ program
30
33
  .option('-d, --debug', 'use Debug Out put')
31
34
  program.command('update [channel]').action(update)
32
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 调用
33
38
  program.command('rollback').action(rollback)
34
39
  program
35
40
  .command('install <deviceId>')
@@ -582,6 +587,78 @@ async function lightUpdate() {
582
587
  }
583
588
  }
584
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
+
585
662
  /** 比较点分数字版本,a > b 返回 true。'1.9.61' > '1.7.908' === true。 */
586
663
  function versionGt(a, b) {
587
664
  const pa = String(a).split('.').map(n => parseInt(n) || 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aaden-cli",
3
- "version": "1.6.103-rc.2",
3
+ "version": "1.6.103",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {