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 +17 -1
- package/lib/cli.js +463 -10
- package/lib/config.js +74 -10
- package/lib/skills.js +7 -2
- package/openxiangda-skills/SKILL.md +2 -0
- package/openxiangda-skills/references/workspace-state.md +1 -1
- package/openxiangda-skills/skills/openxiangda-core/SKILL.md +18 -0
- package/package.json +1 -1
- package/packages/sdk/src/build-source/scripts/publish-oss.mjs +1 -1
- package/packages/sdk/src/build-source/scripts/utils/load-config.mjs +69 -7
- package/packages/sdk/src/build-source/scripts/utils/load-config.test.ts +130 -1
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
|
|
111
|
-
openxiangda skill
|
|
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
|
|
36
|
-
ensureUserConfigDir();
|
|
37
|
-
const config = readJson(CONFIG_FILE, emptyConfig());
|
|
38
|
+
function normalizeConfig(config) {
|
|
38
39
|
return {
|
|
39
40
|
...emptyConfig(),
|
|
40
|
-
...config,
|
|
41
|
-
profiles: config
|
|
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.
|
|
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
|
@@ -52,7 +52,7 @@ if (
|
|
|
52
52
|
!dryRun &&
|
|
53
53
|
(!config.oss.accessKeyId || config.oss.accessKeyId.startsWith("your_"))
|
|
54
54
|
) {
|
|
55
|
-
console.error("❌ 请先配置 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
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|