openxiangda 1.0.31 → 1.0.33

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.
package/README.md CHANGED
@@ -36,7 +36,23 @@ openxiangda inspect app --profile dev --json
36
36
  openxiangda app snapshot APP_XXXX --profile dev --json
37
37
  ```
38
38
 
39
- User tokens are stored in `~/.openxiangda/profiles.json` with `0600` permissions. Project state is stored in `.openxiangda/state.json` and contains only profile-specific resource IDs.
39
+ User tokens are stored in `~/.openxiangda/profiles.json` with `0600` permissions. Shared workspace environment values, including `APP_OSS_*`, can live in `~/.openxiangda/.env` and are inherited by new workspaces. Project `.env` files still work and override the global defaults. Project state is stored in `.openxiangda/state.json` and contains only profile-specific resource IDs.
40
+
41
+ Feedback can be sent to a DingTalk custom robot from the CLI after user confirmation. Store the robot settings in `~/.openxiangda/.env`, not in project files:
42
+
43
+ ```env
44
+ OPENXIANGDA_FEEDBACK_DINGTALK_WEBHOOK=https://oapi.dingtalk.com/robot/send?access_token=...
45
+ OPENXIANGDA_FEEDBACK_DINGTALK_SECRET=SEC...
46
+ ```
47
+
48
+ Preview the sanitized payload first, then submit:
49
+
50
+ ```bash
51
+ openxiangda feedback preview --profile dev --summary "发布后资源 404" --description "..."
52
+ openxiangda feedback submit --profile dev --summary "发布后资源 404" --description "..." --yes
53
+ ```
54
+
55
+ Feedback messages include the logged-in user's name when available, profile/platform/app binding, workspace path, Git branch/commit, CLI/Node/OS versions, OSS bucket metadata, command, error text, logs, and optional context files. Token, cookie, secret, access key secret, phone, and email patterns are redacted before sending.
40
56
 
41
57
  Use the official npm registry for OpenXiangda updates:
42
58
 
package/lib/cli.js CHANGED
@@ -7,8 +7,10 @@ const { pathToFileURL } = require('url');
7
7
  const esbuild = require('esbuild');
8
8
  const {
9
9
  CONFIG_FILE,
10
+ GLOBAL_ENV_FILE,
10
11
  PROJECT_STATE_FILE,
11
12
  getProfile,
13
+ loadGlobalEnv,
12
14
  loadConfig,
13
15
  loadProjectState,
14
16
  normalizeBaseUrl,
@@ -20,6 +22,7 @@ const { getSkillStatusReport, installSkills } = require('./skills');
20
22
  const { assertCanInitializeWorkspace, initWorkspace } = require('./workspace-init');
21
23
  const {
22
24
  fail,
25
+ maskText,
23
26
  openBrowser,
24
27
  parseArgs,
25
28
  print,
@@ -56,6 +59,7 @@ async function main(argv) {
56
59
  if (command === 'resource') return resource(rest);
57
60
  if (command === 'inspect') return inspect(rest);
58
61
  if (command === 'skill') return skill(rest);
62
+ if (command === 'feedback') return feedback(rest);
59
63
  if (command === 'commands') return commands(rest);
60
64
 
61
65
  fail(`未知命令: ${command}`);
@@ -107,8 +111,9 @@ Usage:
107
111
  openxiangda settings get|save|indexes|indexes-save|data-management|data-management-save|public-access
108
112
  openxiangda resource validate|plan|publish|pull [--profile name] [--json]
109
113
  openxiangda inspect app|form|workflow|automation|permissions
110
- openxiangda skill install [--agent codex] [--dest <skills-dir>] [--force] [--dry-run] [--json]
111
- openxiangda skill status [--agent codex] [--dest <skills-dir>] [--json]
114
+ openxiangda feedback preview|submit --summary <text> [--type bug] [--severity medium] [--profile name] [--yes]
115
+ openxiangda skill install [--agent codex|claude|qoder|dual] [--dest <skills-dir>] [--force] [--dry-run] [--json]
116
+ openxiangda skill status [--agent codex|claude|qoder|dual] [--dest <skills-dir>] [--json]
112
117
 
113
118
  OpenXiangda 使用普通用户登录 token,不需要 AK/SK。
114
119
  表单页、流程表单页和代码页的主链路是 sy-lowcode-app-workspace + openxiangda workspace publish。
@@ -554,30 +559,462 @@ async function auth(args) {
554
559
  async function env(args) {
555
560
  const { flags } = parseArgs(args);
556
561
  const config = loadConfig();
562
+ const globalEnv = loadGlobalEnv();
557
563
  const profileName = flags.profile || config.currentProfile;
558
564
  const profile = profileName ? config.profiles[profileName] : null;
559
565
  const state = loadProjectState();
560
566
  const currentState = profileName ? state.profiles?.[profileName] : null;
567
+ const oss = summarizeOssEnv(globalEnv);
561
568
  const data = {
562
569
  configFile: CONFIG_FILE,
570
+ globalEnvFile: GLOBAL_ENV_FILE,
571
+ globalEnvExists: fs.existsSync(GLOBAL_ENV_FILE),
563
572
  projectStateFile: path.join(process.cwd(), PROJECT_STATE_FILE),
564
573
  currentProfile: profileName || null,
565
574
  baseUrl: profile?.baseUrl || null,
566
575
  loggedIn: Boolean(profile?.token?.accessToken),
567
576
  user: profile?.user || null,
568
577
  tenant: profile?.tenant || null,
578
+ oss,
569
579
  appType: currentState?.appType || null,
570
580
  };
571
581
 
572
582
  if (flags.json) return writeJson(data);
573
583
  print(`配置文件: ${data.configFile}`);
584
+ print(`全局 env: ${data.globalEnvFile}${data.globalEnvExists ? '' : ' (未创建)'}`);
574
585
  print(`项目状态: ${data.projectStateFile}`);
575
586
  print(`当前 profile: ${data.currentProfile || '-'}`);
576
587
  print(`平台地址: ${data.baseUrl || '-'}`);
577
588
  print(`登录状态: ${data.loggedIn ? '已登录' : '未登录'}`);
589
+ print(
590
+ `OSS: ${oss.configured ? '已配置' : '未完整配置'} bucket=${oss.bucket || '-'} region=${oss.region || '-'} pathPrefix=${oss.pathPrefix || '-'}`
591
+ );
578
592
  print(`项目绑定 appType: ${data.appType || '-'}`);
579
593
  }
580
594
 
595
+ async function feedback(args) {
596
+ const [subcommand, ...rest] = args;
597
+ const { flags, positional } = parseArgs(rest);
598
+
599
+ if (subcommand !== 'preview' && subcommand !== 'submit') {
600
+ fail(
601
+ '用法: openxiangda feedback preview|submit --summary <text> [--type bug] [--severity medium] [--profile name] [--yes] [--json]'
602
+ );
603
+ }
604
+
605
+ const config = loadConfig();
606
+ const globalEnv = loadGlobalEnv();
607
+ const envValues = {
608
+ ...globalEnv,
609
+ ...process.env,
610
+ };
611
+ const feedbackData = buildFeedbackData(config, flags, positional, envValues);
612
+ const markdown = buildFeedbackMarkdown(feedbackData);
613
+
614
+ if (flags.json) {
615
+ if (subcommand === 'preview' || flags['dry-run']) {
616
+ return writeJson({
617
+ dryRun: true,
618
+ backend: 'dingtalk',
619
+ data: feedbackData,
620
+ markdown,
621
+ });
622
+ }
623
+ }
624
+
625
+ if (subcommand === 'preview' || flags['dry-run']) {
626
+ print(markdown);
627
+ return;
628
+ }
629
+
630
+ if (!flags.yes && !flags.confirm) {
631
+ fail('提交反馈需要用户确认。确认后请追加 --yes,或先执行 openxiangda feedback preview 查看内容。');
632
+ }
633
+
634
+ const webhook = readFeedbackEnv(envValues, [
635
+ 'OPENXIANGDA_FEEDBACK_DINGTALK_WEBHOOK',
636
+ 'OPENXIANGDA_DINGTALK_WEBHOOK',
637
+ 'DINGTALK_ROBOT_WEBHOOK',
638
+ ]);
639
+ const secret = readFeedbackEnv(envValues, [
640
+ 'OPENXIANGDA_FEEDBACK_DINGTALK_SECRET',
641
+ 'OPENXIANGDA_DINGTALK_SECRET',
642
+ 'DINGTALK_ROBOT_SECRET',
643
+ ]);
644
+
645
+ if (!webhook) {
646
+ fail(
647
+ `缺少钉钉机器人 webhook。请在 ${GLOBAL_ENV_FILE} 中配置 OPENXIANGDA_FEEDBACK_DINGTALK_WEBHOOK。`
648
+ );
649
+ }
650
+
651
+ const result = await submitDingTalkFeedback({
652
+ webhook,
653
+ secret,
654
+ title: `OpenXiangda反馈: ${feedbackData.summary}`,
655
+ markdown,
656
+ });
657
+
658
+ const response = {
659
+ backend: 'dingtalk',
660
+ fingerprint: feedbackData.fingerprint,
661
+ sentAt: feedbackData.createdAt,
662
+ result,
663
+ };
664
+ if (flags.json) return writeJson(response);
665
+ print(`反馈已发送到钉钉机器人: ${feedbackData.fingerprint}`);
666
+ }
667
+
668
+ function readFeedbackEnv(envValues, names) {
669
+ for (const name of names) {
670
+ const value = envValues[name];
671
+ if (typeof value === 'string' && value.trim()) return value.trim();
672
+ }
673
+ return '';
674
+ }
675
+
676
+ function buildFeedbackData(config, flags, positional, envValues) {
677
+ const summary =
678
+ readStringFlag(flags, 'summary') ||
679
+ readStringFlag(flags, 'title') ||
680
+ positional.join(' ').trim();
681
+ if (!summary) {
682
+ fail('缺少反馈摘要。用法: openxiangda feedback submit --summary <text> --yes');
683
+ }
684
+
685
+ const profileName = flags.profile || config.currentProfile || null;
686
+ const profile = profileName ? config.profiles[profileName] || null : null;
687
+ const state = loadProjectState();
688
+ const bound = profileName ? state.profiles?.[profileName] || null : null;
689
+ const cwd = process.cwd();
690
+ const packageInfo = readWorkspacePackageInfo(cwd);
691
+ const gitInfo = readGitInfo(cwd);
692
+ const relatedFiles = splitList(readStringFlag(flags, 'files')).concat(
693
+ splitList(readStringFlag(flags, 'file'))
694
+ );
695
+ const description = readFeedbackLongText(flags, ['description', 'desc'], ['description-file']);
696
+ const error = readFeedbackLongText(flags, ['error', 'message'], ['error-file']);
697
+ const logs = readFeedbackLongText(flags, ['logs', 'log'], ['log-file']);
698
+ const context = readFeedbackLongText(flags, ['context'], ['context-file']);
699
+ const command = readStringFlag(flags, 'command') || readStringFlag(flags, 'cmd');
700
+ const createdAt = new Date().toISOString();
701
+ const baseUrlInfo = describeBaseUrl(profile?.baseUrl);
702
+
703
+ const data = {
704
+ type: readStringFlag(flags, 'type') || 'bug',
705
+ severity: readStringFlag(flags, 'severity') || 'medium',
706
+ summary: sanitizeFeedbackText(summary, 300),
707
+ description: sanitizeFeedbackText(description, 3000),
708
+ error: sanitizeFeedbackText(error, 3000),
709
+ logs: sanitizeFeedbackText(logs, 6000),
710
+ context: sanitizeFeedbackText(context, 6000),
711
+ command: sanitizeFeedbackText(command, 1000),
712
+ createdAt,
713
+ reporter: describeFeedbackReporter(profile),
714
+ platform: {
715
+ profile: profileName,
716
+ baseUrl: baseUrlInfo.safeUrl,
717
+ host: baseUrlInfo.host,
718
+ tenant: sanitizeObject(profile?.tenant || null),
719
+ appType: bound?.appType || null,
720
+ },
721
+ workspace: {
722
+ cwd,
723
+ packageName: packageInfo.name,
724
+ packageVersion: packageInfo.version,
725
+ stateFile: path.join(cwd, PROJECT_STATE_FILE),
726
+ stateExists: fs.existsSync(path.join(cwd, PROJECT_STATE_FILE)),
727
+ relatedFiles: unique(relatedFiles).map(file => sanitizeFeedbackText(file, 500)),
728
+ },
729
+ git: gitInfo,
730
+ environment: {
731
+ openxiangdaVersion: CURRENT_VERSION,
732
+ nodeVersion: process.version,
733
+ platform: process.platform,
734
+ arch: process.arch,
735
+ oss: {
736
+ bucket: sanitizeFeedbackText(envValues.APP_OSS_BUCKET || '', 300) || null,
737
+ region: sanitizeFeedbackText(envValues.APP_OSS_REGION || '', 300) || null,
738
+ pathPrefix: sanitizeFeedbackText(envValues.APP_OSS_PATH_PREFIX || '', 300) || null,
739
+ hasAccessKeyId: Boolean(envValues.APP_OSS_ACCESS_KEY_ID),
740
+ hasAccessKeySecret: Boolean(envValues.APP_OSS_ACCESS_KEY_SECRET),
741
+ },
742
+ },
743
+ };
744
+
745
+ data.fingerprint = createFeedbackFingerprint(data);
746
+ return data;
747
+ }
748
+
749
+ function readFeedbackLongText(flags, inlineKeys, fileKeys) {
750
+ for (const key of inlineKeys) {
751
+ const value = readStringFlag(flags, key);
752
+ if (value) return value;
753
+ }
754
+ for (const key of fileKeys) {
755
+ const file = readStringFlag(flags, key);
756
+ if (file) return readTextFileForFeedback(file);
757
+ }
758
+ return '';
759
+ }
760
+
761
+ function readTextFileForFeedback(file) {
762
+ const resolved = path.resolve(file);
763
+ try {
764
+ const stat = fs.statSync(resolved);
765
+ if (!stat.isFile()) fail(`反馈上下文不是文件: ${resolved}`);
766
+ const maxBytes = 256 * 1024;
767
+ const fd = fs.openSync(resolved, 'r');
768
+ try {
769
+ const buffer = Buffer.alloc(Math.min(stat.size, maxBytes));
770
+ fs.readSync(fd, buffer, 0, buffer.length, 0);
771
+ const suffix = stat.size > maxBytes ? '\n\n[内容过长,仅截取前 256KB]' : '';
772
+ return `${buffer.toString('utf8')}${suffix}`;
773
+ } finally {
774
+ fs.closeSync(fd);
775
+ }
776
+ } catch (error) {
777
+ if (error.message && error.message.startsWith('反馈上下文不是文件')) throw error;
778
+ fail(`无法读取反馈上下文文件: ${resolved}`);
779
+ }
780
+ }
781
+
782
+ function describeFeedbackReporter(profile) {
783
+ const user = profile?.user || {};
784
+ return sanitizeObject({
785
+ name:
786
+ user.name ||
787
+ user.realName ||
788
+ user.nick ||
789
+ user.nickname ||
790
+ user.username ||
791
+ user.userName ||
792
+ user.id ||
793
+ null,
794
+ id: user.id || user.userId || user.unionId || null,
795
+ username: user.username || user.userName || null,
796
+ });
797
+ }
798
+
799
+ function describeBaseUrl(baseUrl) {
800
+ if (!baseUrl) return { safeUrl: null, host: null };
801
+ try {
802
+ const parsed = new URL(baseUrl);
803
+ return {
804
+ safeUrl: `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, ''),
805
+ host: parsed.host,
806
+ };
807
+ } catch {
808
+ return { safeUrl: sanitizeFeedbackText(baseUrl, 500), host: null };
809
+ }
810
+ }
811
+
812
+ function readWorkspacePackageInfo(cwd) {
813
+ try {
814
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
815
+ return {
816
+ name: pkg.name || null,
817
+ version: pkg.version || null,
818
+ };
819
+ } catch {
820
+ return { name: null, version: null };
821
+ }
822
+ }
823
+
824
+ function readGitInfo(cwd) {
825
+ return {
826
+ branch: runGitValue(cwd, ['branch', '--show-current']),
827
+ commit: runGitValue(cwd, ['rev-parse', '--short', 'HEAD']),
828
+ remote: sanitizeGitRemote(runGitValue(cwd, ['remote', 'get-url', 'origin'])),
829
+ dirty: runGitValue(cwd, ['status', '--porcelain']) ? true : false,
830
+ };
831
+ }
832
+
833
+ function runGitValue(cwd, args) {
834
+ const result = spawnSync('git', args, {
835
+ cwd,
836
+ encoding: 'utf8',
837
+ stdio: ['ignore', 'pipe', 'ignore'],
838
+ });
839
+ if (result.status !== 0) return null;
840
+ return sanitizeFeedbackText(String(result.stdout || '').trim(), 1000) || null;
841
+ }
842
+
843
+ function sanitizeGitRemote(value) {
844
+ if (!value) return null;
845
+ return sanitizeFeedbackText(value.replace(/\/\/[^/@\s]+@/g, '//***@'), 1000);
846
+ }
847
+
848
+ function sanitizeObject(value) {
849
+ if (!value || typeof value !== 'object') return value || null;
850
+ const sanitized = sanitizeFeedbackText(JSON.stringify(value), 5000);
851
+ try {
852
+ return JSON.parse(sanitized);
853
+ } catch {
854
+ return sanitized;
855
+ }
856
+ }
857
+
858
+ function sanitizeFeedbackText(value, maxLength = 4000) {
859
+ const masked = maskText(value)
860
+ .replace(/([?&]access_token=)[^&\s]+/gi, '$1***redacted***')
861
+ .replace(/([?&]sign=)[^&\s]+/gi, '$1***redacted***')
862
+ .replace(/\bSEC[0-9a-f]{20,}\b/gi, '***dingtalk-secret***')
863
+ .replace(
864
+ /\b(access_token|accessToken|refreshToken|authorization|cookie|secret|token|sign|webhook|accessKeySecret|APP_OSS_ACCESS_KEY_SECRET|OPENXIANGDA_ACCESS_TOKEN)\b\s*[:=]\s*["']?[^"',\n\r\s]+["']?/gi,
865
+ '$1=***redacted***'
866
+ );
867
+ if (masked.length <= maxLength) return masked;
868
+ return `${masked.slice(0, maxLength)}\n...[已截断 ${masked.length - maxLength} 字符]`;
869
+ }
870
+
871
+ function createFeedbackFingerprint(data) {
872
+ const raw = [
873
+ data.type,
874
+ data.severity,
875
+ data.summary,
876
+ data.platform.host,
877
+ data.platform.appType,
878
+ data.command,
879
+ data.error,
880
+ ]
881
+ .filter(Boolean)
882
+ .join('\n');
883
+ return `OXD-${crypto.createHash('sha256').update(raw).digest('hex').slice(0, 12)}`;
884
+ }
885
+
886
+ function buildFeedbackMarkdown(data) {
887
+ const lines = [
888
+ `## OpenXiangda 反馈`,
889
+ '',
890
+ `**摘要**: ${escapeMarkdownLine(data.summary)}`,
891
+ `**类型**: ${escapeMarkdownLine(data.type)}`,
892
+ `**严重级别**: ${escapeMarkdownLine(data.severity)}`,
893
+ `**发送人**: ${escapeMarkdownLine(data.reporter?.name || '-')}`,
894
+ `**发送时间**: ${escapeMarkdownLine(data.createdAt)}`,
895
+ `**Fingerprint**: ${escapeMarkdownLine(data.fingerprint)}`,
896
+ '',
897
+ `### 登录与平台`,
898
+ `- profile: ${escapeMarkdownLine(data.platform.profile || '-')}`,
899
+ `- user: ${escapeMarkdownLine(formatObjectInline(data.reporter))}`,
900
+ `- tenant: ${escapeMarkdownLine(formatObjectInline(data.platform.tenant))}`,
901
+ `- baseUrl: ${escapeMarkdownLine(data.platform.baseUrl || '-')}`,
902
+ `- appType: ${escapeMarkdownLine(data.platform.appType || '-')}`,
903
+ '',
904
+ `### 工作区`,
905
+ `- cwd: ${escapeMarkdownLine(data.workspace.cwd)}`,
906
+ `- package: ${escapeMarkdownLine([data.workspace.packageName, data.workspace.packageVersion].filter(Boolean).join('@') || '-')}`,
907
+ `- stateFile: ${escapeMarkdownLine(data.workspace.stateFile)}`,
908
+ `- stateExists: ${data.workspace.stateExists ? 'true' : 'false'}`,
909
+ `- relatedFiles: ${escapeMarkdownLine(data.workspace.relatedFiles.join(', ') || '-')}`,
910
+ '',
911
+ `### Git`,
912
+ `- branch: ${escapeMarkdownLine(data.git.branch || '-')}`,
913
+ `- commit: ${escapeMarkdownLine(data.git.commit || '-')}`,
914
+ `- dirty: ${data.git.dirty ? 'true' : 'false'}`,
915
+ `- remote: ${escapeMarkdownLine(data.git.remote || '-')}`,
916
+ '',
917
+ `### 环境`,
918
+ `- openxiangda: ${escapeMarkdownLine(data.environment.openxiangdaVersion)}`,
919
+ `- node: ${escapeMarkdownLine(data.environment.nodeVersion)}`,
920
+ `- os: ${escapeMarkdownLine(`${data.environment.platform}/${data.environment.arch}`)}`,
921
+ `- oss: ${escapeMarkdownLine(formatObjectInline(data.environment.oss))}`,
922
+ ];
923
+
924
+ appendMarkdownBlock(lines, '命令', data.command, 'bash');
925
+ appendMarkdownBlock(lines, '问题描述', data.description);
926
+ appendMarkdownBlock(lines, '错误信息', data.error);
927
+ appendMarkdownBlock(lines, '日志', data.logs);
928
+ appendMarkdownBlock(lines, '补充上下文', data.context, 'json');
929
+
930
+ return sanitizeFeedbackText(lines.join('\n'), 18000);
931
+ }
932
+
933
+ function appendMarkdownBlock(lines, title, value, language = '') {
934
+ if (!value) return;
935
+ lines.push('', `### ${title}`, '```' + language, escapeMarkdownBlock(value), '```');
936
+ }
937
+
938
+ function escapeMarkdownLine(value) {
939
+ return String(value || '').replace(/\n/g, ' ');
940
+ }
941
+
942
+ function escapeMarkdownBlock(value) {
943
+ return String(value || '').replace(/```/g, '``\u200b`');
944
+ }
945
+
946
+ function formatObjectInline(value) {
947
+ if (!value) return '-';
948
+ return JSON.stringify(value);
949
+ }
950
+
951
+ async function submitDingTalkFeedback({ webhook, secret, title, markdown }) {
952
+ if (typeof fetch !== 'function') {
953
+ throw new Error('当前 Node.js 版本不支持 fetch,请使用 Node.js 18 或更高版本');
954
+ }
955
+
956
+ const url = buildDingTalkRobotUrl(webhook, secret);
957
+ const response = await fetch(url, {
958
+ method: 'POST',
959
+ headers: {
960
+ 'content-type': 'application/json',
961
+ },
962
+ body: JSON.stringify({
963
+ msgtype: 'markdown',
964
+ markdown: {
965
+ title: sanitizeFeedbackText(title, 100),
966
+ text: markdown,
967
+ },
968
+ }),
969
+ });
970
+ const text = await response.text();
971
+ let payload = null;
972
+ if (text) {
973
+ try {
974
+ payload = JSON.parse(text);
975
+ } catch {
976
+ payload = { message: text };
977
+ }
978
+ }
979
+ if (!response.ok || Number(payload?.errcode || 0) !== 0) {
980
+ const message = payload?.errmsg || payload?.message || response.statusText || 'DingTalk robot send failed';
981
+ throw new Error(sanitizeFeedbackText(`钉钉反馈发送失败: ${message}`, 1000));
982
+ }
983
+ return payload || { errcode: 0 };
984
+ }
985
+
986
+ function buildDingTalkRobotUrl(webhook, secret) {
987
+ const url = new URL(webhook);
988
+ if (secret) {
989
+ const timestamp = Date.now();
990
+ const sign = crypto
991
+ .createHmac('sha256', secret)
992
+ .update(`${timestamp}\n${secret}`)
993
+ .digest('base64');
994
+ url.searchParams.set('timestamp', String(timestamp));
995
+ url.searchParams.set('sign', sign);
996
+ }
997
+ return url.toString();
998
+ }
999
+
1000
+ function summarizeOssEnv(globalEnv) {
1001
+ const env = {
1002
+ ...globalEnv,
1003
+ ...process.env,
1004
+ };
1005
+ const hasAccessKeyId = Boolean(env.APP_OSS_ACCESS_KEY_ID);
1006
+ const hasAccessKeySecret = Boolean(env.APP_OSS_ACCESS_KEY_SECRET);
1007
+ const bucket = env.APP_OSS_BUCKET || null;
1008
+ return {
1009
+ bucket,
1010
+ region: env.APP_OSS_REGION || null,
1011
+ pathPrefix: env.APP_OSS_PATH_PREFIX || null,
1012
+ hasAccessKeyId,
1013
+ hasAccessKeySecret,
1014
+ configured: Boolean(bucket && hasAccessKeyId && hasAccessKeySecret),
1015
+ };
1016
+ }
1017
+
581
1018
  async function workspace(args) {
582
1019
  const [subcommand, ...rest] = args;
583
1020
  const { flags, positional } = parseArgs(rest);
@@ -2170,6 +2607,7 @@ async function commands(args) {
2170
2607
  'settings get|save|indexes|indexes-save|data-management|data-management-save|public-access|public-access-save|public-access-delete',
2171
2608
  'resource validate|plan|publish|pull',
2172
2609
  'inspect app|form|workflow|automation|permissions',
2610
+ 'feedback preview|submit',
2173
2611
  'skill install|status',
2174
2612
  ],
2175
2613
  };
@@ -2222,7 +2660,7 @@ async function skill(args) {
2222
2660
  return;
2223
2661
  }
2224
2662
 
2225
- fail('用法: openxiangda skill install|status [--agent codex] [--dest <skills-dir>]');
2663
+ fail('用法: openxiangda skill install|status [--agent codex|claude|qoder|dual] [--dest <skills-dir>]');
2226
2664
  }
2227
2665
 
2228
2666
  function printSkillInstallReport(result) {
@@ -5467,22 +5905,37 @@ function runWorkspacePublish(profileName, profile, appType, publishArgs = []) {
5467
5905
  const args = publishArgs.length
5468
5906
  ? ['run', scriptName, '--', ...publishArgs]
5469
5907
  : ['run', scriptName];
5908
+ const globalEnv = loadGlobalEnv();
5470
5909
  const result = spawnSync(command, args, {
5471
5910
  cwd: process.cwd(),
5472
5911
  stdio: 'inherit',
5473
- env: {
5474
- ...process.env,
5475
- OPENXIANGDA_PROFILE: profileName,
5476
- OPENXIANGDA_BASE_URL: profile.baseUrl,
5477
- OPENXIANGDA_ACCESS_TOKEN: profile.token.accessToken,
5478
- OPENXIANGDA_APP_TYPE: appType,
5479
- },
5912
+ env: buildWorkspacePublishEnv(profileName, profile, appType, globalEnv),
5480
5913
  });
5481
5914
  if (result.status !== 0) {
5482
5915
  process.exit(result.status || 1);
5483
5916
  }
5484
5917
  }
5485
5918
 
5919
+ function buildWorkspacePublishEnv(profileName, profile, appType, globalEnv) {
5920
+ const env = { ...process.env };
5921
+ const injectedGlobalKeys = [];
5922
+
5923
+ for (const [key, value] of Object.entries(globalEnv)) {
5924
+ if (key.startsWith('APP_') && env[key] === undefined) {
5925
+ env[key] = value;
5926
+ injectedGlobalKeys.push(key);
5927
+ }
5928
+ }
5929
+
5930
+ env.OPENXIANGDA_PROFILE = profileName;
5931
+ env.OPENXIANGDA_BASE_URL = profile.baseUrl;
5932
+ env.OPENXIANGDA_ACCESS_TOKEN = profile.token.accessToken;
5933
+ env.OPENXIANGDA_APP_TYPE = appType;
5934
+ env.OPENXIANGDA_GLOBAL_ENV_KEYS = injectedGlobalKeys.join(',');
5935
+
5936
+ return env;
5937
+ }
5938
+
5486
5939
  module.exports = {
5487
5940
  main,
5488
5941
  };
package/lib/config.js CHANGED
@@ -1,11 +1,14 @@
1
1
  const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
+ const { parse: parseDotenv } = require('dotenv');
4
5
 
5
6
  const CONFIG_DIR = path.join(os.homedir(), '.openxiangda');
6
7
  const CONFIG_FILE = path.join(CONFIG_DIR, 'profiles.json');
8
+ const GLOBAL_ENV_FILE = path.join(CONFIG_DIR, '.env');
7
9
  const PROJECT_DIR = '.openxiangda';
8
10
  const PROJECT_STATE_FILE = path.join(PROJECT_DIR, 'state.json');
11
+ const LEGACY_PROJECT_CONFIG_FILE = path.join(PROJECT_DIR, 'profiles.json');
9
12
 
10
13
  function emptyConfig() {
11
14
  return {
@@ -32,23 +35,72 @@ function readJson(file, fallback) {
32
35
  }
33
36
  }
34
37
 
35
- function loadConfig() {
36
- ensureUserConfigDir();
37
- const config = readJson(CONFIG_FILE, emptyConfig());
38
+ function normalizeConfig(config) {
38
39
  return {
39
40
  ...emptyConfig(),
40
- ...config,
41
- profiles: config.profiles || {},
41
+ ...(config || {}),
42
+ profiles: config?.profiles || {},
43
+ };
44
+ }
45
+
46
+ function mergeConfigs(...configs) {
47
+ const merged = emptyConfig();
48
+ for (const config of configs) {
49
+ if (!config) continue;
50
+ const normalized = normalizeConfig(config);
51
+ merged.version = normalized.version || merged.version;
52
+ if (normalized.currentProfile) {
53
+ merged.currentProfile = normalized.currentProfile;
54
+ }
55
+ for (const [name, profile] of Object.entries(normalized.profiles)) {
56
+ merged.profiles[name] = mergeProfile(merged.profiles[name], profile);
57
+ }
58
+ }
59
+ return normalizeConfig(merged);
60
+ }
61
+
62
+ function mergeProfile(previous = {}, next = {}) {
63
+ const merged = {
64
+ ...previous,
65
+ ...next,
42
66
  };
67
+ const baseUrlChanged =
68
+ previous.baseUrl && next.baseUrl && previous.baseUrl !== next.baseUrl;
69
+ for (const key of ['token', 'user', 'tenant']) {
70
+ if (
71
+ !baseUrlChanged &&
72
+ !Object.prototype.hasOwnProperty.call(next, key) &&
73
+ previous[key] != null
74
+ ) {
75
+ merged[key] = previous[key];
76
+ }
77
+ }
78
+ return merged;
79
+ }
80
+
81
+ function isConfigEqual(left, right) {
82
+ return JSON.stringify(normalizeConfig(left)) === JSON.stringify(normalizeConfig(right));
83
+ }
84
+
85
+ function loadConfig(cwd = process.cwd()) {
86
+ ensureUserConfigDir();
87
+ const globalConfig = readJson(CONFIG_FILE, null);
88
+ const legacyConfig = readJson(path.join(cwd, LEGACY_PROJECT_CONFIG_FILE), null);
89
+
90
+ if (legacyConfig?.profiles && Object.keys(legacyConfig.profiles).length > 0) {
91
+ const migrated = mergeConfigs(legacyConfig, globalConfig);
92
+ if (!globalConfig || !isConfigEqual(globalConfig, migrated)) {
93
+ saveConfig(migrated);
94
+ }
95
+ return migrated;
96
+ }
97
+
98
+ return normalizeConfig(globalConfig);
43
99
  }
44
100
 
45
101
  function saveConfig(config) {
46
102
  ensureUserConfigDir();
47
- const normalized = {
48
- ...emptyConfig(),
49
- ...config,
50
- profiles: config.profiles || {},
51
- };
103
+ const normalized = normalizeConfig(config);
52
104
  const tempFile = `${CONFIG_FILE}.${process.pid}.tmp`;
53
105
  fs.writeFileSync(tempFile, `${JSON.stringify(normalized, null, 2)}\n`, {
54
106
  mode: 0o600,
@@ -109,10 +161,22 @@ function saveProjectState(state, cwd = process.cwd()) {
109
161
  );
110
162
  }
111
163
 
164
+ function loadGlobalEnv() {
165
+ ensureUserConfigDir();
166
+ try {
167
+ return parseDotenv(fs.readFileSync(GLOBAL_ENV_FILE));
168
+ } catch {
169
+ return {};
170
+ }
171
+ }
172
+
112
173
  module.exports = {
174
+ CONFIG_DIR,
113
175
  CONFIG_FILE,
176
+ GLOBAL_ENV_FILE,
114
177
  PROJECT_STATE_FILE,
115
178
  getProfile,
179
+ loadGlobalEnv,
116
180
  loadConfig,
117
181
  loadProjectState,
118
182
  normalizeBaseUrl,
package/lib/skills.js CHANGED
@@ -302,7 +302,12 @@ function installOneSkill(spec, skillsDir) {
302
302
  }
303
303
 
304
304
  function copyRootSkill(stagingDir) {
305
- fs.cpSync(SOURCE_SKILLS_DIR, stagingDir, {
305
+ fs.mkdirSync(stagingDir, { recursive: true });
306
+ const skillMarkdown = fs
307
+ .readFileSync(path.join(SOURCE_SKILLS_DIR, 'SKILL.md'), 'utf8')
308
+ .replace(/`skills\/(openxiangda-[^`]+)\/SKILL\.md`/g, '`../$1/SKILL.md`');
309
+ fs.writeFileSync(path.join(stagingDir, 'SKILL.md'), skillMarkdown);
310
+ fs.cpSync(SOURCE_REFERENCES_DIR, path.join(stagingDir, 'references'), {
306
311
  recursive: true,
307
312
  dereference: false,
308
313
  });
@@ -368,4 +373,4 @@ module.exports = {
368
373
  getSkillStatusReport,
369
374
  installSkills,
370
375
  resolveSkillsDir,
371
- };
376
+ };
@@ -82,6 +82,8 @@ When the user provides a root domain such as `https://yida.wisejob.cn/`, use it
82
82
  - For live platform publishing, do not call `lowcode-workspace publish-all`, `pnpm publish:all`, or legacy project scripts such as `scripts/openxiangda-publish.mjs` directly. They are workspace internals and may miss the normal-user token/profile injection. Use `openxiangda workspace publish ...` so `OPENXIANGDA_PROFILE`, `OPENXIANGDA_BASE_URL`, `OPENXIANGDA_ACCESS_TOKEN`, and `OPENXIANGDA_APP_TYPE` are injected consistently.
83
83
  - For routine AI edits, avoid reflexive full publish. First run `openxiangda workspace publish --profile <name> --changed --dry-run`, then use `--changed`, `--page <pageCode>`, `--form <formCode>`, or `--only pages/a,forms/b`. Use full publish only when broad shared/config changes intentionally affect many modules.
84
84
  - Never store token data in the project directory. User tokens live in `~/.openxiangda/profiles.json`; project state lives in `.openxiangda/state.json` and stores only IDs and mappings.
85
+ - Shared workspace env values such as `APP_OSS_*` should live in `~/.openxiangda/.env` by default. Project `.env` files are only per-workspace overrides.
86
+ - For suspected platform defects, bugs, or product optimization requests, ask the user to confirm first, then use `openxiangda feedback preview` and `openxiangda feedback submit --yes` to send a detailed DingTalk robot report. Include the command, error, relevant files, and logs/context files when available. The CLI redacts tokens, cookies, secrets, phone numbers, and emails before sending.
85
87
  - Use logical resource codes in local files. Platform-specific IDs such as `formUuid`, `pageId`, `workflowId`, and `automationId` must be isolated by profile.
86
88
  - Put engineering-managed resources in `src/resources/` and use `openxiangda resource validate|plan|publish|pull`. `workspace publish` publishes workspace forms/pages first, then runs non-destructive resource upsert. Only pass `--prune` when the user explicitly wants local manifests to delete platform-side extras.
87
89
  - For external APIs, create a connector manifest in `src/resources/connectors/` and call it from pages through `sdk.connector`; never put third-party API keys in page source.
@@ -2,7 +2,7 @@
2
2
 
3
3
  OpenXiangda project state lives in `.openxiangda/state.json`.
4
4
 
5
- Tokens never belong in the project. User tokens live in `~/.openxiangda/profiles.json`.
5
+ Tokens never belong in the project. User tokens live in `~/.openxiangda/profiles.json`. Shared workspace env values such as `APP_OSS_*` live in `~/.openxiangda/.env` by default, while project `.env` is only a local override.
6
6
 
7
7
  ## Shape
8
8
 
@@ -17,6 +17,24 @@ The CLI creates a one-time login session, opens the platform authorization page,
17
17
 
18
18
  Do not request or generate AK/SK credentials.
19
19
 
20
+ Shared workspace environment values such as `APP_OSS_REGION`, `APP_OSS_BUCKET`, `APP_OSS_ACCESS_KEY_ID`, `APP_OSS_ACCESS_KEY_SECRET`, and `APP_OSS_PATH_PREFIX` should live in `~/.openxiangda/.env` by default. A workspace-local `.env` may override those values for one project.
21
+
22
+ Feedback robot settings also live in `~/.openxiangda/.env`:
23
+
24
+ ```bash
25
+ OPENXIANGDA_FEEDBACK_DINGTALK_WEBHOOK=https://oapi.dingtalk.com/robot/send?access_token=...
26
+ OPENXIANGDA_FEEDBACK_DINGTALK_SECRET=SEC...
27
+ ```
28
+
29
+ When a user confirms that a suspected platform defect, bug, or optimization request should be reported, run:
30
+
31
+ ```bash
32
+ openxiangda feedback preview --profile <name> --summary "问题摘要" --description "..." --command "..." --log-file <file>
33
+ openxiangda feedback submit --profile <name> --summary "问题摘要" --description "..." --command "..." --log-file <file> --yes
34
+ ```
35
+
36
+ Feedback includes the logged-in profile user name when available and detailed workspace/platform context. The CLI redacts tokens, cookies, secrets, phone numbers, and emails before sending.
37
+
20
38
  ## Private Routing
21
39
 
22
40
  OpenXiangda private deployments use fixed public paths:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda",
3
- "version": "1.0.31",
3
+ "version": "1.0.33",
4
4
  "description": "OpenXiangda CLI, workspace build tools, runtime SDK, and form components.",
5
5
  "private": false,
6
6
  "bin": {
@@ -52,7 +52,7 @@ if (
52
52
  !dryRun &&
53
53
  (!config.oss.accessKeyId || config.oss.accessKeyId.startsWith("your_"))
54
54
  ) {
55
- console.error("❌ 请先配置 OSS 密钥(.env 文件中的 APP_OSS_* 字段)");
55
+ console.error("❌ 请先配置 OSS 密钥(~/.openxiangda/.env 或项目 .env 中的 APP_OSS_* 字段)");
56
56
  process.exit(1);
57
57
  }
58
58
 
@@ -1,8 +1,75 @@
1
- import { config as dotenvConfig } from "dotenv";
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import { parse as parseDotenv } from "dotenv";
2
4
  import { resolve } from "path";
3
5
  import { pathToFileURL } from "url";
4
6
 
5
7
  export const rootDir = resolve(process.env.LOWCODE_WORKSPACE_ROOT || process.cwd());
8
+ const OPENXIANGDA_HOME_DIR = ".openxiangda";
9
+ const OPENXIANGDA_GLOBAL_ENV_FILE = ".env";
10
+
11
+ export function getGlobalOpenXiangdaEnvFile(env = process.env) {
12
+ const home = env.HOME || os.homedir();
13
+ return resolve(home, OPENXIANGDA_HOME_DIR, OPENXIANGDA_GLOBAL_ENV_FILE);
14
+ }
15
+
16
+ function readDotenvFile(filePath) {
17
+ try {
18
+ return parseDotenv(fs.readFileSync(filePath, "utf-8"));
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ export function loadWorkspaceEnv(options = {}) {
25
+ const workspaceRoot = options.workspaceRoot || rootDir;
26
+ const env = options.env || process.env;
27
+ const globalEnvFile = getGlobalOpenXiangdaEnvFile(env);
28
+ const globalEnv = readDotenvFile(globalEnvFile);
29
+ const mode = options.mode || env.APP_MODE || globalEnv.APP_MODE || "development";
30
+ const files = [
31
+ { filePath: globalEnvFile, values: globalEnv },
32
+ {
33
+ filePath: resolve(workspaceRoot, ".env"),
34
+ values: readDotenvFile(resolve(workspaceRoot, ".env")),
35
+ },
36
+ ];
37
+
38
+ if (mode !== "development") {
39
+ const modeFile = resolve(workspaceRoot, `.env.${mode}`);
40
+ files.push({ filePath: modeFile, values: readDotenvFile(modeFile) });
41
+ }
42
+
43
+ const injectedGlobalKeys = new Set(
44
+ String(env.OPENXIANGDA_GLOBAL_ENV_KEYS || "")
45
+ .split(",")
46
+ .map((key) => key.trim())
47
+ .filter(Boolean),
48
+ );
49
+ const originalKeys = new Set(
50
+ Object.keys(env).filter(
51
+ (key) => env[key] !== undefined && !injectedGlobalKeys.has(key),
52
+ ),
53
+ );
54
+ const merged = {};
55
+ const loadedFiles = [];
56
+ for (const file of files) {
57
+ if (fs.existsSync(file.filePath)) loadedFiles.push(file.filePath);
58
+ Object.assign(merged, file.values);
59
+ }
60
+
61
+ for (const [key, value] of Object.entries(merged)) {
62
+ if (!originalKeys.has(key)) {
63
+ env[key] = value;
64
+ }
65
+ }
66
+
67
+ return {
68
+ globalEnvFile,
69
+ loadedFiles,
70
+ mode,
71
+ };
72
+ }
6
73
 
7
74
  /**
8
75
  * 标准化 OSS 路径前缀,去除前后斜杠
@@ -88,12 +155,7 @@ async function loadConfigModule() {
88
155
  * @returns {Promise<object>} 合并后的应用配置对象
89
156
  */
90
157
  export async function loadConfig() {
91
- const mode = process.env.APP_MODE || "development";
92
-
93
- if (mode !== "development") {
94
- dotenvConfig({ path: resolve(rootDir, `.env.${mode}`) });
95
- }
96
- dotenvConfig({ path: resolve(rootDir, ".env") });
158
+ loadWorkspaceEnv();
97
159
 
98
160
  const source = await loadConfigModule();
99
161
  const openXiangdaMode = Boolean(
@@ -1,10 +1,31 @@
1
- import { describe, expect, it } from "vitest";
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterEach, describe, expect, it } from "vitest";
2
6
 
3
7
  import {
4
8
  getApiBaseUrl,
9
+ getGlobalOpenXiangdaEnvFile,
10
+ loadWorkspaceEnv,
5
11
  resolveOpenXiangdaEndpointConfig,
6
12
  } from "./load-config.mjs";
7
13
 
14
+ let tempDirs: string[] = [];
15
+
16
+ function makeTempDir(prefix: string) {
17
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
18
+ tempDirs.push(dir);
19
+ return dir;
20
+ }
21
+
22
+ afterEach(() => {
23
+ for (const dir of tempDirs) {
24
+ fs.rmSync(dir, { recursive: true, force: true });
25
+ }
26
+ tempDirs = [];
27
+ });
28
+
8
29
  describe("OpenXiangda endpoint config", () => {
9
30
  it("treats /service base as the complete API base", () => {
10
31
  const config = resolveOpenXiangdaEndpointConfig(
@@ -42,3 +63,111 @@ describe("OpenXiangda endpoint config", () => {
42
63
  );
43
64
  });
44
65
  });
66
+
67
+ describe("OpenXiangda workspace env loading", () => {
68
+ it("loads global env defaults and lets project env override them", () => {
69
+ const home = makeTempDir("openxiangda-home-");
70
+ const workspace = makeTempDir("openxiangda-workspace-");
71
+ fs.mkdirSync(path.join(home, ".openxiangda"), { recursive: true });
72
+ fs.writeFileSync(
73
+ path.join(home, ".openxiangda", ".env"),
74
+ [
75
+ "APP_OSS_REGION=oss-cn-shanghai",
76
+ "APP_OSS_BUCKET=global-bucket",
77
+ "APP_OSS_ACCESS_KEY_ID=global-key",
78
+ "APP_OSS_ACCESS_KEY_SECRET=global-secret",
79
+ "APP_OSS_PATH_PREFIX=global-prefix",
80
+ "",
81
+ ].join("\n"),
82
+ "utf-8",
83
+ );
84
+ fs.writeFileSync(
85
+ path.join(workspace, ".env"),
86
+ ["APP_OSS_BUCKET=project-bucket", "APP_OSS_PATH_PREFIX=project-prefix", ""].join(
87
+ "\n",
88
+ ),
89
+ "utf-8",
90
+ );
91
+
92
+ const env: Record<string, string> = {
93
+ HOME: home,
94
+ APP_OSS_ACCESS_KEY_ID: "shell-key",
95
+ };
96
+
97
+ const result = loadWorkspaceEnv({ workspaceRoot: workspace, env });
98
+
99
+ expect(getGlobalOpenXiangdaEnvFile(env)).toBe(
100
+ path.join(home, ".openxiangda", ".env"),
101
+ );
102
+ expect(result.loadedFiles).toEqual([
103
+ path.join(home, ".openxiangda", ".env"),
104
+ path.join(workspace, ".env"),
105
+ ]);
106
+ expect(env.APP_OSS_REGION).toBe("oss-cn-shanghai");
107
+ expect(env.APP_OSS_BUCKET).toBe("project-bucket");
108
+ expect(env.APP_OSS_ACCESS_KEY_ID).toBe("shell-key");
109
+ expect(env.APP_OSS_ACCESS_KEY_SECRET).toBe("global-secret");
110
+ expect(env.APP_OSS_PATH_PREFIX).toBe("project-prefix");
111
+ });
112
+
113
+ it("lets project env override values injected from global env by the CLI", () => {
114
+ const home = makeTempDir("openxiangda-home-");
115
+ const workspace = makeTempDir("openxiangda-workspace-");
116
+ fs.mkdirSync(path.join(home, ".openxiangda"), { recursive: true });
117
+ fs.writeFileSync(
118
+ path.join(home, ".openxiangda", ".env"),
119
+ "APP_OSS_BUCKET=global-bucket\n",
120
+ "utf-8",
121
+ );
122
+ fs.writeFileSync(
123
+ path.join(workspace, ".env"),
124
+ "APP_OSS_BUCKET=project-bucket\n",
125
+ "utf-8",
126
+ );
127
+
128
+ const env: Record<string, string> = {
129
+ HOME: home,
130
+ APP_OSS_BUCKET: "global-bucket",
131
+ OPENXIANGDA_GLOBAL_ENV_KEYS: "APP_OSS_BUCKET",
132
+ };
133
+
134
+ loadWorkspaceEnv({ workspaceRoot: workspace, env });
135
+
136
+ expect(env.APP_OSS_BUCKET).toBe("project-bucket");
137
+ });
138
+
139
+ it("keeps mode-specific project env above normal project env", () => {
140
+ const home = makeTempDir("openxiangda-home-");
141
+ const workspace = makeTempDir("openxiangda-workspace-");
142
+ fs.mkdirSync(path.join(home, ".openxiangda"), { recursive: true });
143
+ fs.writeFileSync(
144
+ path.join(home, ".openxiangda", ".env"),
145
+ "APP_OSS_BUCKET=global-bucket\n",
146
+ "utf-8",
147
+ );
148
+ fs.writeFileSync(
149
+ path.join(workspace, ".env"),
150
+ "APP_OSS_BUCKET=project-bucket\n",
151
+ "utf-8",
152
+ );
153
+ fs.writeFileSync(
154
+ path.join(workspace, ".env.production"),
155
+ "APP_OSS_BUCKET=prod-bucket\n",
156
+ "utf-8",
157
+ );
158
+
159
+ const env: Record<string, string> = { HOME: home };
160
+ const result = loadWorkspaceEnv({
161
+ workspaceRoot: workspace,
162
+ mode: "production",
163
+ env,
164
+ });
165
+
166
+ expect(result.loadedFiles).toEqual([
167
+ path.join(home, ".openxiangda", ".env"),
168
+ path.join(workspace, ".env"),
169
+ path.join(workspace, ".env.production"),
170
+ ]);
171
+ expect(env.APP_OSS_BUCKET).toBe("prod-bucket");
172
+ });
173
+ });