huaweicloud-devkit 1.1.0-next.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +27 -1
  2. package/README.zh-CN.md +26 -1
  3. package/package.json +3 -1
  4. package/plugins/huaweicloud-core/.claude-plugin/plugin.json +1 -1
  5. package/plugins/huaweicloud-core/.codex-plugin/plugin.json +1 -1
  6. package/plugins/huaweicloud-core/.cursor-plugin/plugin.json +1 -1
  7. package/plugins/huaweicloud-core/.hermes-plugin/plugin.json +1 -1
  8. package/plugins/huaweicloud-core/.workbuddy-plugin/plugin.json +1 -1
  9. package/plugins/huaweicloud-core/hooks/huaweicloud-safety.py +29 -12
  10. package/plugins/huaweicloud-core/openclaw.plugin.json +1 -1
  11. package/plugins/huaweicloud-core/safety/rules/cloud-risk-rules.json +136 -24
  12. package/plugins/huaweicloud-core/skills/huawei-billing/SKILL.md +15 -14
  13. package/plugins/huaweicloud-core/skills/huawei-dew/SKILL.md +1 -1
  14. package/plugins/huaweicloud-core/skills/huawei-ecs/SKILL.md +5 -1
  15. package/plugins/huaweicloud-core/skills/huawei-ecs/references/hc-activity.md +27 -0
  16. package/plugins/huaweicloud-core/skills/huawei-iam/SKILL.md +11 -11
  17. package/plugins/huaweicloud-core/skills/huawei-rds/SKILL.md +6 -5
  18. package/plugins/huaweicloud-core/skills/huawei-sandbox/SKILL.md +71 -7
  19. package/plugins/huaweicloud-core/skills/huawei-sandbox/references/nginx-templates.md +4 -0
  20. package/plugins/huaweicloud-core/skills/huawei-smn-dms/SKILL.md +19 -12
  21. package/plugins/huaweicloud-core/skills/huawei-vpc/SKILL.md +26 -26
  22. package/plugins/huaweicloud-core/skills/huawei-vpc/references/network.md +12 -6
  23. package/plugins/huaweicloud-core/skills/huawei-waf-aad/SKILL.md +7 -4
  24. package/plugins/huaweicloud-core/skills/huaweicloud-cli-and-auth/SKILL.md +7 -7
  25. package/plugins/huaweicloud-core/src/auth/agent-registration.mjs +14 -0
  26. package/plugins/huaweicloud-core/src/auth/credentials.mjs +30 -1
  27. package/plugins/huaweicloud-core/src/auth/service.mjs +33 -0
  28. package/plugins/huaweicloud-core/src/detect-framework.mjs +25 -3
  29. package/plugins/huaweicloud-core/src/hcloud-cli.mjs +67 -5
  30. package/plugins/huaweicloud-core/src/mcp-server.mjs +23 -0
  31. package/plugins/huaweicloud-core/src/sandbox/hdkitservice-api.mjs +11 -3
  32. package/plugins/huaweicloud-core/src/sandbox/session-manager.mjs +118 -23
  33. package/plugins/huaweicloud-core/src/search-market.mjs +1 -0
  34. package/plugins/huaweicloud-core/src/setup-cli.mjs +491 -64
  35. package/plugins/huaweicloud-core/src/tools.mjs +37 -15
@@ -157,10 +157,13 @@ function readPkg(projectPath) {
157
157
  }
158
158
  }
159
159
 
160
- function detectPackageManager(projectPath) {
160
+ function detectPackageManager(projectPath, pkg) {
161
161
  if (existsSync(join(projectPath, 'pnpm-lock.yaml'))) return 'pnpm';
162
162
  if (existsSync(join(projectPath, 'yarn.lock'))) return 'yarn';
163
163
  if (existsSync(join(projectPath, 'package-lock.json'))) return 'npm';
164
+ if (existsSync(join(projectPath, 'bun.lockb'))) return 'bun';
165
+ const declared = (pkg?.packageManager || '').split('@')[0];
166
+ if (declared && ['pnpm', 'yarn', 'npm', 'bun'].includes(declared)) return declared;
164
167
  return 'npm';
165
168
  }
166
169
 
@@ -234,10 +237,24 @@ function frameworkResult(fw, pm, projectPath) {
234
237
  return patchCommands({ ...fw, packageManager: pm, rootDir: projectPath }, pm);
235
238
  }
236
239
 
240
+ function readVitepressOutDir(projectPath) {
241
+ const configFiles = ['config.mts', 'config.ts', 'config.mjs', 'config.js'];
242
+ for (const cf of configFiles) {
243
+ const configPath = join(projectPath, '.vitepress', cf);
244
+ if (!existsSync(configPath)) continue;
245
+ try {
246
+ const content = readFileSync(configPath, 'utf8');
247
+ const match = content.match(/outDir\s*:\s*['"]([^'"]+)['"]/);
248
+ if (match) return match[1];
249
+ } catch {}
250
+ }
251
+ return null;
252
+ }
253
+
237
254
  export function detectFramework(projectPath) {
238
255
  const pkg = readPkg(projectPath);
239
256
  const deps = collectDeps(pkg);
240
- const pm = detectPackageManager(projectPath);
257
+ const pm = detectPackageManager(projectPath, pkg);
241
258
 
242
259
  if (
243
260
  existsSync(join(projectPath, 'pnpm-workspace.yaml')) ||
@@ -287,7 +304,12 @@ export function detectFramework(projectPath) {
287
304
  }
288
305
 
289
306
  if (existsSync(join(projectPath, '.vitepress'))) {
290
- return frameworkResult(FRAMEWORKS.vitepress, pm, projectPath);
307
+ const outDir = readVitepressOutDir(projectPath);
308
+ return frameworkResult(
309
+ outDir ? { ...FRAMEWORKS.vitepress, outputDir: outDir } : FRAMEWORKS.vitepress,
310
+ pm,
311
+ projectPath,
312
+ );
291
313
  }
292
314
 
293
315
  if (
@@ -1,4 +1,8 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
2
6
 
3
7
  import { classifyHcloudArgs, redactSecrets, assertAllowed } from './safety-policy.mjs';
4
8
  import { getProxySettings } from './proxy/proxy-config.mjs';
@@ -6,6 +10,46 @@ import { getProxySettings } from './proxy/proxy-config.mjs';
6
10
  const DEFAULT_TIMEOUT_MS = 60_000;
7
11
  const DEFAULT_FORCE_KILL_AFTER_MS = 2_000;
8
12
  const DEFAULT_MAX_RETRIES = 1;
13
+ const APPROVAL_TTL_MS = 5 * 60_000;
14
+ const LARGE_OUTPUT_THRESHOLD = 50_000;
15
+ const OUTPUT_DIR = join('/tmp', 'huaweicloud-devkit');
16
+
17
+ const approvalStore = new Map();
18
+
19
+ export function createApprovalToken(rawArgs) {
20
+ const token = randomUUID();
21
+ approvalStore.set(token, { rawArgs, createdAt: Date.now() });
22
+ if (approvalStore.size % 20 === 0) {
23
+ const now = Date.now();
24
+ for (const [k, v] of approvalStore) {
25
+ if (now - v.createdAt > APPROVAL_TTL_MS) approvalStore.delete(k);
26
+ }
27
+ }
28
+ return token;
29
+ }
30
+
31
+ export function consumeApprovalToken(token) {
32
+ const entry = approvalStore.get(token);
33
+ if (!entry) return null;
34
+ if (Date.now() - entry.createdAt > APPROVAL_TTL_MS) {
35
+ approvalStore.delete(token);
36
+ return null;
37
+ }
38
+ approvalStore.delete(token);
39
+ return entry.rawArgs;
40
+ }
41
+
42
+ function saveLargeOutput(rawStdout) {
43
+ if (rawStdout.length <= LARGE_OUTPUT_THRESHOLD) return null;
44
+ try {
45
+ mkdirSync(OUTPUT_DIR, { recursive: true });
46
+ const filePath = join(OUTPUT_DIR, `output-${Date.now()}.json`);
47
+ writeFileSync(filePath, rawStdout, { encoding: 'utf8' });
48
+ return filePath;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
9
53
 
10
54
  export function planHcloudCommand(args, options = {}) {
11
55
  const normalizedArgs = Array.isArray(args) ? args.map(String) : [];
@@ -26,6 +70,7 @@ export function planHcloudCommand(args, options = {}) {
26
70
  executableBlock: redactOutput(command),
27
71
  warnings,
28
72
  classification,
73
+ approvalToken: createApprovalToken(normalizedArgs),
29
74
  safeToRun: classification.decision === 'allow',
30
75
  };
31
76
  }
@@ -53,12 +98,22 @@ export async function runHcloud(args, options = {}) {
53
98
  throw new Error('Unreachable retry state.');
54
99
  }
55
100
 
101
+ function discoverHcloudPath() {
102
+ if (process.env.HCLOUD_BIN && existsSync(process.env.HCLOUD_BIN)) return process.env.HCLOUD_BIN;
103
+ const candidates =
104
+ process.platform === 'win32'
105
+ ? [join(homedir(), 'hcloud', 'hcloud.exe')]
106
+ : [join(homedir(), '.local', 'bin', 'hcloud'), join(homedir(), 'hcloud', 'hcloud')];
107
+ return candidates.find((c) => existsSync(c)) || null;
108
+ }
109
+
56
110
  function runHcloudOnce(plan, options) {
57
111
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
58
112
  const forceKillAfterMs = options.forceKillAfterMs ?? DEFAULT_FORCE_KILL_AFTER_MS;
59
- const executable = options.executable || options.env?.HCLOUD_BIN || process.env.HCLOUD_BIN || 'hcloud';
113
+ const executable = options.executable || options.env?.HCLOUD_BIN || discoverHcloudPath() || 'hcloud';
60
114
  const executableArgs = Array.isArray(options.executableArgs) ? options.executableArgs.map(String) : [];
61
115
  const cwd = options.cwd || undefined;
116
+ const stdin = options.stdin ?? 'y\n';
62
117
 
63
118
  return new Promise((resolve) => {
64
119
  const proxySettings = getProxySettings();
@@ -78,11 +133,11 @@ function runHcloudOnce(plan, options) {
78
133
  ...options.env,
79
134
  },
80
135
  });
81
- if (options.stdin) {
82
- if (typeof options.stdin === 'function') {
83
- options.stdin(child.stdin);
136
+ if (stdin) {
137
+ if (typeof stdin === 'function') {
138
+ stdin(child.stdin);
84
139
  } else {
85
- child.stdin.write(String(options.stdin));
140
+ child.stdin.write(String(stdin));
86
141
  child.stdin.end();
87
142
  }
88
143
  }
@@ -99,6 +154,13 @@ function runHcloudOnce(plan, options) {
99
154
  clearTimeout(timer);
100
155
  clearTimeout(forceTimer);
101
156
  clearTimeout(settleTimer);
157
+ if (result.stdout && String(result.stdout).length > LARGE_OUTPUT_THRESHOLD) {
158
+ const outputFile = saveLargeOutput(stdout);
159
+ if (outputFile) {
160
+ result.outputFile = outputFile;
161
+ result.stdout = String(result.stdout).slice(0, 2000) + `\n...(truncated, full output saved to ${outputFile})`;
162
+ }
163
+ }
102
164
  resolve(result);
103
165
  }
104
166
 
@@ -49,6 +49,29 @@ for (const base of [pluginRoot, packageRoot]) {
49
49
  let buffer = Buffer.alloc(0);
50
50
  let useContentLengthFraming = true;
51
51
 
52
+ // Keep the event loop alive after stdin is closed (Windows Hermes workaround).
53
+ // Node.js exits when no active handles remain; the stdin 'data' listener is
54
+ // the only handle. On Windows, Hermes may close the stdin pipe after the
55
+ // initial handshake, causing the process to exit silently (exit 0).
56
+ //
57
+ // When stdin closes, start a keepalive timer. When stdout also closes (normal
58
+ // shutdown signal from OfficeAce or other agents), clear the timer and exit.
59
+ let keepAlive = null;
60
+ function onStdinClose() {
61
+ if (keepAlive) return;
62
+ keepAlive = setInterval(() => {}, 60000);
63
+ }
64
+ function onStdoutClose() {
65
+ if (keepAlive) {
66
+ clearInterval(keepAlive);
67
+ keepAlive = null;
68
+ }
69
+ process.exitCode = 0;
70
+ }
71
+ stdin.on('close', onStdinClose);
72
+ stdin.on('end', onStdinClose);
73
+ stdout.on('close', onStdoutClose);
74
+
52
75
  stdin.on('data', (chunk) => {
53
76
  buffer = Buffer.concat([buffer, chunk]);
54
77
  readFrames();
@@ -7,6 +7,12 @@ const HDKIT_BASE_URL =
7
7
  async function hdkitRequest(method, path, body, timeoutMs = 300000) {
8
8
  const { ak, sk, securitytoken } = getCredentials();
9
9
 
10
+ if (!ak || !sk) {
11
+ throw new Error(
12
+ 'Huawei Cloud credentials are not configured. ' +
13
+ 'Run "npx huaweicloud-devkit auth init" or set HW_ACCESS_KEY/HW_SECRET_KEY.',
14
+ );
15
+ }
10
16
  const headers = {
11
17
  'Content-Type': 'application/json',
12
18
  'X-HW-AK': ak,
@@ -49,10 +55,12 @@ async function hdkitRequest(method, path, body, timeoutMs = 300000) {
49
55
  }
50
56
 
51
57
  if (!resp.ok) {
52
- const err = new Error(data.message || `hdkitservice error: ${data.code || resp.status}`);
53
- err.code = data.code;
58
+ const code = data.code || `HTTP_${resp.status}`;
59
+ const trace = data.traceId ? ` [trace: ${data.traceId}]` : '';
60
+ const err = new Error(`${code}: ${data.message || 'hdkitservice error'}${trace}`);
61
+ err.code = code;
54
62
  err.status = resp.status;
55
- err.traceId = data.traceId; // 后端实际返回驼峰 traceId
63
+ err.traceId = data.traceId;
56
64
  throw err;
57
65
  }
58
66
 
@@ -694,7 +694,7 @@ export async function deployNginx(
694
694
  workspaceId,
695
695
  { nginxType, port, project, outputDir, nodePort, publicPort, configName },
696
696
  username = 'root',
697
- timeoutMs = 30000,
697
+ timeoutMs = 60000,
698
698
  ) {
699
699
  if (!workspaceId) {
700
700
  throw new Error('sandbox deploy nginx: workspace_id is required.');
@@ -703,6 +703,53 @@ export async function deployNginx(
703
703
  throw new Error('sandbox deploy nginx: nginxType, port, project, and outputDir are required.');
704
704
  }
705
705
 
706
+ const nginxCheck = await execOneShot(
707
+ workspaceId,
708
+ 'command -v nginx >/dev/null 2>&1 && echo "INSTALLED" || echo "MISSING"',
709
+ username,
710
+ 10000,
711
+ );
712
+ if (!String(nginxCheck.stdout || '').includes('INSTALLED')) {
713
+ throw new Error(
714
+ 'sandbox deploy nginx: nginx is not installed. Install it first:\n' +
715
+ ' Detect OS: source /etc/os-release && echo $ID\n' +
716
+ ' apt: sudo apt-get update -qq && sudo apt-get install -y -qq nginx\n' +
717
+ ' yum: sudo yum install -y nginx\n' +
718
+ ' dnf: sudo dnf install -y nginx\n' +
719
+ 'Alternatively, skip nginx and use Python HTTP server (see nginx-templates.md).',
720
+ );
721
+ }
722
+
723
+ const listenPort = publicPort || port;
724
+ const basePort = nginxType === 'proxy' ? listenPort : port;
725
+
726
+ let targetPort = basePort;
727
+ let portWarning;
728
+ const maxPortAttempts = 10;
729
+ for (let offset = 0; offset < maxPortAttempts; offset += 1) {
730
+ targetPort = basePort + offset;
731
+ try {
732
+ const portCheck = await execOneShot(
733
+ workspaceId,
734
+ `ss -tlnp 2>/dev/null | grep -q ":${targetPort} " && echo "IN_USE" || echo "FREE"`,
735
+ username,
736
+ 10000,
737
+ );
738
+ if (!String(portCheck.stdout || '').includes('IN_USE')) break;
739
+ if (offset === 0) {
740
+ portWarning = `Port ${basePort} is in use — auto-assigned port ${targetPort}`;
741
+ }
742
+ } catch {}
743
+ if (offset === maxPortAttempts - 1) {
744
+ throw new Error(
745
+ `sandbox deploy nginx: all ports ${basePort}-${basePort + maxPortAttempts - 1} are in use. Free a port and try again.`,
746
+ );
747
+ }
748
+ }
749
+
750
+ const effectiveNodePort =
751
+ nginxType === 'proxy' ? (nodePort && nodePort !== listenPort ? nodePort : listenPort + 1) : undefined;
752
+
706
753
  const projectPath = `/workspace/${project}`;
707
754
  const outputPath = outputDir.startsWith('/') ? outputDir : `${projectPath}/${outputDir}`;
708
755
 
@@ -715,7 +762,7 @@ fi`;
715
762
 
716
763
  const templates = {
717
764
  spa: `server {
718
- listen ${port};
765
+ listen ${targetPort};
719
766
  root ${outputPath};
720
767
  index index.html;
721
768
 
@@ -729,11 +776,12 @@ fi`;
729
776
  }
730
777
  }`,
731
778
  proxy: `server {
732
- listen ${publicPort || port};
779
+ listen ${listenPort};
733
780
  server_name _;
781
+ large_client_header_buffers 4 32k;
734
782
 
735
783
  location / {
736
- proxy_pass http://127.0.0.1:${nodePort};
784
+ proxy_pass http://127.0.0.1:${effectiveNodePort};
737
785
  proxy_http_version 1.1;
738
786
  proxy_set_header Upgrade $http_upgrade;
739
787
  proxy_set_header Connection 'upgrade';
@@ -743,10 +791,13 @@ fi`;
743
791
  proxy_set_header X-Forwarded-Proto $scheme;
744
792
  proxy_cache_bypass $http_upgrade;
745
793
  proxy_read_timeout 60s;
794
+ proxy_buffer_size 128k;
795
+ proxy_buffers 4 256k;
796
+ proxy_busy_buffers_size 256k;
746
797
  }
747
798
  }`,
748
799
  static: `server {
749
- listen ${port};
800
+ listen ${targetPort};
750
801
  root ${outputPath};
751
802
  index index.html;
752
803
 
@@ -769,14 +820,14 @@ fi`;
769
820
  const cmd = [
770
821
  resolveScript,
771
822
  `sudo mkdir -p /etc/nginx/conf.d`,
772
- `sudo tee /etc/nginx/conf.d/${configName || 'app'}.conf > /dev/null << 'NGINX_EOF'`,
823
+ `sudo tee /etc/nginx/conf.d/${configName || project}.conf > /dev/null << 'NGINX_EOF'`,
773
824
  config,
774
825
  `NGINX_EOF`,
775
826
  `# Resolve symlinks for chmod (chmod does not follow symlinks on Linux)`,
776
827
  `chmod -R o+rX "$REAL_PROJECT" 2>/dev/null || true`,
777
828
  `find "$REAL_PROJECT" -type d -exec chmod o+x {} \\; 2>/dev/null || true`,
778
829
  `find "$REAL_PROJECT" -type f -path "*/node_modules/.bin/*" -exec chmod +x {} \\; 2>/dev/null || true`,
779
- `if pgrep -x nginx > /dev/null 2>&1; then sudo nginx -s reload 2>/dev/null; else sudo nginx; fi`,
830
+ `if pgrep -x nginx > /dev/null 2>&1; then sudo nginx -s reload 2>/dev/null || { sudo killall -9 nginx 2>/dev/null; sleep 1; sudo nginx; }; else sudo nginx; fi`,
780
831
  ].join('\n');
781
832
 
782
833
  const result = await execOneShot(workspaceId, cmd, username, timeoutMs);
@@ -785,7 +836,7 @@ fi`;
785
836
  try {
786
837
  const tunnelCheck = await execOneShot(
787
838
  workspaceId,
788
- 'devbridge ls 2>/dev/null | grep -q "Tunnel URL" && echo "ACTIVE" || echo "INACTIVE"',
839
+ 'devbridge list -j 2>/dev/null | grep -q \'"tunnelId"\' && echo "ACTIVE" || echo "INACTIVE"',
789
840
  username,
790
841
  10000,
791
842
  );
@@ -795,15 +846,17 @@ fi`;
795
846
  return {
796
847
  ok: result.exitCode === 0,
797
848
  nginxType,
798
- port: nginxType === 'proxy' ? publicPort || port : port,
849
+ port: targetPort,
850
+ nodePort: effectiveNodePort || undefined,
799
851
  outputPath,
800
852
  projectPath,
801
853
  exitCode: result.exitCode,
802
854
  stdout: result.stdout,
803
855
  nextStep: 'expose_via_devbridge',
804
- warning: !tunnelActive
805
- ? 'No active DevBridge tunnel — deployment is incomplete. Proceed to Step 7 to expose the app.'
806
- : undefined,
856
+ warning:
857
+ (!tunnelActive
858
+ ? 'No active DevBridge tunnel — deployment is incomplete. Proceed to Step 7 to expose the app.'
859
+ : portWarning) || undefined,
807
860
  };
808
861
  }
809
862
 
@@ -843,7 +896,23 @@ export async function deployCheck(
843
896
  `fi`,
844
897
  ``,
845
898
  `TOTAL=$((TOTAL+1))`,
846
- `if devbridge ls 2>/dev/null | grep -q "Tunnel URL"; then`,
899
+ `FINGERPRINT_FILE="${outputPath}/.deploy_fingerprint"`,
900
+ `FINGERPRINT_EXPECTED=$(cat "$FINGERPRINT_FILE" 2>/dev/null)`,
901
+ `if [ -n "$FINGERPRINT_EXPECTED" ]; then`,
902
+ ` FINGERPRINT_ACTUAL=$(curl -s http://localhost:${port}/.deploy_fingerprint 2>/dev/null)`,
903
+ ` if [ "$FINGERPRINT_EXPECTED" = "$FINGERPRINT_ACTUAL" ]; then`,
904
+ ` echo "content_verified:PASS"`,
905
+ ` PASS=$((PASS+1))`,
906
+ ` else`,
907
+ ` echo "content_verified:FAIL (fingerprint mismatch — nginx may be serving stale content from a previous deployment)"`,
908
+ ` fi`,
909
+ `else`,
910
+ ` echo "content_verified:SKIP (no fingerprint file)"`,
911
+ ` TOTAL=$((TOTAL-1))`,
912
+ `fi`,
913
+ ``,
914
+ `TOTAL=$((TOTAL+1))`,
915
+ `if devbridge list -j 2>/dev/null | grep -q '"tunnelId"'; then`,
847
916
  ` echo "devbridge_tunnel:PASS"`,
848
917
  ` PASS=$((PASS+1))`,
849
918
  `else`,
@@ -851,8 +920,9 @@ export async function deployCheck(
851
920
  `fi`,
852
921
  ``,
853
922
  `TOTAL=$((TOTAL+1))`,
854
- `TUNNEL_URL=$(devbridge ls 2>/dev/null | grep -oP "Tunnel URL: \\Khttps://[^ ]+")`,
855
- `if [ -n "$TUNNEL_URL" ]; then`,
923
+ `TUNNEL_ID=$(devbridge list -j 2>/dev/null | grep -oP '"tunnelId":\\s*"\\K[^"]+' | head -1)`,
924
+ `TUNNEL_URL="https://\${TUNNEL_ID}-${port}.cn-north-4-bridge.myhuaweicloud.com"`,
925
+ `if [ -n "$TUNNEL_ID" ] && [ -n "$TUNNEL_URL" ]; then`,
856
926
  ` HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$TUNNEL_URL" 2>/dev/null || echo "000")`,
857
927
  ` if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "304" ]; then`,
858
928
  ` echo "tunnel_url_accessible:PASS ($TUNNEL_URL -> $HTTP_CODE)"`,
@@ -864,20 +934,43 @@ export async function deployCheck(
864
934
  ` echo "tunnel_url_accessible:FAIL (no tunnel URL)"`,
865
935
  `fi`,
866
936
  ``,
867
- `${
868
- isCrossPlatform
869
- ? `
937
+ `${`
870
938
  TOTAL=$((TOTAL+1))
871
939
  if [ -f "${outputPath}/qr.png" ]; then
872
940
  echo "qr_code:PASS"
873
941
  PASS=$((PASS+1))
874
942
  else
875
- echo "qr_code:FAIL (cross-platform app missing QR code)"
943
+ # Auto-detect cross-platform from project files (more reliable than frameworkType param)
944
+ PROJ_DIR="/workspace/${project}"
945
+ IS_CROSS=0
946
+ if [ -f "$PROJ_DIR/manifest.json" ] || grep -qE '"@tarojs/taro"|"@dcloudio/uni-app"' "$PROJ_DIR/package.json" 2>/dev/null; then
947
+ IS_CROSS=1
948
+ fi
949
+ if [ $IS_CROSS -eq 0 ] && ( [ -f "$PROJ_DIR/app.config.ts" ] || [ -f "$PROJ_DIR/app.config.js" ] ) && grep -qE "pages|tabBar" "$PROJ_DIR/app.config."* 2>/dev/null; then
950
+ IS_CROSS=1
951
+ fi
952
+
953
+ if [ $IS_CROSS -eq 1 ]; then
954
+ if [ -n "$TUNNEL_URL" ]; then
955
+ curl -s "https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$TUNNEL_URL" 2>/dev/null)" -o "${outputPath}/qr.png" 2>/dev/null
956
+ chmod o+r "${outputPath}/qr.png" 2>/dev/null || true
957
+ if [ -f "${outputPath}/qr.png" ] && [ -s "${outputPath}/qr.png" ]; then
958
+ echo "qr_code:PASS (auto-generated)"
959
+ PASS=$((PASS+1))
960
+ else
961
+ echo "qr_code:FAIL (QR generation failed)"
962
+ fi
963
+ else
964
+ echo "qr_code:FAIL (no tunnel URL for QR generation)"
965
+ fi
966
+ else
967
+ echo "qr_code:SKIP (not a cross-platform project)"
968
+ TOTAL=$((TOTAL-1))
969
+ fi
876
970
  fi
877
- `
878
- : ''
879
- }`,
971
+ `}`,
880
972
  `echo "SCORE:\${PASS}/\${TOTAL}"`,
973
+ `echo "TUNNEL_URL:\${TUNNEL_URL:-}"`,
881
974
  `[ "\${PASS}" = "\${TOTAL}" ] && echo "VERDICT:COMPLETE" || echo "VERDICT:INCOMPLETE"`,
882
975
  ]
883
976
  .filter(Boolean)
@@ -888,10 +981,11 @@ fi
888
981
  const checks = {};
889
982
  const lines = stdout.split('\n');
890
983
  for (const line of lines) {
891
- const m = line.match(/^(\w+):(PASS|FAIL)\b(.*)/);
984
+ const m = line.match(/^(\w+):(\w+)\b(.*)/);
892
985
  if (m) checks[m[1]] = { status: m[2], detail: (m[3] || '').trim() };
893
986
  }
894
987
  const scoreMatch = stdout.match(/SCORE:(\d+)\/(\d+)/);
988
+ const tunnelMatch = stdout.match(/TUNNEL_URL:(https:\/\/[^\s]+)/);
895
989
  const complete = /VERDICT:COMPLETE/.test(stdout);
896
990
 
897
991
  const missing = [];
@@ -907,6 +1001,7 @@ fi
907
1001
  checkType: isCrossPlatform ? 'cross-platform' : 'standard',
908
1002
  checks,
909
1003
  score: scoreMatch ? { pass: parseInt(scoreMatch[1], 10), total: parseInt(scoreMatch[2], 10) } : null,
1004
+ publicUrl: tunnelMatch ? tunnelMatch[1] : undefined,
910
1005
  missingSteps: missing.length > 0 ? missing.join(', ') : undefined,
911
1006
  nextStep: !complete
912
1007
  ? missing.includes('devbridge_tunnel') || missing.includes('tunnel_url_accessible')
@@ -171,6 +171,7 @@ export async function searchMarketplace(query = '', category = '') {
171
171
  description: truncate(skill.description),
172
172
  triggers: (skill.triggers || []).slice(0, 5),
173
173
  matched,
174
+ installCommand: `npx skills add huaweicloud/huaweicloud-skills --skill ${skill.name}`,
174
175
  });
175
176
  }
176
177
  results.sort((a, b) => b.score - a.score);