aaden-cli 1.6.121 → 1.6.122

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.
@@ -13,7 +13,9 @@ local(localhost/127.x,收银机本机)、lan(局域网 IP,平板等)
13
13
 
14
14
  用法: appCenterUsage.py [日志目录,默认 /var/log/apache2];需要能读 access.log*(root 或 adm 组)。
15
15
  """
16
+ import datetime
16
17
  import glob
18
+ import hashlib
17
19
  import gzip
18
20
  import json
19
21
  import os
@@ -62,6 +64,64 @@ def parse_ts(ts):
62
64
  return day, f'{day}T{hms}'
63
65
 
64
66
 
67
+ # index.html 里引用的入口 js(Vue CLI:js/app.1234abcd.js;Vite:assets/index-AbCd1234.js,老版 Vite:assets/index.1234abcd.js)
68
+ MAIN_JS = re.compile(r'''src=["']?[^"'\s>]*?((?:js/(?:app|main|index)\.[0-9a-f]{8}|assets/(?:index|main)[.-][\w-]{8,})\.js)''')
69
+
70
+
71
+ def app_version(rel):
72
+ try:
73
+ return _app_version(rel)
74
+ except Exception: # 兜底:版本信息只是附带的,任何意外都不能让整份访问量上报失败
75
+ return {'version': 'unknown'}
76
+
77
+
78
+ def _app_version(rel):
79
+ """
80
+ 某个前端目录装的是哪个版本(前端版本遥测 A 层,云端 device_app_inventory.app_versions)。值一律是对象:
81
+ version / commit AppCenter 发布时写进产物的 version.json(innerken-gmbh/workflows appcenter-publish);
82
+ 没有就读目录里的 package.json;都没有是 "unknown"
83
+ treeSha AppCenter 目录的 .appcenter-manifest 的 sha256,跟 S3 manifest 的 treeSha256 一致
84
+ fingerprint index.html 的 sha256 前 16 位 + 入口 js 文件名:根目录老前端(不走 AppCenter、没有 version.json)
85
+ mainJs 靠它区分是哪次构建,云端按已知构建换算成版本号
86
+ deployedAt index.html 的修改时间(≈ 装到这台机器的时间,不是构建时间)
87
+ 读不到的字段就不带,任何异常都不能影响访问量上报。
88
+ """
89
+ d = os.path.join(DOCROOT, rel)
90
+ info = {'version': 'unknown'}
91
+ try:
92
+ for name, keys in (('version.json', ('version', 'commit')), ('package.json', ('version',))):
93
+ f = os.path.join(d, name)
94
+ if os.path.isfile(f):
95
+ with open(f, encoding='utf-8', errors='replace') as fh:
96
+ data = json.load(fh)
97
+ for k in keys:
98
+ v = str(data.get(k) or '').strip()
99
+ if v:
100
+ info[k] = v[:40]
101
+ break
102
+ except (OSError, ValueError, AttributeError):
103
+ pass
104
+ try:
105
+ mf = os.path.join(d, '.appcenter-manifest')
106
+ if os.path.isfile(mf):
107
+ with open(mf, 'rb') as fh:
108
+ info['treeSha'] = hashlib.sha256(fh.read()).hexdigest()
109
+ except OSError:
110
+ pass
111
+ try:
112
+ idx = os.path.join(d, 'index.html')
113
+ with open(idx, 'rb') as fh:
114
+ html = fh.read()
115
+ info['fingerprint'] = hashlib.sha256(html).hexdigest()[:16]
116
+ m = MAIN_JS.search(html.decode('utf-8', 'replace'))
117
+ if m:
118
+ info['mainJs'] = m.group(1)[:64]
119
+ info['deployedAt'] = datetime.datetime.fromtimestamp(os.path.getmtime(idx)).strftime('%Y-%m-%dT%H:%M:%S')
120
+ except OSError:
121
+ pass
122
+ return info
123
+
124
+
65
125
  def main():
66
126
  installed = []
67
127
  if os.path.isdir(APPCENTER):
@@ -135,6 +195,7 @@ def main():
135
195
  'logTo': log_to,
136
196
  'totalRequests': total,
137
197
  'installedApps': installed,
198
+ 'appVersions': {app: app_version(app) for app in installed},
138
199
  'usage': [
139
200
  {
140
201
  'app': app, 'day': day, 'pageLoads': s['pageLoads'], 'requests': s['requests'],
@@ -10,6 +10,13 @@
10
10
  # —— 光靠退出码分不清"TSE 忙跳过"和"产物陈旧",deviceLog 里就只剩一句含糊的失败。
11
11
  set -uo pipefail
12
12
  S3="https://aaden-backend.s3.eu-central-1.amazonaws.com"
13
+ # 参数只有 staging 通道(modules/release-channel.js,测试机)会传;不带参数 = 原来的 stable 路径,URL 和判断都不变。
14
+ # $1 = staging → 从 S3 的 staging/ 前缀拉(composer.lock 变了不退回全量包,直接跳过,由调用方这次改走 stable)
15
+ # $2 = semver → 版本带「-」后缀时按 semver 比(1.9.83-staging.15 < 1.9.83);纯 x.y.z 之间照旧
16
+ CHANNEL="${1:-}"
17
+ CMP="${2:-}"
18
+ PREFIX=""
19
+ [ "$CHANNEL" = staging ] && PREFIX="/staging"
13
20
  STAGE=/var/tmp/aaden-lu-stage # 绝对路径:download 与 sudo cp 必须看到同一个目录(~ 在 sudo 下会变 /root)
14
21
  BACKUP=/var/lib/aaden-php-backup
15
22
  PW='Innerken322.'
@@ -29,13 +36,29 @@ if exec 9<"$LOCK" 2>/dev/null; then
29
36
  fi
30
37
 
31
38
  # 点分数字版本比较:a > b 返回 0。与 index.js 的 versionGt 同一套语义('1.9.61' > '1.7.908')。
32
- verGt(){ python3 -c "
39
+ # 只有 staging 通道传了 semver、并且两个版本里至少一个带「-」后缀(1.9.83-staging.15)时才改用 verGtSemver;
40
+ # 纯 x.y.z 之间、以及不带参数的 stable 路径,一律走下面原来的比较,结果和改动前逐个一致。
41
+ verGt(){ [ "$CMP" = semver ] && case "$1$2" in *-*) verGtSemver "$1" "$2"; return;; esac; python3 -c "
33
42
  import sys
34
43
  pa=[int(x) if x.isdigit() else 0 for x in sys.argv[1].split('.')]
35
44
  pb=[int(x) if x.isdigit() else 0 for x in sys.argv[2].split('.')]
36
45
  n=max(len(pa),len(pb)); pa+=[0]*(n-len(pa)); pb+=[0]*(n-len(pb))
37
46
  sys.exit(0 if pa>pb else 1)" "$1" "$2" 2>/dev/null; }
38
47
 
48
+ # semver:x.y.z[-预发布],有预发布的 < 没有的,预发布逐段比(数字段按数值、数字段 < 字母段);不合法一律「不大于」(不更新)
49
+ verGtSemver(){ python3 -c "
50
+ import re,sys
51
+ def p(v):
52
+ m=re.fullmatch(r'(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?',v.strip())
53
+ if not m: return None
54
+ return [int(x) for x in m.groups()[:3]], (m.group(4).split('.') if m.group(4) else [])
55
+ def key(pre):
56
+ if not pre: return (1,)
57
+ return (0,)+tuple((0,int(x),'') if x.isdigit() else (1,0,x) for x in pre)+((-1,0,''),)
58
+ a,b=p(sys.argv[1]),p(sys.argv[2])
59
+ if a is None or b is None: sys.exit(1)
60
+ sys.exit(0 if (a[0],key(a[1]))>(b[0],key(b[1])) else 1)" "$1" "$2" 2>/dev/null; }
61
+
39
62
  # 读 package.json 的 version;读不到输出空串。
40
63
  pkgVer(){ python3 -c "
41
64
  import json,sys
@@ -48,12 +71,15 @@ except Exception: print('')" "$1" 2>/dev/null; }
48
71
  if ! echo "$PW" | sudo -S true 2>/dev/null; then finish "ABORT_SUDO_AUTH (aaden sudo 密码不符?)" 1; fi
49
72
 
50
73
  # ---- composer.lock 门 ----
51
- REMOTE_LOCK=$(curl -s "$S3/raw/PHP/composer.lock" --max-time 30)
74
+ REMOTE_LOCK=$(curl -s "$S3$PREFIX/raw/PHP/composer.lock" --max-time 30)
52
75
  LOCAL_LOCK=$(cat /var/www/html/PHP/composer.lock 2>/dev/null)
53
76
  RH=$(printf '%s' "$REMOTE_LOCK" | shasum -a 256 | cut -d' ' -f1)
54
77
  LH=$(printf '%s' "$LOCAL_LOCK" | shasum -a 256 | cut -d' ' -f1)
55
78
  if [ -n "$REMOTE_LOCK" ] && [ "$RH" = "$LH" ]; then
56
79
  ARTIFACT="code.zip"; echo "composer.lock 一致 → 轻量 code.zip"
80
+ elif [ "$CHANNEL" = staging ]; then
81
+ # staging/ 下没有全量包:vendor 变了就这次不走 staging(调用方改走 stable),不拿 stable 的全量包冒充
82
+ finish "SKIP_STAGING_LOCK (staging 的 composer.lock 和本机不一致或取不到,这次不走 staging)" 1
57
83
  else
58
84
  ARTIFACT="latest.zip"; echo "composer.lock 变化(或取不到) → 全量 latest.zip"
59
85
  fi
@@ -69,7 +95,7 @@ ZIP="$STAGE/$ARTIFACT"
69
95
  DL=/var/tmp/aaden-lu-dl
70
96
  PART="$DL/$ARTIFACT.part"
71
97
  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')
98
+ HEAD=$(curl -sfI "$S3$PREFIX/$ARTIFACT" --max-time 30 | tr -d '\r')
73
99
  ETAG=$(printf '%s\n' "$HEAD" | awk 'tolower($1)=="etag:"{print $2}' | tr -d '"')
74
100
  SIZE=$(printf '%s\n' "$HEAD" | awk 'tolower($1)=="content-length:"{print $2}')
75
101
  if [ -f "$PART" ] && [ "$(cat "$DL/$ARTIFACT.etag" 2>/dev/null)" != "$ETAG" ]; then
@@ -77,7 +103,7 @@ if [ -f "$PART" ] && [ "$(cat "$DL/$ARTIFACT.etag" 2>/dev/null)" != "$ETAG" ]; t
77
103
  fi
78
104
  echo "$ETAG" > "$DL/$ARTIFACT.etag"
79
105
  [ -f "$PART" ] && echo "接着上次续传:已有 $(( $(stat -c %s "$PART") / 1048576 )) MB"
80
- curl -fsSL -C - "$S3/$ARTIFACT" -o "$PART" --max-time 1200
106
+ curl -fsSL -C - "$S3$PREFIX/$ARTIFACT" -o "$PART" --max-time 1200
81
107
  GOT=$(stat -c %s "$PART" 2>/dev/null || echo 0)
82
108
  if [ -n "$SIZE" ] && [ "$GOT" -lt "$SIZE" ]; then
83
109
  # 没下完:半截留着下次接着下,这次什么都不动
package/dist/index.js CHANGED
@@ -570,6 +570,89 @@ function collectHostInfo() {
570
570
  module.exports = {collectHostInfo}
571
571
 
572
572
 
573
+ /***/ }),
574
+
575
+ /***/ 6096:
576
+ /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => {
577
+
578
+ /**
579
+ * 后端发布通道(staging 预发布)。只给测试机用:ik0001(设备号 1)和测试门店农场 9020–9099。
580
+ *
581
+ * - 通道写在本机文件 /etc/aaden/config/release-channel(`aaden-cli release-channel staging|stable` 写,root 写、644);
582
+ * - light-update 只有在「文件里写的是 staging」并且「本机设备号是测试机」时才走 staging,其他一律是原来的 stable 路径;
583
+ * - staging 的包由 php 仓库 staging 分支的 workflow 发到 S3 的 staging/ 前缀,版本号形如 1.9.83-staging.15。
584
+ *
585
+ * 不是测试机、没有文件、文件内容不是 staging:light-update 的代码路径和请求的 URL 跟原来完全一样(只多读一次本地文件)。
586
+ * 兼容 node 10(门店上最老的 node),不用可选链等新语法。
587
+ */
588
+ const fs = __nccwpck_require__(7147)
589
+
590
+ const CHANNEL_FILE = '/etc/aaden/config/release-channel'
591
+ const STAGING = 'staging'
592
+ const STABLE = 'stable'
593
+
594
+ /** 测试机:1 号机,或测试门店农场号段 9020–9099(和云端 TestStores 默认值一致,写死在这里,不信任何外部配置) */
595
+ function isTestDevice(deviceId) {
596
+ const id = parseInt(String(deviceId).trim(), 10)
597
+ if (isNaN(id)) return false
598
+ return id === 1 || (id >= 9020 && id <= 9099)
599
+ }
600
+
601
+ /** 本机文件里写的通道;读不到、不认识一律 stable */
602
+ function readChannelFile() {
603
+ try {
604
+ const v = fs.readFileSync(CHANNEL_FILE, 'utf8').trim().toLowerCase()
605
+ return v === STAGING ? STAGING : STABLE
606
+ } catch (e) {
607
+ return STABLE
608
+ }
609
+ }
610
+
611
+ /**
612
+ * semver 解析:只认 x.y.z 和 x.y.z-<预发布标识>。不合法返回 null。
613
+ * 预发布标识按 semver:逐段比,纯数字段按数值、数字段 < 字母段,段少的小;有预发布标识的 < 没有的。
614
+ */
615
+ function parseSemver(v) {
616
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim())
617
+ if (!m) return null
618
+ return {core: [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)], pre: m[4] ? m[4].split('.') : []}
619
+ }
620
+
621
+ /** a 比 b 大返回 1,小返回 -1,相等 0;任一不合法返回 null(调用方按「不更新」处理) */
622
+ function semverCompare(a, b) {
623
+ const pa = parseSemver(a), pb = parseSemver(b)
624
+ if (!pa || !pb) return null
625
+ for (let i = 0; i < 3; i++) {
626
+ if (pa.core[i] !== pb.core[i]) return pa.core[i] > pb.core[i] ? 1 : -1
627
+ }
628
+ if (pa.pre.length === 0 && pb.pre.length === 0) return 0
629
+ if (pa.pre.length === 0) return 1
630
+ if (pb.pre.length === 0) return -1
631
+ for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) {
632
+ const x = pa.pre[i], y = pb.pre[i]
633
+ if (x === undefined) return -1
634
+ if (y === undefined) return 1
635
+ const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y)
636
+ if (xn && yn) {
637
+ const d = parseInt(x, 10) - parseInt(y, 10)
638
+ if (d !== 0) return d > 0 ? 1 : -1
639
+ } else if (xn !== yn) {
640
+ return xn ? -1 : 1
641
+ } else if (x !== y) {
642
+ return x > y ? 1 : -1
643
+ }
644
+ }
645
+ return 0
646
+ }
647
+
648
+ /** a > b;不合法按 false(不更新) */
649
+ function semverGt(a, b) {
650
+ return semverCompare(a, b) === 1
651
+ }
652
+
653
+ module.exports = {CHANNEL_FILE, STAGING, STABLE, isTestDevice, readChannelFile, parseSemver, semverCompare, semverGt}
654
+
655
+
573
656
  /***/ }),
574
657
 
575
658
  /***/ 5906:
@@ -19269,7 +19352,7 @@ module.exports = JSON.parse('{"name":"axios","version":"0.19.2","description":"P
19269
19352
  /***/ ((module) => {
19270
19353
 
19271
19354
  "use strict";
19272
- module.exports = {"i8":"1.6.121"};
19355
+ module.exports = {"i8":"1.6.122"};
19273
19356
 
19274
19357
  /***/ })
19275
19358
 
@@ -19355,6 +19438,7 @@ const os = __nccwpck_require__(2037)
19355
19438
  const InnerKenTimeStampFormat = 'YYYYMMDDHHmmss'
19356
19439
  const prompts = __nccwpck_require__(9646)
19357
19440
  const {deviceEcho, logAction, getCurrentDeviceId} = __nccwpck_require__(7415)
19441
+ const releaseChannel = __nccwpck_require__(6096)
19358
19442
  const {collectHostInfo} = __nccwpck_require__(3727)
19359
19443
  const {collectTseInfo} = __nccwpck_require__(6677)
19360
19444
  const {parseSnapListVersion} = __nccwpck_require__(5906)
@@ -19379,6 +19463,7 @@ program
19379
19463
  .option('-d, --debug', 'use Debug Out put')
19380
19464
  program.command('update [channel]').action(update)
19381
19465
  program.command('light-update').action(lightUpdate) // 轻量+带闸门的后端部署,供 auto-upgrade/patch 调用
19466
+ program.command('release-channel [channel]').action(setReleaseChannel) // 测试机切后端发布通道 staging|stable(不带参数 = 查看)
19382
19467
  program.command('appcenter-usage [force]').action(appCenterUsage) // 门店前端目录(AppCenter 各应用 + 根目录老前端)访问量上报云端,供 auto-upgrade 每天调一次
19383
19468
  program.command('appcenter-sync [channel]').action(appCenterSync) // AppCenter 前端按应用同步,供 auto-upgrade 调用
19384
19469
  program.command('rollback').action(rollback)
@@ -19883,6 +19968,13 @@ async function update(channel = '') {
19883
19968
  * 具体状态由 lightUpdate.sh 写在 /var/tmp/aaden-lu-status,这里读出来一并上报
19884
19969
  */
19885
19970
  async function lightUpdate() {
19971
+ // staging 通道(modules/release-channel.js):本机文件写着 staging 并且是测试机(1、9020–9099)才走;
19972
+ // 其他一律是下面原来的 stable 路径,只多读一次本地文件
19973
+ if (releaseChannel.readChannelFile() === releaseChannel.STAGING) {
19974
+ const deviceId = await getCurrentDeviceId()
19975
+ if (releaseChannel.isTestDevice(deviceId)) return lightUpdateStaging(deviceId)
19976
+ console.log(('通道文件写着 staging,但设备号 ' + deviceId + ' 不是测试机,按 stable 走').yellow)
19977
+ }
19886
19978
  try {
19887
19979
  const localVersion = getBackendVersionOffline()
19888
19980
  const remoteVersion = (await hillo.get('https://aaden-backend.s3.eu-central-1.amazonaws.com/raw/package.json')).version
@@ -20015,6 +20107,139 @@ async function appCenterSync(channel = 'stable') {
20015
20107
  }
20016
20108
  }
20017
20109
 
20110
+ /**
20111
+ * staging 通道的 light-update(只有测试机会进来):
20112
+ * - 取 stable(raw/package.json)和 staging(staging/raw/package.json)两份清单,选更高的那个;
20113
+ * stable 清单拿不到 → 整次中止;staging 清单拿不到或格式不对 → 这一次按 stable;
20114
+ * - maxVersion 灰度门照旧;
20115
+ * - 选中 staging 时 lightUpdate.sh 带 staging 参数(拉 staging/ 前缀);下载之前就失败(staging 的 composer.lock 变了、
20116
+ * 下载失败、包有问题)→ 这一次改走 stable;部署后回滚、TSE 忙、别的 light-update 在跑 → 不改走;
20117
+ * - 版本比较见 versionGtChannel:带「-」后缀才按 semver(1.9.83-staging.15 < 1.9.83,stable 发版后测试机自动跟上正式版),
20118
+ * 纯 x.y.z 之间和原来的 versionGt 完全一样。
20119
+ */
20120
+ async function lightUpdateStaging(deviceId) {
20121
+ const S3 = 'https://aaden-backend.s3.eu-central-1.amazonaws.com'
20122
+ const tag = 'light-update[staging]'
20123
+ try {
20124
+ const localVersion = getBackendVersionOffline()
20125
+ let stableVersion = null
20126
+ try {
20127
+ stableVersion = (await hillo.get(S3 + '/raw/package.json')).version
20128
+ } catch (e) {
20129
+ console.log('取 stable 清单失败:', e.message)
20130
+ }
20131
+ if (!releaseChannel.parseSemver(stableVersion)) {
20132
+ process.exitCode = 1
20133
+ await logAction(tag + ' 中止:取不到 stable 清单(' + stableVersion + ')', 1)
20134
+ return
20135
+ }
20136
+ let stagingVersion = null
20137
+ try {
20138
+ stagingVersion = (await hillo.get(S3 + '/staging/raw/package.json')).version
20139
+ } catch (e) {
20140
+ console.log('取 staging 清单失败,这次按 stable:', e.message)
20141
+ }
20142
+ if (stagingVersion && !releaseChannel.parseSemver(stagingVersion)) {
20143
+ console.log(('staging 清单版本格式不对(' + stagingVersion + '),这次按 stable').yellow)
20144
+ stagingVersion = null
20145
+ }
20146
+ const useStaging = !!stagingVersion && versionGtChannel(stagingVersion, stableVersion)
20147
+ const target = useStaging ? stagingVersion : stableVersion
20148
+ if (!versionGtChannel(target, localVersion)) {
20149
+ console.log((tag + ' 已是最新或更高 (本机 ' + localVersion + ' / stable ' + stableVersion + ' / staging ' + (stagingVersion || '-') + '),跳过').green)
20150
+ return
20151
+ }
20152
+ const maxVersion = await getDeviceMaxVersion(deviceId)
20153
+ if (maxVersion && maxVersion !== '-1' && versionGtChannel(target, maxVersion)) {
20154
+ const msg = tag + ' 跳过:灰度闸门 target=' + target + ' > maxVersion=' + maxVersion
20155
+ console.log(msg.yellow)
20156
+ await logAction(msg, 0)
20157
+ return
20158
+ }
20159
+
20160
+ let run = await runLightUpdateScript(useStaging ? 'staging' : 'stable')
20161
+ let channelUsed = useStaging ? 'staging' : 'stable'
20162
+ // 下载之前就失败的这几种,这一次改走 stable(前提是 stable 比本机新);部署后回滚 / TSE 忙等不改走。
20163
+ // SKIP_DOWNLOAD_PARTIAL(半截包留着下次续传)退出码是 0,本来就不回落,不列在这里
20164
+ const FALLBACK = ['SKIP_STAGING_LOCK', 'ABORT_DOWNLOAD', 'ABORT_BAD_ARTIFACT', 'ABORT_NO_VERSION']
20165
+ if (useStaging && run.code !== 0 && FALLBACK.some(s => run.status.indexOf(s) === 0) &&
20166
+ versionGtChannel(stableVersion, localVersion)) {
20167
+ console.log(('staging 这次没走通(' + run.status + '),改走 stable').yellow)
20168
+ run = await runLightUpdateScript('stable')
20169
+ channelUsed = 'stable(staging 回落)'
20170
+ }
20171
+ const suffix = run.status ? ' [' + run.status + ']' : ''
20172
+ const newVersion = readBackendVersionFresh()
20173
+ if (run.code !== 0) {
20174
+ process.exitCode = run.code || 1
20175
+ await logAction(tag + ' 失败/未部署 (仍 ' + newVersion + ',通道 ' + channelUsed + ')' + suffix, run.code || 1)
20176
+ } else if (newVersion !== localVersion) {
20177
+ await logAction(tag + ' 完成 ' + localVersion + ' -> ' + newVersion + '(通道 ' + channelUsed + ')' + suffix, 0)
20178
+ } else {
20179
+ await logAction(tag + ' 无实际变化 (' + newVersion + ',通道 ' + channelUsed + ')' + suffix, 0)
20180
+ }
20181
+ } catch (e) {
20182
+ console.error(e)
20183
+ try { await logAction(tag + ' 异常: ' + (e.message || e), 1) } catch (_) {}
20184
+ }
20185
+ }
20186
+
20187
+ /** staging 通道跑一次 lightUpdate.sh:channel = staging | stable,带 semver 参数(脚本里只在版本带「-」时才按 semver 比)。返回退出码和末行状态 */
20188
+ async function runLightUpdateScript(channel) {
20189
+ try { fs.unlinkSync(LU_STATUS_FILE) } catch (e) { /* 本来就没有,正常 */ }
20190
+ const command = [
20191
+ ...getPrivilegeCMD('AadenDeploy/lightUpdate.sh'),
20192
+ 'cd AadenDeploy',
20193
+ './lightUpdate.sh ' + (channel === 'staging' ? 'staging' : 'stable') + ' semver'
20194
+ ]
20195
+ const {code} = await cli.executeInShell(command, program.debug)
20196
+ return {code, status: readLightUpdateStatus()}
20197
+ }
20198
+
20199
+ /** 测试机切后端发布通道:写 /etc/aaden/config/release-channel(root 写、644);不带参数 = 查看当前通道 */
20200
+ async function setReleaseChannel(channel) {
20201
+ const deviceId = await getCurrentDeviceId()
20202
+ const test = releaseChannel.isTestDevice(deviceId)
20203
+ if (!channel) {
20204
+ console.log('设备 ' + deviceId + '(' + (test ? '测试机' : '不是测试机,通道文件不生效') + '),通道文件:' + releaseChannel.readChannelFile())
20205
+ return
20206
+ }
20207
+ const v = String(channel).trim().toLowerCase()
20208
+ if (v !== releaseChannel.STAGING && v !== releaseChannel.STABLE) {
20209
+ console.log('通道只能是 staging 或 stable'.red)
20210
+ process.exitCode = 1
20211
+ return
20212
+ }
20213
+ if (v === releaseChannel.STAGING && !test) {
20214
+ console.log(('设备 ' + deviceId + ' 不是测试机(只有 1 和 9020–9099 能切 staging)').red)
20215
+ process.exitCode = 1
20216
+ return
20217
+ }
20218
+ const file = releaseChannel.CHANNEL_FILE
20219
+ // 密码走 sudo -S 的标准输入,写文件放在 bash -c 里(不能把内容也从管道喂给 sudo:会被当成密码吃掉);v 只可能是 staging / stable
20220
+ const {code} = await cli.executeInShell([
20221
+ 'echo "Innerken322." | sudo -S -p "" bash -c "mkdir -p ' + path.dirname(file) + ' && printf \'%s\\n\' ' + v + ' > ' + file + ' && chmod 644 ' + file + '"',
20222
+ ], program.debug)
20223
+ if (code !== 0) {
20224
+ process.exitCode = code
20225
+ console.log('写通道文件失败'.red)
20226
+ return
20227
+ }
20228
+ console.log(('后端发布通道已设为 ' + v + '(下一次 light-update 生效)' +
20229
+ (v === releaseChannel.STABLE ? ';本机版本如果比 stable 新,按现有规则不会降级' : '')).green)
20230
+ await logAction('release-channel → ' + v, 0)
20231
+ }
20232
+
20233
+ /**
20234
+ * staging 通道用的版本比较:两个版本里至少一个带「-」后缀(1.9.83-staging.15)才按 semver 比;
20235
+ * 纯 x.y.z 之间一律走原来的 versionGt,结果和改动前逐个一致。
20236
+ */
20237
+ function versionGtChannel(a, b) {
20238
+ return (String(a).indexOf('-') >= 0 || String(b).indexOf('-') >= 0)
20239
+ ? releaseChannel.semverGt(a, b)
20240
+ : versionGt(a, b)
20241
+ }
20242
+
20018
20243
  /** 比较点分数字版本,a > b 返回 true。'1.9.61' > '1.7.908' === true。 */
20019
20244
  function versionGt(a, b) {
20020
20245
  const pa = String(a).split('.').map(n => parseInt(n) || 0)
package/index.js CHANGED
@@ -12,6 +12,7 @@ const os = require('os')
12
12
  const InnerKenTimeStampFormat = 'YYYYMMDDHHmmss'
13
13
  const prompts = require('prompts')
14
14
  const {deviceEcho, logAction, getCurrentDeviceId} = require('./modules/firebase')
15
+ const releaseChannel = require('./modules/release-channel')
15
16
  const {collectHostInfo} = require('./modules/host-info')
16
17
  const {collectTseInfo} = require('./modules/tse-info')
17
18
  const {parseSnapListVersion} = require('./modules/snap-version')
@@ -36,6 +37,7 @@ program
36
37
  .option('-d, --debug', 'use Debug Out put')
37
38
  program.command('update [channel]').action(update)
38
39
  program.command('light-update').action(lightUpdate) // 轻量+带闸门的后端部署,供 auto-upgrade/patch 调用
40
+ program.command('release-channel [channel]').action(setReleaseChannel) // 测试机切后端发布通道 staging|stable(不带参数 = 查看)
39
41
  program.command('appcenter-usage [force]').action(appCenterUsage) // 门店前端目录(AppCenter 各应用 + 根目录老前端)访问量上报云端,供 auto-upgrade 每天调一次
40
42
  program.command('appcenter-sync [channel]').action(appCenterSync) // AppCenter 前端按应用同步,供 auto-upgrade 调用
41
43
  program.command('rollback').action(rollback)
@@ -540,6 +542,13 @@ async function update(channel = '') {
540
542
  * 具体状态由 lightUpdate.sh 写在 /var/tmp/aaden-lu-status,这里读出来一并上报
541
543
  */
542
544
  async function lightUpdate() {
545
+ // staging 通道(modules/release-channel.js):本机文件写着 staging 并且是测试机(1、9020–9099)才走;
546
+ // 其他一律是下面原来的 stable 路径,只多读一次本地文件
547
+ if (releaseChannel.readChannelFile() === releaseChannel.STAGING) {
548
+ const deviceId = await getCurrentDeviceId()
549
+ if (releaseChannel.isTestDevice(deviceId)) return lightUpdateStaging(deviceId)
550
+ console.log(('通道文件写着 staging,但设备号 ' + deviceId + ' 不是测试机,按 stable 走').yellow)
551
+ }
543
552
  try {
544
553
  const localVersion = getBackendVersionOffline()
545
554
  const remoteVersion = (await hillo.get('https://aaden-backend.s3.eu-central-1.amazonaws.com/raw/package.json')).version
@@ -672,6 +681,139 @@ async function appCenterSync(channel = 'stable') {
672
681
  }
673
682
  }
674
683
 
684
+ /**
685
+ * staging 通道的 light-update(只有测试机会进来):
686
+ * - 取 stable(raw/package.json)和 staging(staging/raw/package.json)两份清单,选更高的那个;
687
+ * stable 清单拿不到 → 整次中止;staging 清单拿不到或格式不对 → 这一次按 stable;
688
+ * - maxVersion 灰度门照旧;
689
+ * - 选中 staging 时 lightUpdate.sh 带 staging 参数(拉 staging/ 前缀);下载之前就失败(staging 的 composer.lock 变了、
690
+ * 下载失败、包有问题)→ 这一次改走 stable;部署后回滚、TSE 忙、别的 light-update 在跑 → 不改走;
691
+ * - 版本比较见 versionGtChannel:带「-」后缀才按 semver(1.9.83-staging.15 < 1.9.83,stable 发版后测试机自动跟上正式版),
692
+ * 纯 x.y.z 之间和原来的 versionGt 完全一样。
693
+ */
694
+ async function lightUpdateStaging(deviceId) {
695
+ const S3 = 'https://aaden-backend.s3.eu-central-1.amazonaws.com'
696
+ const tag = 'light-update[staging]'
697
+ try {
698
+ const localVersion = getBackendVersionOffline()
699
+ let stableVersion = null
700
+ try {
701
+ stableVersion = (await hillo.get(S3 + '/raw/package.json')).version
702
+ } catch (e) {
703
+ console.log('取 stable 清单失败:', e.message)
704
+ }
705
+ if (!releaseChannel.parseSemver(stableVersion)) {
706
+ process.exitCode = 1
707
+ await logAction(tag + ' 中止:取不到 stable 清单(' + stableVersion + ')', 1)
708
+ return
709
+ }
710
+ let stagingVersion = null
711
+ try {
712
+ stagingVersion = (await hillo.get(S3 + '/staging/raw/package.json')).version
713
+ } catch (e) {
714
+ console.log('取 staging 清单失败,这次按 stable:', e.message)
715
+ }
716
+ if (stagingVersion && !releaseChannel.parseSemver(stagingVersion)) {
717
+ console.log(('staging 清单版本格式不对(' + stagingVersion + '),这次按 stable').yellow)
718
+ stagingVersion = null
719
+ }
720
+ const useStaging = !!stagingVersion && versionGtChannel(stagingVersion, stableVersion)
721
+ const target = useStaging ? stagingVersion : stableVersion
722
+ if (!versionGtChannel(target, localVersion)) {
723
+ console.log((tag + ' 已是最新或更高 (本机 ' + localVersion + ' / stable ' + stableVersion + ' / staging ' + (stagingVersion || '-') + '),跳过').green)
724
+ return
725
+ }
726
+ const maxVersion = await getDeviceMaxVersion(deviceId)
727
+ if (maxVersion && maxVersion !== '-1' && versionGtChannel(target, maxVersion)) {
728
+ const msg = tag + ' 跳过:灰度闸门 target=' + target + ' > maxVersion=' + maxVersion
729
+ console.log(msg.yellow)
730
+ await logAction(msg, 0)
731
+ return
732
+ }
733
+
734
+ let run = await runLightUpdateScript(useStaging ? 'staging' : 'stable')
735
+ let channelUsed = useStaging ? 'staging' : 'stable'
736
+ // 下载之前就失败的这几种,这一次改走 stable(前提是 stable 比本机新);部署后回滚 / TSE 忙等不改走。
737
+ // SKIP_DOWNLOAD_PARTIAL(半截包留着下次续传)退出码是 0,本来就不回落,不列在这里
738
+ const FALLBACK = ['SKIP_STAGING_LOCK', 'ABORT_DOWNLOAD', 'ABORT_BAD_ARTIFACT', 'ABORT_NO_VERSION']
739
+ if (useStaging && run.code !== 0 && FALLBACK.some(s => run.status.indexOf(s) === 0) &&
740
+ versionGtChannel(stableVersion, localVersion)) {
741
+ console.log(('staging 这次没走通(' + run.status + '),改走 stable').yellow)
742
+ run = await runLightUpdateScript('stable')
743
+ channelUsed = 'stable(staging 回落)'
744
+ }
745
+ const suffix = run.status ? ' [' + run.status + ']' : ''
746
+ const newVersion = readBackendVersionFresh()
747
+ if (run.code !== 0) {
748
+ process.exitCode = run.code || 1
749
+ await logAction(tag + ' 失败/未部署 (仍 ' + newVersion + ',通道 ' + channelUsed + ')' + suffix, run.code || 1)
750
+ } else if (newVersion !== localVersion) {
751
+ await logAction(tag + ' 完成 ' + localVersion + ' -> ' + newVersion + '(通道 ' + channelUsed + ')' + suffix, 0)
752
+ } else {
753
+ await logAction(tag + ' 无实际变化 (' + newVersion + ',通道 ' + channelUsed + ')' + suffix, 0)
754
+ }
755
+ } catch (e) {
756
+ console.error(e)
757
+ try { await logAction(tag + ' 异常: ' + (e.message || e), 1) } catch (_) {}
758
+ }
759
+ }
760
+
761
+ /** staging 通道跑一次 lightUpdate.sh:channel = staging | stable,带 semver 参数(脚本里只在版本带「-」时才按 semver 比)。返回退出码和末行状态 */
762
+ async function runLightUpdateScript(channel) {
763
+ try { fs.unlinkSync(LU_STATUS_FILE) } catch (e) { /* 本来就没有,正常 */ }
764
+ const command = [
765
+ ...getPrivilegeCMD('AadenDeploy/lightUpdate.sh'),
766
+ 'cd AadenDeploy',
767
+ './lightUpdate.sh ' + (channel === 'staging' ? 'staging' : 'stable') + ' semver'
768
+ ]
769
+ const {code} = await cli.executeInShell(command, program.debug)
770
+ return {code, status: readLightUpdateStatus()}
771
+ }
772
+
773
+ /** 测试机切后端发布通道:写 /etc/aaden/config/release-channel(root 写、644);不带参数 = 查看当前通道 */
774
+ async function setReleaseChannel(channel) {
775
+ const deviceId = await getCurrentDeviceId()
776
+ const test = releaseChannel.isTestDevice(deviceId)
777
+ if (!channel) {
778
+ console.log('设备 ' + deviceId + '(' + (test ? '测试机' : '不是测试机,通道文件不生效') + '),通道文件:' + releaseChannel.readChannelFile())
779
+ return
780
+ }
781
+ const v = String(channel).trim().toLowerCase()
782
+ if (v !== releaseChannel.STAGING && v !== releaseChannel.STABLE) {
783
+ console.log('通道只能是 staging 或 stable'.red)
784
+ process.exitCode = 1
785
+ return
786
+ }
787
+ if (v === releaseChannel.STAGING && !test) {
788
+ console.log(('设备 ' + deviceId + ' 不是测试机(只有 1 和 9020–9099 能切 staging)').red)
789
+ process.exitCode = 1
790
+ return
791
+ }
792
+ const file = releaseChannel.CHANNEL_FILE
793
+ // 密码走 sudo -S 的标准输入,写文件放在 bash -c 里(不能把内容也从管道喂给 sudo:会被当成密码吃掉);v 只可能是 staging / stable
794
+ const {code} = await cli.executeInShell([
795
+ 'echo "Innerken322." | sudo -S -p "" bash -c "mkdir -p ' + path.dirname(file) + ' && printf \'%s\\n\' ' + v + ' > ' + file + ' && chmod 644 ' + file + '"',
796
+ ], program.debug)
797
+ if (code !== 0) {
798
+ process.exitCode = code
799
+ console.log('写通道文件失败'.red)
800
+ return
801
+ }
802
+ console.log(('后端发布通道已设为 ' + v + '(下一次 light-update 生效)' +
803
+ (v === releaseChannel.STABLE ? ';本机版本如果比 stable 新,按现有规则不会降级' : '')).green)
804
+ await logAction('release-channel → ' + v, 0)
805
+ }
806
+
807
+ /**
808
+ * staging 通道用的版本比较:两个版本里至少一个带「-」后缀(1.9.83-staging.15)才按 semver 比;
809
+ * 纯 x.y.z 之间一律走原来的 versionGt,结果和改动前逐个一致。
810
+ */
811
+ function versionGtChannel(a, b) {
812
+ return (String(a).indexOf('-') >= 0 || String(b).indexOf('-') >= 0)
813
+ ? releaseChannel.semverGt(a, b)
814
+ : versionGt(a, b)
815
+ }
816
+
675
817
  /** 比较点分数字版本,a > b 返回 true。'1.9.61' > '1.7.908' === true。 */
676
818
  function versionGt(a, b) {
677
819
  const pa = String(a).split('.').map(n => parseInt(n) || 0)
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 后端发布通道(staging 预发布)。只给测试机用:ik0001(设备号 1)和测试门店农场 9020–9099。
3
+ *
4
+ * - 通道写在本机文件 /etc/aaden/config/release-channel(`aaden-cli release-channel staging|stable` 写,root 写、644);
5
+ * - light-update 只有在「文件里写的是 staging」并且「本机设备号是测试机」时才走 staging,其他一律是原来的 stable 路径;
6
+ * - staging 的包由 php 仓库 staging 分支的 workflow 发到 S3 的 staging/ 前缀,版本号形如 1.9.83-staging.15。
7
+ *
8
+ * 不是测试机、没有文件、文件内容不是 staging:light-update 的代码路径和请求的 URL 跟原来完全一样(只多读一次本地文件)。
9
+ * 兼容 node 10(门店上最老的 node),不用可选链等新语法。
10
+ */
11
+ const fs = require('fs')
12
+
13
+ const CHANNEL_FILE = '/etc/aaden/config/release-channel'
14
+ const STAGING = 'staging'
15
+ const STABLE = 'stable'
16
+
17
+ /** 测试机:1 号机,或测试门店农场号段 9020–9099(和云端 TestStores 默认值一致,写死在这里,不信任何外部配置) */
18
+ function isTestDevice(deviceId) {
19
+ const id = parseInt(String(deviceId).trim(), 10)
20
+ if (isNaN(id)) return false
21
+ return id === 1 || (id >= 9020 && id <= 9099)
22
+ }
23
+
24
+ /** 本机文件里写的通道;读不到、不认识一律 stable */
25
+ function readChannelFile() {
26
+ try {
27
+ const v = fs.readFileSync(CHANNEL_FILE, 'utf8').trim().toLowerCase()
28
+ return v === STAGING ? STAGING : STABLE
29
+ } catch (e) {
30
+ return STABLE
31
+ }
32
+ }
33
+
34
+ /**
35
+ * semver 解析:只认 x.y.z 和 x.y.z-<预发布标识>。不合法返回 null。
36
+ * 预发布标识按 semver:逐段比,纯数字段按数值、数字段 < 字母段,段少的小;有预发布标识的 < 没有的。
37
+ */
38
+ function parseSemver(v) {
39
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim())
40
+ if (!m) return null
41
+ return {core: [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)], pre: m[4] ? m[4].split('.') : []}
42
+ }
43
+
44
+ /** a 比 b 大返回 1,小返回 -1,相等 0;任一不合法返回 null(调用方按「不更新」处理) */
45
+ function semverCompare(a, b) {
46
+ const pa = parseSemver(a), pb = parseSemver(b)
47
+ if (!pa || !pb) return null
48
+ for (let i = 0; i < 3; i++) {
49
+ if (pa.core[i] !== pb.core[i]) return pa.core[i] > pb.core[i] ? 1 : -1
50
+ }
51
+ if (pa.pre.length === 0 && pb.pre.length === 0) return 0
52
+ if (pa.pre.length === 0) return 1
53
+ if (pb.pre.length === 0) return -1
54
+ for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) {
55
+ const x = pa.pre[i], y = pb.pre[i]
56
+ if (x === undefined) return -1
57
+ if (y === undefined) return 1
58
+ const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y)
59
+ if (xn && yn) {
60
+ const d = parseInt(x, 10) - parseInt(y, 10)
61
+ if (d !== 0) return d > 0 ? 1 : -1
62
+ } else if (xn !== yn) {
63
+ return xn ? -1 : 1
64
+ } else if (x !== y) {
65
+ return x > y ? 1 : -1
66
+ }
67
+ }
68
+ return 0
69
+ }
70
+
71
+ /** a > b;不合法按 false(不更新) */
72
+ function semverGt(a, b) {
73
+ return semverCompare(a, b) === 1
74
+ }
75
+
76
+ module.exports = {CHANNEL_FILE, STAGING, STABLE, isTestDevice, readChannelFile, parseSemver, semverCompare, semverGt}
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "aaden-cli",
3
- "version": "1.6.121",
3
+ "version": "1.6.122",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
7
- "test": "node test/snap-version.test.js",
7
+ "test": "node test/snap-version.test.js && python3 test/app_version_test.py",
8
8
  "update": "git add . && release-it --ci"
9
9
  },
10
10
  "bin": {
@@ -0,0 +1,48 @@
1
+ # python3 test/app_version_test.py:appCenterUsage.app_version 在临时 docroot 上的行为
2
+ import importlib.util, json, os, sys, tempfile
3
+ sys.dont_write_bytecode = True # 不在仓库里留 __pycache__
4
+
5
+ spec = importlib.util.spec_from_file_location('usage', os.path.join(os.path.dirname(__file__), '..', 'AadenDeploy', 'appCenterUsage.py'))
6
+ usage = importlib.util.module_from_spec(spec)
7
+ sys.argv = [sys.argv[0], '/nonexistent']
8
+ spec.loader.exec_module(usage)
9
+
10
+ root = tempfile.mkdtemp()
11
+ usage.DOCROOT = root
12
+ def mk(rel, html, **files):
13
+ d = os.path.join(root, rel); os.makedirs(d)
14
+ open(os.path.join(d, 'index.html'), 'w').write(html)
15
+ for name, content in files.items():
16
+ open(os.path.join(d, name.replace('_', '.', 1) if name != 'appcenter_manifest' else '.appcenter-manifest'), 'w').write(content)
17
+
18
+ # AppCenter 应用:有 version.json 和 .appcenter-manifest
19
+ mk('AppCenter/AdminV3', '<script src=js/chunk-vendors.11111111.js></script><script src=js/app.3960d167.js></script>',
20
+ version_json=json.dumps({'version': '3.0.2', 'commit': 'abcdef123456'}), appcenter_manifest='x index.html\n')
21
+ v = usage.app_version('AppCenter/AdminV3')
22
+ assert v['version'] == '3.0.2' and v['commit'] == 'abcdef123456', v
23
+ assert len(v['treeSha']) == 64 and v['mainJs'] == 'js/app.3960d167.js' and len(v['fingerprint']) == 16, v
24
+
25
+ # 根目录老前端:没有 version.json,只有指纹;老版 Vite 入口
26
+ mk('Board', '<script type="module" crossorigin src="./assets/index.ce3abdc3.js"></script>')
27
+ v = usage.app_version('Board')
28
+ assert v['version'] == 'unknown' and 'treeSha' not in v and v['mainJs'] == 'assets/index.ce3abdc3.js', v
29
+
30
+ # 新版 Vite 入口 + package.json 兜底
31
+ mk('KDS3', '<script src="/assets/index-AbCd1234.js"></script>', package_json='{"version":"0.6.1"}')
32
+ v = usage.app_version('KDS3')
33
+ assert v['version'] == '0.6.1' and v['mainJs'] == 'assets/index-AbCd1234.js', v
34
+
35
+ # 坏掉的 version.json 不影响其它字段;没有 index.html 只报 unknown
36
+ mk('Broken', '<p>x</p>', version_json='{not json')
37
+ v = usage.app_version('Broken')
38
+ assert v['version'] == 'unknown' and 'fingerprint' in v and 'mainJs' not in v, v
39
+ assert usage.app_version('Missing') == {'version': 'unknown'}
40
+
41
+ print('app-version: ok')
42
+
43
+ # 兜底:内部抛任何异常都只返回 unknown
44
+ orig = usage._app_version
45
+ usage._app_version = lambda rel: 1 / 0
46
+ assert usage.app_version('AppCenter/AdminV3') == {'version': 'unknown'}
47
+ usage._app_version = orig
48
+ print('app-version fallback: ok')