huaweicloud-devkit 1.1.0-next.2 → 1.1.0-next.21

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 (39) hide show
  1. package/README.md +64 -1
  2. package/README.zh-CN.md +63 -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 +46 -21
  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 +596 -93
  19. package/plugins/huaweicloud-core/skills/huawei-sandbox/references/framework-commands.md +0 -1
  20. package/plugins/huaweicloud-core/skills/huawei-sandbox/references/nginx-templates.md +8 -4
  21. package/plugins/huaweicloud-core/skills/huawei-smn-dms/SKILL.md +19 -12
  22. package/plugins/huaweicloud-core/skills/huawei-voucher/SKILL.md +52 -0
  23. package/plugins/huaweicloud-core/skills/huawei-vpc/SKILL.md +26 -26
  24. package/plugins/huaweicloud-core/skills/huawei-vpc/references/network.md +12 -6
  25. package/plugins/huaweicloud-core/skills/huawei-waf-aad/SKILL.md +7 -4
  26. package/plugins/huaweicloud-core/skills/huaweicloud-cli-and-auth/SKILL.md +7 -7
  27. package/plugins/huaweicloud-core/skills/huaweicloud-core/SKILL.md +2 -0
  28. package/plugins/huaweicloud-core/src/auth/agent-registration.mjs +8 -0
  29. package/plugins/huaweicloud-core/src/auth/credentials.mjs +30 -1
  30. package/plugins/huaweicloud-core/src/auth/service.mjs +33 -0
  31. package/plugins/huaweicloud-core/src/detect-framework.mjs +25 -3
  32. package/plugins/huaweicloud-core/src/hcloud-cli.mjs +67 -5
  33. package/plugins/huaweicloud-core/src/mcp-server.mjs +23 -0
  34. package/plugins/huaweicloud-core/src/sandbox/hdkitservice-api.mjs +11 -3
  35. package/plugins/huaweicloud-core/src/sandbox/session-manager.mjs +436 -21
  36. package/plugins/huaweicloud-core/src/search-market.mjs +1 -0
  37. package/plugins/huaweicloud-core/src/setup-cli.mjs +605 -50
  38. package/plugins/huaweicloud-core/src/tools.mjs +193 -18
  39. package/plugins/huaweicloud-core/src/ws-exec/ws-exec-client.js +29 -8
@@ -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
 
@@ -1,7 +1,16 @@
1
1
  import { spawn, execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { createConnection as netConnect } from 'node:net';
4
- import { existsSync, readFileSync, statSync, mkdirSync, rmSync, createReadStream, appendFileSync } from 'node:fs';
4
+ import {
5
+ existsSync,
6
+ readFileSync,
7
+ statSync,
8
+ mkdirSync,
9
+ rmSync,
10
+ createReadStream,
11
+ appendFileSync,
12
+ unlinkSync,
13
+ } from 'node:fs';
5
14
  import { createHash, randomBytes } from 'node:crypto';
6
15
  import { join, dirname, basename } from 'node:path';
7
16
  import { tmpdir } from 'node:os';
@@ -155,7 +164,9 @@ export async function execWithSession(workspaceId, command, username, timeoutMs)
155
164
 
156
165
  export const UPLOAD_CHUNK_SIZE = 30000;
157
166
 
158
- export const UPLOAD_BATCH_SIZE = 5;
167
+ export const UPLOAD_BATCH_SIZE = 2;
168
+
169
+ export const UPLOAD_MAX_RETRIES = 3;
159
170
 
160
171
  export function splitBase64Chunks(base64, chunkSize = UPLOAD_CHUNK_SIZE) {
161
172
  const chunks = [];
@@ -189,11 +200,21 @@ export async function uploadFileWithSession(workspaceId, localPath, remotePath,
189
200
  const batchNum = Math.floor(batchStart / UPLOAD_BATCH_SIZE) + 1;
190
201
  const totalBatches = Math.ceil(chunks.length / UPLOAD_BATCH_SIZE);
191
202
  const cmd = `printf '%s' '${combinedChunk}' >> "${tmp}"`;
192
- const res = await execWithSession(workspaceId, cmd, username, timeoutMs);
193
- if (res.exitCode !== 0) {
194
- throw new Error(
195
- `sandbox upload: failed writing batch ${batchNum}/${totalBatches}: ${res.stdout || res.error || res.exitCode}`,
196
- );
203
+
204
+ let batchOk = false;
205
+ let lastError;
206
+ for (let retry = 0; retry < UPLOAD_MAX_RETRIES; retry++) {
207
+ const res = await execWithSession(workspaceId, cmd, username, timeoutMs);
208
+ if (res.exitCode === 0) {
209
+ batchOk = true;
210
+ break;
211
+ }
212
+ lastError = res.stdout || res.error || res.exitCode;
213
+ console.error(` upload retry ${retry + 1}/${UPLOAD_MAX_RETRIES} for batch ${batchNum}/${totalBatches}`);
214
+ await new Promise((r) => setTimeout(r, 2000));
215
+ }
216
+ if (!batchOk) {
217
+ throw new Error(`sandbox upload: failed writing batch ${batchNum}/${totalBatches}: ${lastError}`);
197
218
  }
198
219
  if (batchNum % 10 === 0 || batchNum === totalBatches) {
199
220
  console.error(` upload progress: batch ${batchNum}/${totalBatches}`);
@@ -257,6 +278,17 @@ function uploadLog(message) {
257
278
  } catch {}
258
279
  }
259
280
 
281
+ function rotateUploadLog(maxBytes = 100 * 1024) {
282
+ try {
283
+ if (existsSync(UPLOAD_LOG_PATH)) {
284
+ const stat = statSync(UPLOAD_LOG_PATH);
285
+ if (stat.size > maxBytes) {
286
+ unlinkSync(UPLOAD_LOG_PATH);
287
+ }
288
+ }
289
+ } catch {}
290
+ }
291
+
260
292
  function generateUploadToken() {
261
293
  return randomBytes(16).toString('hex');
262
294
  }
@@ -267,15 +299,34 @@ async function createTarGz(localDir, exclude = []) {
267
299
  mkdirSync(archiveDir, { recursive: true });
268
300
  const archivePath = join(archiveDir, archiveName);
269
301
 
270
- const args = [
271
- ...exclude.flatMap((p) => ['--exclude', p]),
272
- '-czf',
273
- archivePath,
274
- '-C',
275
- dirname(localDir),
276
- basename(localDir),
277
- ];
278
- await execFileAsync('tar', args);
302
+ const hasGit = existsSync(join(localDir, '.git'));
303
+ if (hasGit) {
304
+ await execFileAsync('git', [
305
+ '-C',
306
+ localDir,
307
+ 'archive',
308
+ '--format=tar.gz',
309
+ `--prefix=${basename(localDir)}/`,
310
+ `--output=${archivePath}`,
311
+ 'HEAD',
312
+ ]);
313
+ } else {
314
+ const args = [];
315
+ for (const pattern of exclude) {
316
+ if (pattern.startsWith('**/')) {
317
+ const base = pattern.slice(3);
318
+ for (let depth = 0; depth <= 4; depth++) {
319
+ const prefix = depth === 0 ? '' : '*/'.repeat(depth);
320
+ args.push('--exclude', `${prefix}${base}`);
321
+ }
322
+ } else {
323
+ args.push('--exclude', pattern);
324
+ }
325
+ }
326
+ args.push('-czf', archivePath, '-C', dirname(localDir), basename(localDir));
327
+ await execFileAsync('tar', args);
328
+ }
329
+
279
330
  return archivePath;
280
331
  }
281
332
 
@@ -545,6 +596,8 @@ export async function uploadProjectWithSession(
545
596
  throw new Error(`sandbox upload project: path is not a directory: ${localDir}`);
546
597
  }
547
598
 
599
+ rotateUploadLog();
600
+
548
601
  const projectName = basename(localDir);
549
602
  const targetParentDir = remoteDir || '/workspace';
550
603
  const archiveRemotePath = `${targetParentDir}/${projectName}.tar.gz`;
@@ -555,15 +608,36 @@ export async function uploadProjectWithSession(
555
608
 
556
609
  uploadLog(`uploadProject: ${localDir} -> ${archiveRemotePath} (archive=${archiveSize} bytes, md5=${expectedMd5})`);
557
610
 
611
+ const SIZE_50MB = 50 * 1024 * 1024;
612
+ if (archiveSize > SIZE_50MB) {
613
+ uploadLog(
614
+ `uploadProject: archive size ${(archiveSize / (1024 * 1024)).toFixed(1)}MB exceeds 50MB. ` +
615
+ `Dependencies or platform binaries may have been included. ` +
616
+ `Ensure exclude list contains "**/node_modules" to match all nesting levels.`,
617
+ );
618
+ }
619
+
558
620
  let result;
559
- try {
560
- result = await uploadViaHttpTunnel(workspaceId, archivePath, archiveRemotePath, username, timeoutMs, options);
561
- } catch (tunnelError) {
562
- uploadLog(`uploadProject: HTTP tunnel upload failed: ${tunnelError.message}`);
621
+ let tunnelError;
622
+ for (let attempt = 0; attempt < UPLOAD_MAX_RETRIES; attempt++) {
623
+ try {
624
+ await cleanupFileServer(workspaceId, username).catch(() => {});
625
+ result = await uploadViaHttpTunnel(workspaceId, archivePath, archiveRemotePath, username, timeoutMs, options);
626
+ tunnelError = null;
627
+ break;
628
+ } catch (error) {
629
+ tunnelError = error;
630
+ const errorType = error.code || error.name || 'unknown';
631
+ uploadLog(`uploadProject: attempt ${attempt + 1}/${UPLOAD_MAX_RETRIES} failed [${errorType}]: ${error.message}`);
632
+ await new Promise((r) => setTimeout(r, 5000));
633
+ }
634
+ }
635
+ if (tunnelError) {
636
+ uploadLog(`uploadProject: all ${UPLOAD_MAX_RETRIES} attempts failed: ${tunnelError.message}`);
563
637
  uploadLog(`uploadProject: NOT falling back to base64 (removed). Rethrowing with diagnostics.`);
564
638
  cleanupLocalArchive(archivePath);
565
639
  throw new Error(
566
- `sandbox upload failed: HTTP tunnel could not transfer the project archive. ` +
640
+ `sandbox upload failed after ${UPLOAD_MAX_RETRIES} attempts: HTTP tunnel could not transfer the project archive. ` +
567
641
  `Archive size: ${(archiveSize / 1024).toFixed(1)}KB. ` +
568
642
  `Root cause: ${tunnelError.message}. ` +
569
643
  `Diagnostic log: ${UPLOAD_LOG_PATH}`,
@@ -582,6 +656,22 @@ export async function uploadProjectWithSession(
582
656
  username,
583
657
  timeoutMs,
584
658
  );
659
+ try {
660
+ await execWithSession(
661
+ workspaceId,
662
+ [
663
+ `REAL_PATH=$(readlink -f "${targetParentDir}/${projectName}" 2>/dev/null || echo "${targetParentDir}/${projectName}")`,
664
+ `if [ ! -d "$REAL_PATH" ] && [ -d "${targetParentDir}" ]; then`,
665
+ ` REAL_PATH="${targetParentDir}/${projectName}"`,
666
+ `fi`,
667
+ `chmod -R o+rX "$REAL_PATH" 2>/dev/null || true`,
668
+ `find "$REAL_PATH" -type d -exec chmod o+x {} \\; 2>/dev/null || true`,
669
+ `find "$REAL_PATH" -type f -path "*/node_modules/.bin/*" -exec chmod +x {} \\; 2>/dev/null || true`,
670
+ ].join('\n'),
671
+ username,
672
+ 15000,
673
+ );
674
+ } catch {}
585
675
  }
586
676
 
587
677
  try {
@@ -600,6 +690,331 @@ export async function uploadProjectWithSession(
600
690
  };
601
691
  }
602
692
 
693
+ export async function deployNginx(
694
+ workspaceId,
695
+ { nginxType, port, project, outputDir, nodePort, publicPort, configName },
696
+ username = 'root',
697
+ timeoutMs = 60000,
698
+ ) {
699
+ if (!workspaceId) {
700
+ throw new Error('sandbox deploy nginx: workspace_id is required.');
701
+ }
702
+ if (!nginxType || !port || !project || !outputDir) {
703
+ throw new Error('sandbox deploy nginx: nginxType, port, project, and outputDir are required.');
704
+ }
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
+
753
+ const projectPath = `/workspace/${project}`;
754
+ const outputPath = outputDir.startsWith('/') ? outputDir : `${projectPath}/${outputDir}`;
755
+
756
+ const resolveScript = `REAL_PROJECT=$(readlink -f "${projectPath}" 2>/dev/null || echo "${projectPath}")
757
+ REAL_OUTPUT="${outputPath}"
758
+ if [ "\${REAL_PROJECT}" != "${projectPath}" ]; then
759
+ REL_OUTPUT=$(echo "${outputDir}" | sed "s|${projectPath}/||")
760
+ REAL_OUTPUT="\${REAL_PROJECT}/\${REL_OUTPUT}"
761
+ fi`;
762
+
763
+ const templates = {
764
+ spa: `server {
765
+ listen ${targetPort};
766
+ root ${outputPath};
767
+ index index.html;
768
+
769
+ location / {
770
+ try_files $uri /index.html;
771
+ }
772
+
773
+ location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
774
+ expires 1h;
775
+ add_header Cache-Control "public, immutable";
776
+ }
777
+ }`,
778
+ proxy: `server {
779
+ listen ${listenPort};
780
+ server_name _;
781
+ large_client_header_buffers 4 32k;
782
+
783
+ location / {
784
+ proxy_pass http://127.0.0.1:${effectiveNodePort};
785
+ proxy_http_version 1.1;
786
+ proxy_set_header Upgrade $http_upgrade;
787
+ proxy_set_header Connection 'upgrade';
788
+ proxy_set_header Host $host;
789
+ proxy_set_header X-Real-IP $remote_addr;
790
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
791
+ proxy_set_header X-Forwarded-Proto $scheme;
792
+ proxy_cache_bypass $http_upgrade;
793
+ proxy_read_timeout 60s;
794
+ proxy_buffer_size 128k;
795
+ proxy_buffers 4 256k;
796
+ proxy_busy_buffers_size 256k;
797
+ }
798
+ }`,
799
+ static: `server {
800
+ listen ${targetPort};
801
+ root ${outputPath};
802
+ index index.html;
803
+
804
+ location / {
805
+ autoindex off;
806
+ }
807
+
808
+ location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
809
+ expires 1h;
810
+ add_header Cache-Control "public, immutable";
811
+ }
812
+ }`,
813
+ };
814
+
815
+ const config = templates[nginxType];
816
+ if (!config) {
817
+ throw new Error(`sandbox deploy nginx: unknown nginxType "${nginxType}". Must be one of: spa, proxy, static`);
818
+ }
819
+
820
+ const cmd = [
821
+ resolveScript,
822
+ `sudo mkdir -p /etc/nginx/conf.d`,
823
+ `sudo tee /etc/nginx/conf.d/${configName || project}.conf > /dev/null << 'NGINX_EOF'`,
824
+ config,
825
+ `NGINX_EOF`,
826
+ `# Resolve symlinks for chmod (chmod does not follow symlinks on Linux)`,
827
+ `chmod -R o+rX "$REAL_PROJECT" 2>/dev/null || true`,
828
+ `find "$REAL_PROJECT" -type d -exec chmod o+x {} \\; 2>/dev/null || true`,
829
+ `find "$REAL_PROJECT" -type f -path "*/node_modules/.bin/*" -exec chmod +x {} \\; 2>/dev/null || true`,
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`,
831
+ ].join('\n');
832
+
833
+ const result = await execOneShot(workspaceId, cmd, username, timeoutMs);
834
+
835
+ let tunnelActive = false;
836
+ try {
837
+ const tunnelCheck = await execOneShot(
838
+ workspaceId,
839
+ 'devbridge list -j 2>/dev/null | grep -q \'"tunnelId"\' && echo "ACTIVE" || echo "INACTIVE"',
840
+ username,
841
+ 10000,
842
+ );
843
+ tunnelActive = String(tunnelCheck.stdout || '').includes('ACTIVE');
844
+ } catch {}
845
+
846
+ return {
847
+ ok: result.exitCode === 0,
848
+ nginxType,
849
+ port: targetPort,
850
+ nodePort: effectiveNodePort || undefined,
851
+ outputPath,
852
+ projectPath,
853
+ exitCode: result.exitCode,
854
+ stdout: result.stdout,
855
+ nextStep: 'expose_via_devbridge',
856
+ warning:
857
+ (!tunnelActive
858
+ ? 'No active DevBridge tunnel — deployment is incomplete. Proceed to Step 7 to expose the app.'
859
+ : portWarning) || undefined,
860
+ };
861
+ }
862
+
863
+ export async function deployCheck(
864
+ workspaceId,
865
+ { port, project, outputDir, frameworkType },
866
+ username = 'root',
867
+ timeoutMs = 30000,
868
+ ) {
869
+ if (!workspaceId) {
870
+ throw new Error('sandbox deploy check: workspace_id is required.');
871
+ }
872
+
873
+ const projectPath = `/workspace/${project}`;
874
+ const outputPath = outputDir.startsWith('/') ? outputDir : `${projectPath}/${outputDir}`;
875
+ const isCrossPlatform = frameworkType === 'cross-platform';
876
+
877
+ const checkScript = [
878
+ `echo "=== DEPLOY CHECK ==="`,
879
+ `PASS=0`,
880
+ `TOTAL=0`,
881
+ ``,
882
+ `TOTAL=$((TOTAL+1))`,
883
+ `if curl -s -o /dev/null -w "%{http_code}" http://localhost:${port} 2>/dev/null | grep -qE "^(2|3)"; then`,
884
+ ` echo "nginx_serving:PASS (port ${port})"`,
885
+ ` PASS=$((PASS+1))`,
886
+ `else`,
887
+ ` echo "nginx_serving:FAIL"`,
888
+ `fi`,
889
+ ``,
890
+ `TOTAL=$((TOTAL+1))`,
891
+ `if [ -d "${outputPath}" ] && ls -A "${outputPath}" 2>/dev/null | grep -q .; then`,
892
+ ` echo "output_dir:PASS (${outputPath})"`,
893
+ ` PASS=$((PASS+1))`,
894
+ `else`,
895
+ ` echo "output_dir:FAIL (${outputPath} empty or missing)"`,
896
+ `fi`,
897
+ ``,
898
+ `TOTAL=$((TOTAL+1))`,
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`,
916
+ ` echo "devbridge_tunnel:PASS"`,
917
+ ` PASS=$((PASS+1))`,
918
+ `else`,
919
+ ` echo "devbridge_tunnel:FAIL"`,
920
+ `fi`,
921
+ ``,
922
+ `TOTAL=$((TOTAL+1))`,
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`,
926
+ ` HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$TUNNEL_URL" 2>/dev/null || echo "000")`,
927
+ ` if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "304" ]; then`,
928
+ ` echo "tunnel_url_accessible:PASS ($TUNNEL_URL -> $HTTP_CODE)"`,
929
+ ` PASS=$((PASS+1))`,
930
+ ` else`,
931
+ ` echo "tunnel_url_accessible:FAIL ($TUNNEL_URL -> HTTP $HTTP_CODE)"`,
932
+ ` fi`,
933
+ `else`,
934
+ ` echo "tunnel_url_accessible:FAIL (no tunnel URL)"`,
935
+ `fi`,
936
+ ``,
937
+ `${`
938
+ TOTAL=$((TOTAL+1))
939
+ if [ -f "${outputPath}/qr.png" ]; then
940
+ echo "qr_code:PASS"
941
+ PASS=$((PASS+1))
942
+ else
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
970
+ fi
971
+ `}`,
972
+ `echo "SCORE:\${PASS}/\${TOTAL}"`,
973
+ `echo "TUNNEL_URL:\${TUNNEL_URL:-}"`,
974
+ `[ "\${PASS}" = "\${TOTAL}" ] && echo "VERDICT:COMPLETE" || echo "VERDICT:INCOMPLETE"`,
975
+ ]
976
+ .filter(Boolean)
977
+ .join('\n');
978
+
979
+ const result = await execOneShot(workspaceId, checkScript, username, timeoutMs);
980
+ const stdout = String(result.stdout || '');
981
+ const checks = {};
982
+ const lines = stdout.split('\n');
983
+ for (const line of lines) {
984
+ const m = line.match(/^(\w+):(\w+)\b(.*)/);
985
+ if (m) checks[m[1]] = { status: m[2], detail: (m[3] || '').trim() };
986
+ }
987
+ const scoreMatch = stdout.match(/SCORE:(\d+)\/(\d+)/);
988
+ const tunnelMatch = stdout.match(/TUNNEL_URL:(https:\/\/[^\s]+)/);
989
+ const complete = /VERDICT:COMPLETE/.test(stdout);
990
+
991
+ const missing = [];
992
+ if (!complete) {
993
+ for (const [key, val] of Object.entries(checks)) {
994
+ if (val.status === 'FAIL') missing.push(key);
995
+ }
996
+ }
997
+
998
+ return {
999
+ ok: true,
1000
+ complete,
1001
+ checkType: isCrossPlatform ? 'cross-platform' : 'standard',
1002
+ checks,
1003
+ score: scoreMatch ? { pass: parseInt(scoreMatch[1], 10), total: parseInt(scoreMatch[2], 10) } : null,
1004
+ publicUrl: tunnelMatch ? tunnelMatch[1] : undefined,
1005
+ missingSteps: missing.length > 0 ? missing.join(', ') : undefined,
1006
+ nextStep: !complete
1007
+ ? missing.includes('devbridge_tunnel') || missing.includes('tunnel_url_accessible')
1008
+ ? 'expose_via_devbridge'
1009
+ : missing.includes('nginx_serving')
1010
+ ? 'configure_nginx'
1011
+ : missing.includes('qr_code')
1012
+ ? 'generate_qr_code'
1013
+ : 'review_checks'
1014
+ : 'complete',
1015
+ };
1016
+ }
1017
+
603
1018
  export async function closeSession(workspaceId, username) {
604
1019
  const key = `${workspaceId}:${username}`;
605
1020
  const session = sessions.get(key);