huaweicloud-devkit 1.1.2-next.0 → 1.1.2-next.2

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 (125) hide show
  1. package/.agents/plugins/marketplace.json +18 -18
  2. package/README.md +182 -38
  3. package/README.zh-CN.md +180 -36
  4. package/bin/dsh-postinstall.cjs +77 -0
  5. package/bin/setup.cjs +7 -4
  6. package/cordis.patch.yml +13 -0
  7. package/integrations/atomcode/hooks/huaweicloud-telemetry.js +181 -0
  8. package/integrations/atomcode/hooks.json +9 -0
  9. package/integrations/dsh/hook-plugin.mjs +148 -0
  10. package/integrations/hermes/hooks/huaweicloud-telemetry.py +238 -0
  11. package/integrations/hermes/manifest.yaml +43 -0
  12. package/integrations/opencode/hooks/skill-tracker.js +115 -0
  13. package/integrations/opencode/opencode.json +1 -4
  14. package/integrations/workbuddy/hooks/telemetry-tracker.py +226 -0
  15. package/package.json +74 -43
  16. package/plugins/huaweicloud-core/.claude-plugin/plugin.json +4 -17
  17. package/plugins/huaweicloud-core/.codex-plugin/plugin.json +23 -33
  18. package/plugins/huaweicloud-core/.cursor-plugin/plugin.json +4 -17
  19. package/plugins/huaweicloud-core/.hermes-plugin/plugin.json +30 -0
  20. package/plugins/huaweicloud-core/.mcp.json +2 -5
  21. package/plugins/huaweicloud-core/.workbuddy-plugin/plugin.json +4 -18
  22. package/plugins/huaweicloud-core/assets/icon.png +0 -0
  23. package/plugins/huaweicloud-core/assets/logo-dark.png +0 -0
  24. package/plugins/huaweicloud-core/assets/logo.png +0 -0
  25. package/plugins/huaweicloud-core/hooks/huaweicloud-safety.py +104 -28
  26. package/plugins/huaweicloud-core/openclaw.plugin.json +20 -0
  27. package/plugins/huaweicloud-core/safety/policy.json +75 -99
  28. package/plugins/huaweicloud-core/safety/rules/cloud-risk-rules.json +196 -35
  29. package/plugins/huaweicloud-core/skills/huawei-apig/SKILL.md +38 -34
  30. package/plugins/huaweicloud-core/skills/huawei-billing/SKILL.md +21 -20
  31. package/plugins/huaweicloud-core/skills/huawei-cbr/SKILL.md +19 -19
  32. package/plugins/huaweicloud-core/skills/huawei-cce/SKILL.md +38 -32
  33. package/plugins/huaweicloud-core/skills/huawei-cloud-eye/SKILL.md +30 -30
  34. package/plugins/huaweicloud-core/skills/huawei-cts/SKILL.md +20 -20
  35. package/plugins/huaweicloud-core/skills/huawei-dds-dcs/SKILL.md +31 -31
  36. package/plugins/huaweicloud-core/skills/huawei-deployment/SKILL.md +21 -20
  37. package/plugins/huaweicloud-core/skills/huawei-dew/SKILL.md +40 -31
  38. package/plugins/huaweicloud-core/skills/huawei-dew/references/csms-usage.md +8 -1
  39. package/plugins/huaweicloud-core/skills/huawei-dew/references/kms-usage.md +6 -0
  40. package/plugins/huaweicloud-core/skills/huawei-ecs/SKILL.md +45 -37
  41. package/plugins/huaweicloud-core/skills/huawei-ecs/references/create-instance.md +57 -6
  42. package/plugins/huaweicloud-core/skills/huawei-ecs/references/flavors.md +11 -9
  43. package/plugins/huaweicloud-core/skills/huawei-ecs/references/hc-activity.md +27 -0
  44. package/plugins/huaweicloud-core/skills/huawei-ecs/references/troubleshooting.md +13 -13
  45. package/plugins/huaweicloud-core/skills/huawei-functiongraph/SKILL.md +44 -39
  46. package/plugins/huaweicloud-core/skills/huawei-functiongraph/references/apig-event-format.md +12 -10
  47. package/plugins/huaweicloud-core/skills/huawei-functiongraph/references/create-function.md +21 -20
  48. package/plugins/huaweicloud-core/skills/huawei-functiongraph/references/triggers.md +19 -19
  49. package/plugins/huaweicloud-core/skills/huawei-gaussdb/SKILL.md +20 -20
  50. package/plugins/huaweicloud-core/skills/huawei-getting-started/SKILL.md +27 -23
  51. package/plugins/huaweicloud-core/skills/huawei-iac/SKILL.md +93 -0
  52. package/plugins/huaweicloud-core/skills/huawei-iac/references/architectures.md +83 -0
  53. package/plugins/huaweicloud-core/skills/huawei-iac/references/resource-catalog.md +107 -0
  54. package/plugins/huaweicloud-core/skills/huawei-iac/references/state-file.md +104 -0
  55. package/plugins/huaweicloud-core/skills/huawei-iam/SKILL.md +30 -30
  56. package/plugins/huaweicloud-core/skills/huawei-iam/references/policy-examples.md +65 -39
  57. package/plugins/huaweicloud-core/skills/huawei-iam/references/quick-ref.md +9 -9
  58. package/plugins/huaweicloud-core/skills/huawei-modelarts/SKILL.md +33 -25
  59. package/plugins/huaweicloud-core/skills/huawei-obs/SKILL.md +46 -45
  60. package/plugins/huaweicloud-core/skills/huawei-obs/references/bucket-create.md +19 -19
  61. package/plugins/huaweicloud-core/skills/huawei-obs/references/bucket-lifecycle.md +12 -7
  62. package/plugins/huaweicloud-core/skills/huawei-obs/references/common-pitfalls.md +13 -12
  63. package/plugins/huaweicloud-core/skills/huawei-obs/references/single-file-share.md +4 -4
  64. package/plugins/huaweicloud-core/skills/huawei-rds/SKILL.md +71 -61
  65. package/plugins/huaweicloud-core/skills/huawei-sandbox/SKILL.md +776 -44
  66. package/plugins/huaweicloud-core/skills/huawei-sandbox/references/framework-commands.md +84 -0
  67. package/plugins/huaweicloud-core/skills/huawei-sandbox/references/nginx-templates.md +100 -0
  68. package/plugins/huaweicloud-core/skills/huawei-smn-dms/SKILL.md +36 -29
  69. package/plugins/huaweicloud-core/skills/huawei-voucher/SKILL.md +57 -0
  70. package/plugins/huaweicloud-core/skills/huawei-vpc/SKILL.md +39 -39
  71. package/plugins/huaweicloud-core/skills/huawei-vpc/references/network.md +22 -15
  72. package/plugins/huaweicloud-core/skills/huawei-vpc/references/security-group.md +20 -19
  73. package/plugins/huaweicloud-core/skills/huawei-vpc/references/subnet.md +15 -15
  74. package/plugins/huaweicloud-core/skills/huawei-waf-aad/SKILL.md +23 -20
  75. package/plugins/huaweicloud-core/skills/huaweicloud-api-and-sdk/SKILL.md +13 -13
  76. package/plugins/huaweicloud-core/skills/huaweicloud-api-and-sdk/agents/openai.yaml +3 -3
  77. package/plugins/huaweicloud-core/skills/huaweicloud-capability-discovery/SKILL.md +21 -2
  78. package/plugins/huaweicloud-core/skills/huaweicloud-capability-discovery/agents/openai.yaml +3 -3
  79. package/plugins/huaweicloud-core/skills/huaweicloud-cli-and-auth/SKILL.md +30 -4
  80. package/plugins/huaweicloud-core/skills/huaweicloud-cli-and-auth/agents/openai.yaml +3 -3
  81. package/plugins/huaweicloud-core/skills/huaweicloud-core/SKILL.md +68 -54
  82. package/plugins/huaweicloud-core/skills/huaweicloud-core/agents/openai.yaml +3 -3
  83. package/plugins/huaweicloud-core/skills/huaweicloud-core/references/report-issue.md +6 -6
  84. package/plugins/huaweicloud-core/skills/huaweicloud-core/references/select.md +15 -1
  85. package/plugins/huaweicloud-core/skills/huaweicloud-safety/SKILL.md +0 -1
  86. package/plugins/huaweicloud-core/skills/huaweicloud-safety/agents/openai.yaml +3 -3
  87. package/plugins/huaweicloud-core/skills/huaweicloud-troubleshooting/SKILL.md +36 -34
  88. package/plugins/huaweicloud-core/skills/huaweicloud-troubleshooting/agents/openai.yaml +3 -3
  89. package/plugins/huaweicloud-core/src/auth/agent-registration.mjs +142 -5
  90. package/plugins/huaweicloud-core/src/auth/credentials.mjs +217 -5
  91. package/plugins/huaweicloud-core/src/auth/reconcile.mjs +174 -0
  92. package/plugins/huaweicloud-core/src/auth/service.mjs +57 -2
  93. package/plugins/huaweicloud-core/src/data/icons-manifest.v1.json +3517 -0
  94. package/plugins/huaweicloud-core/src/detect-framework.mjs +366 -0
  95. package/plugins/huaweicloud-core/src/hcloud-cli.mjs +203 -16
  96. package/plugins/huaweicloud-core/src/icon-library.mjs +176 -0
  97. package/plugins/huaweicloud-core/src/mcp-protocol.mjs +88 -0
  98. package/plugins/huaweicloud-core/src/mcp-server-remote.mjs +107 -0
  99. package/plugins/huaweicloud-core/src/mcp-server.mjs +154 -99
  100. package/plugins/huaweicloud-core/src/proxy/proxy-agent.mjs +20 -102
  101. package/plugins/huaweicloud-core/src/proxy/proxy-config.mjs +11 -4
  102. package/plugins/huaweicloud-core/src/risk-rule-engine.mjs +4 -1
  103. package/plugins/huaweicloud-core/src/safety-policy.mjs +123 -68
  104. package/plugins/huaweicloud-core/src/sandbox/hdkitservice-api.mjs +48 -12
  105. package/plugins/huaweicloud-core/src/sandbox/hwlink-api.mjs +25 -26
  106. package/plugins/huaweicloud-core/src/sandbox/sandbox-file-server.py +45 -0
  107. package/plugins/huaweicloud-core/src/sandbox/session-manager.mjs +943 -10
  108. package/plugins/huaweicloud-core/src/sandbox/uninstall-cleanup.mjs +50 -0
  109. package/plugins/huaweicloud-core/src/search-market.mjs +49 -14
  110. package/plugins/huaweicloud-core/src/setup-cli.mjs +3091 -498
  111. package/plugins/huaweicloud-core/src/telemetry/agent-detect.mjs +40 -0
  112. package/plugins/huaweicloud-core/src/telemetry/agent-registry.mjs +328 -0
  113. package/plugins/huaweicloud-core/src/telemetry/telemetry.mjs +378 -0
  114. package/plugins/huaweicloud-core/src/tools.mjs +1272 -146
  115. package/plugins/huaweicloud-core/src/update-check.mjs +383 -0
  116. package/plugins/huaweicloud-core/src/ws-exec/hwlink-exec-client.js +72 -38
  117. package/plugins/huaweicloud-core/src/ws-exec/hwlink-fair-queue.js +10 -5
  118. package/plugins/huaweicloud-core/src/ws-exec/hwlink-multiplexer.js +13 -16
  119. package/plugins/huaweicloud-core/src/ws-exec/hwlink-packet.js +12 -16
  120. package/plugins/huaweicloud-core/src/ws-exec/hwlink-terminal-channel.js +46 -31
  121. package/plugins/huaweicloud-core/src/ws-exec/hwlink-tunnel-channel.mjs +268 -0
  122. package/plugins/huaweicloud-core/src/ws-exec/index.js +3 -0
  123. package/plugins/huaweicloud-core/src/ws-exec/ws-exec-client.js +40 -13
  124. package/plugins/huaweicloud-core/skills/huawei-cloud-find-skills/SKILL.md +0 -190
  125. package/plugins/huaweicloud-core/skills/huawei-cloud-find-skills/scripts/search-skills.mjs +0 -124
@@ -1,13 +1,43 @@
1
- import { spawn } from 'node:child_process';
2
- import { join, dirname } from 'node:path';
1
+ import { spawn, execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { createConnection as netConnect } from 'node:net';
4
+ import {
5
+ existsSync,
6
+ readFileSync,
7
+ statSync,
8
+ mkdirSync,
9
+ rmSync,
10
+ createReadStream,
11
+ appendFileSync,
12
+ unlinkSync,
13
+ } from 'node:fs';
14
+ import { createHash, randomBytes } from 'node:crypto';
15
+ import { join, dirname, basename } from 'node:path';
16
+ import { tmpdir } from 'node:os';
3
17
  import { fileURLToPath, pathToFileURL } from 'node:url';
18
+
4
19
  import { createConnection, getCredentials } from './hwlink-api.mjs';
5
20
  import { getWebSocketImpl } from '../proxy/proxy-agent.mjs';
21
+ import { trackSandboxConnect, trackSandboxDisconnect } from '../telemetry/telemetry.mjs';
22
+
23
+ const execFileAsync = promisify(execFile);
6
24
 
7
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
26
  export const WS_EXEC_INDEX_URL = pathToFileURL(join(__dirname, '..', 'ws-exec', 'index.js')).href;
9
27
 
10
- const DEFAULT_WORKSPACE_ID = process.env.HW_WORKSPACE_ID || '0107bd9997aa4287bd2b4890b49af07d';
28
+ let currentWorkspaceId = process.env.HW_WORKSPACE_ID || null;
29
+
30
+ function getCurrentWorkspaceId() {
31
+ return currentWorkspaceId;
32
+ }
33
+
34
+ function setWorkspaceId(id) {
35
+ if (id && id !== currentWorkspaceId) {
36
+ trackSandboxConnect();
37
+ }
38
+ currentWorkspaceId = id;
39
+ process.env.HW_WORKSPACE_ID = id;
40
+ }
11
41
 
12
42
  function resolveEnv() {
13
43
  const env = { ...process.env };
@@ -21,8 +51,12 @@ async function runNodeExec(args, timeoutMs = 30000) {
21
51
  const proc = spawn('node', args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
22
52
  let stdout = '';
23
53
  let stderr = '';
24
- proc.stdout.on('data', (d) => { stdout += d.toString(); });
25
- proc.stderr.on('data', (d) => { stderr += d.toString(); });
54
+ proc.stdout.on('data', (d) => {
55
+ stdout += d.toString();
56
+ });
57
+ proc.stderr.on('data', (d) => {
58
+ stderr += d.toString();
59
+ });
26
60
 
27
61
  const timer = setTimeout(() => {
28
62
  proc.kill();
@@ -56,7 +90,7 @@ async function getSession(workspaceId, username, timeoutMs) {
56
90
  const { ak, sk, securitytoken } = getCredentials();
57
91
  const { wsUrl, source } = await createConnection(workspaceId, ak, sk, securitytoken);
58
92
 
59
- const WebSocketImpl = getWebSocketImpl(wsUrl);
93
+ const WebSocketImpl = await getWebSocketImpl(wsUrl);
60
94
 
61
95
  const { connectHwlinkTerminalSession } = await import(WS_EXEC_INDEX_URL);
62
96
  const session = await connectHwlinkTerminalSession({
@@ -71,11 +105,50 @@ async function getSession(workspaceId, username, timeoutMs) {
71
105
  return session;
72
106
  }
73
107
 
108
+ async function createTunnelSession(workspaceId, username, timeoutMs = 30000) {
109
+ const { ak, sk, securitytoken } = getCredentials();
110
+ const { wsUrl, source } = await createConnection(workspaceId, ak, sk, securitytoken);
111
+ const WebSocketImpl = await getWebSocketImpl(wsUrl);
112
+
113
+ const { HwlinkWebSocketMultiplexer } = await import(WS_EXEC_INDEX_URL);
114
+ const mux = new HwlinkWebSocketMultiplexer(wsUrl, source, { WebSocketImpl, protocol: 'devenv' });
115
+
116
+ await new Promise((resolve, reject) => {
117
+ const timer = setTimeout(() => {
118
+ clearInterval(interval);
119
+ reject(new Error('tunnel session WebSocket open timeout'));
120
+ }, timeoutMs);
121
+ const interval = setInterval(() => {
122
+ if (mux.readyState === 1) {
123
+ clearTimeout(timer);
124
+ clearInterval(interval);
125
+ resolve();
126
+ } else if (mux.readyState === 3) {
127
+ clearTimeout(timer);
128
+ clearInterval(interval);
129
+ reject(new Error('tunnel session WebSocket closed'));
130
+ }
131
+ }, 100);
132
+ mux.onClose = () => {
133
+ clearTimeout(timer);
134
+ clearInterval(interval);
135
+ reject(new Error('tunnel session WebSocket closed'));
136
+ };
137
+ mux.onError = (err) => {
138
+ clearTimeout(timer);
139
+ clearInterval(interval);
140
+ reject(err);
141
+ };
142
+ });
143
+
144
+ return { mux, close: () => mux.close() };
145
+ }
146
+
74
147
  export async function execOneShot(workspaceId, command, username, timeoutMs) {
75
148
  const { ak, sk, securitytoken } = getCredentials();
76
149
  const { wsUrl, source } = await createConnection(workspaceId, ak, sk, securitytoken);
77
150
 
78
- const WebSocketImpl = getWebSocketImpl(wsUrl);
151
+ const WebSocketImpl = await getWebSocketImpl(wsUrl);
79
152
 
80
153
  const { executeHwlinkCommand } = await import(WS_EXEC_INDEX_URL);
81
154
  return await executeHwlinkCommand({
@@ -93,20 +166,880 @@ export async function execWithSession(workspaceId, command, username, timeoutMs)
93
166
  return await session.exec(command, { timeoutMs });
94
167
  }
95
168
 
169
+ export const UPLOAD_CHUNK_SIZE = 30000;
170
+
171
+ export const UPLOAD_BATCH_SIZE = 2;
172
+
173
+ export const UPLOAD_MAX_RETRIES = 3;
174
+
175
+ export function splitBase64Chunks(base64, chunkSize = UPLOAD_CHUNK_SIZE) {
176
+ const chunks = [];
177
+ for (let offset = 0; offset < base64.length; offset += chunkSize) {
178
+ chunks.push(base64.slice(offset, offset + chunkSize));
179
+ }
180
+ return chunks;
181
+ }
182
+
183
+ export async function uploadFileWithSession(workspaceId, localPath, remotePath, username = 'root', timeoutMs = 30000) {
184
+ if (!existsSync(localPath)) {
185
+ throw new Error(`sandbox upload: local file not found: ${localPath}`);
186
+ }
187
+ if (!statSync(localPath).isFile()) {
188
+ throw new Error(`sandbox upload: path is not a regular file: ${localPath}`);
189
+ }
190
+ const content = readFileSync(localPath);
191
+ const base64 = content.toString('base64');
192
+ const expectedMd5 = createHash('md5').update(content).digest('hex');
193
+ const chunks = splitBase64Chunks(base64);
194
+ const tmp = `${remotePath}.b64tmp`;
195
+
196
+ const reset = await execWithSession(workspaceId, `rm -f "${tmp}"`, username, timeoutMs);
197
+ if (reset.exitCode !== 0) {
198
+ throw new Error(`sandbox upload: failed to reset temp file: ${reset.stdout || reset.error || reset.exitCode}`);
199
+ }
200
+
201
+ for (let batchStart = 0; batchStart < chunks.length; batchStart += UPLOAD_BATCH_SIZE) {
202
+ const batch = chunks.slice(batchStart, batchStart + UPLOAD_BATCH_SIZE);
203
+ const combinedChunk = batch.join('');
204
+ const batchNum = Math.floor(batchStart / UPLOAD_BATCH_SIZE) + 1;
205
+ const totalBatches = Math.ceil(chunks.length / UPLOAD_BATCH_SIZE);
206
+ const cmd = `printf '%s' '${combinedChunk}' >> "${tmp}"`;
207
+
208
+ let batchOk = false;
209
+ let lastError;
210
+ for (let retry = 0; retry < UPLOAD_MAX_RETRIES; retry++) {
211
+ const res = await execWithSession(workspaceId, cmd, username, timeoutMs);
212
+ if (res.exitCode === 0) {
213
+ batchOk = true;
214
+ break;
215
+ }
216
+ lastError = res.stdout || res.error || res.exitCode;
217
+ console.error(` upload retry ${retry + 1}/${UPLOAD_MAX_RETRIES} for batch ${batchNum}/${totalBatches}`);
218
+ await new Promise((r) => setTimeout(r, 2000));
219
+ }
220
+ if (!batchOk) {
221
+ throw new Error(`sandbox upload: failed writing batch ${batchNum}/${totalBatches}: ${lastError}`);
222
+ }
223
+ if (batchNum % 10 === 0 || batchNum === totalBatches) {
224
+ console.error(` upload progress: batch ${batchNum}/${totalBatches}`);
225
+ }
226
+ }
227
+
228
+ const decode = await execWithSession(
229
+ workspaceId,
230
+ `base64 -d "${tmp}" > "${remotePath}" && rm -f "${tmp}"`,
231
+ username,
232
+ timeoutMs,
233
+ );
234
+ if (decode.exitCode !== 0) {
235
+ throw new Error(
236
+ `sandbox upload: failed decoding to ${remotePath}: ${decode.stdout || decode.error || decode.exitCode}`,
237
+ );
238
+ }
239
+
240
+ const verify = await execWithSession(workspaceId, `md5sum "${remotePath}"`, username, timeoutMs);
241
+ let md5Verified = false;
242
+ if (verify.exitCode === 0) {
243
+ const remoteMd5 = String(verify.stdout || '')
244
+ // eslint-disable-next-line no-control-regex
245
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
246
+ // eslint-disable-next-line no-control-regex
247
+ .replace(/\x1b\][^\x07]*\x07/g, '')
248
+ .trim()
249
+ .split(/\s+/)[0];
250
+ md5Verified = remoteMd5 === expectedMd5;
251
+ if (!md5Verified) {
252
+ throw new Error(
253
+ `sandbox upload: md5 mismatch for ${remotePath} (expected ${expectedMd5}, got ${remoteMd5 || 'none'})`,
254
+ );
255
+ }
256
+ }
257
+
258
+ return {
259
+ ok: true,
260
+ localPath,
261
+ remotePath,
262
+ bytes: content.length,
263
+ chunks: chunks.length,
264
+ md5: expectedMd5,
265
+ md5Verified,
266
+ };
267
+ }
268
+
269
+ const SANDBOX_FILE_SERVER_SCRIPT = readFileSync(join(__dirname, 'sandbox-file-server.py'), 'utf8');
270
+
271
+ const TUNNEL_READY_TIMEOUT_MS = 30000;
272
+ const SERVER_HEALTH_MAX_RETRIES = 30;
273
+ const SERVER_HEALTH_INTERVAL_MS = 1000;
274
+ const UPLOAD_LOG_PATH = join(tmpdir(), 'sandbox-upload.log');
275
+
276
+ function uploadLog(message) {
277
+ const ts = new Date().toISOString();
278
+ const line = `[${ts}] ${message}\n`;
279
+ console.error(line.trimEnd());
280
+ try {
281
+ appendFileSync(UPLOAD_LOG_PATH, line);
282
+ } catch {}
283
+ }
284
+
285
+ function rotateUploadLog(maxBytes = 100 * 1024) {
286
+ try {
287
+ if (existsSync(UPLOAD_LOG_PATH)) {
288
+ const stat = statSync(UPLOAD_LOG_PATH);
289
+ if (stat.size > maxBytes) {
290
+ unlinkSync(UPLOAD_LOG_PATH);
291
+ }
292
+ }
293
+ } catch {}
294
+ }
295
+
296
+ function generateUploadToken() {
297
+ return randomBytes(16).toString('hex');
298
+ }
299
+
300
+ async function createTarGz(localDir, exclude = []) {
301
+ const archiveName = `${basename(localDir)}.tar.gz`;
302
+ const archiveDir = join(tmpdir(), `sandbox-upload-${Date.now()}`);
303
+ mkdirSync(archiveDir, { recursive: true });
304
+ const archivePath = join(archiveDir, archiveName);
305
+
306
+ const hasGit = existsSync(join(localDir, '.git'));
307
+ if (hasGit) {
308
+ await execFileAsync('git', [
309
+ '-C',
310
+ localDir,
311
+ 'archive',
312
+ '--format=tar.gz',
313
+ `--prefix=${basename(localDir)}/`,
314
+ `--output=${archivePath}`,
315
+ 'HEAD',
316
+ ]);
317
+ } else {
318
+ const args = [];
319
+ for (const pattern of exclude) {
320
+ if (pattern.startsWith('**/')) {
321
+ const base = pattern.slice(3);
322
+ for (let depth = 0; depth <= 4; depth++) {
323
+ const prefix = depth === 0 ? '' : '*/'.repeat(depth);
324
+ args.push('--exclude', `${prefix}${base}`);
325
+ }
326
+ } else {
327
+ args.push('--exclude', pattern);
328
+ }
329
+ }
330
+ args.push('-czf', archivePath, '-C', dirname(localDir), basename(localDir));
331
+ await execFileAsync('tar', args);
332
+ }
333
+
334
+ return archivePath;
335
+ }
336
+
337
+ async function computeMd5(filePath) {
338
+ return new Promise((resolve, reject) => {
339
+ const hash = createHash('md5');
340
+ createReadStream(filePath)
341
+ .on('data', (chunk) => hash.update(chunk))
342
+ .on('end', () => resolve(hash.digest('hex')))
343
+ .on('error', reject);
344
+ });
345
+ }
346
+
347
+ function cleanupLocalArchive(archivePath) {
348
+ try {
349
+ rmSync(dirname(archivePath), { recursive: true, force: true });
350
+ } catch {}
351
+ }
352
+
353
+ async function deployFileServer(workspaceId, username, port = 8888, token = '') {
354
+ const scriptPath = '/tmp/sandbox-file-server.py';
355
+ const pidFile = '/tmp/sandbox-file-server.pid';
356
+ uploadLog(`deployFileServer: killing old server (pidFile=${pidFile})`);
357
+ await execWithSession(workspaceId, `kill $(cat ${pidFile} 2>/dev/null) 2>/dev/null; rm -f ${pidFile}`, username);
358
+ const b64 = Buffer.from(SANDBOX_FILE_SERVER_SCRIPT).toString('base64');
359
+ uploadLog(`deployFileServer: writing script (${b64.length} b64 chars)`);
360
+ await execWithSession(workspaceId, `echo '${b64}' | base64 -d > ${scriptPath}`, username);
361
+ const cmd = token
362
+ ? `python3 ${scriptPath} ${port} ${token} & echo $! > ${pidFile}`
363
+ : `python3 ${scriptPath} ${port} & echo $! > ${pidFile}`;
364
+ uploadLog(`deployFileServer: starting server on port ${port}`);
365
+ const startResult = await execWithSession(workspaceId, cmd, username);
366
+ uploadLog(
367
+ `deployFileServer: start result exitCode=${startResult.exitCode} stdout=${JSON.stringify(startResult.stdout?.slice(0, 200))}`,
368
+ );
369
+ return startResult;
370
+ }
371
+
372
+ async function uploadViaTunnel(localPort, archivePath, archiveSize, archiveRemotePath, uploadToken, timeoutMs) {
373
+ const archiveBuffer = readFileSync(archivePath);
374
+ const headers = [
375
+ 'POST /upload HTTP/1.1',
376
+ 'Host: localhost',
377
+ 'Content-Type: application/octet-stream',
378
+ `Content-Length: ${archiveSize}`,
379
+ `X-Target-Path: ${archiveRemotePath}`,
380
+ `X-Upload-Token: ${uploadToken}`,
381
+ 'Connection: close',
382
+ '',
383
+ '',
384
+ ].join('\r\n');
385
+ return new Promise((resolve, reject) => {
386
+ let settled = false;
387
+ const done = (fn) => {
388
+ if (!settled) {
389
+ settled = true;
390
+ clearTimeout(timer);
391
+ sock.destroy();
392
+ fn();
393
+ }
394
+ };
395
+ const timer = setTimeout(() => done(() => reject(new Error(`upload timeout after ${timeoutMs}ms`))), timeoutMs);
396
+ const sock = netConnect({ host: '127.0.0.1', port: localPort }, () => {
397
+ sock.write(headers);
398
+ sock.write(archiveBuffer);
399
+ });
400
+ let respBuf = Buffer.alloc(0);
401
+ let contentLength = -1;
402
+ let headerEnd = -1;
403
+ sock.on('data', (chunk) => {
404
+ respBuf = Buffer.concat([respBuf, chunk]);
405
+ if (contentLength < 0) {
406
+ const resp = respBuf.toString();
407
+ headerEnd = resp.indexOf('\r\n\r\n');
408
+ if (headerEnd > 0) {
409
+ const headerBlock = resp.slice(0, headerEnd);
410
+ const clMatch = headerBlock.match(/Content-Length:\s*(\d+)/i);
411
+ if (clMatch) contentLength = parseInt(clMatch[1], 10);
412
+ }
413
+ }
414
+ if (contentLength >= 0 && headerEnd > 0) {
415
+ const bodyReceived = respBuf.length - (headerEnd + 4);
416
+ if (bodyReceived >= contentLength) {
417
+ done(() => {
418
+ const resp = respBuf.toString();
419
+ const statusLine = resp.split('\r\n')[0] || '';
420
+ const statusCode = parseInt(statusLine.split(' ')[1], 10);
421
+ const body = resp.slice(headerEnd + 4, headerEnd + 4 + contentLength);
422
+ if (!statusCode || statusCode < 200 || statusCode >= 300) {
423
+ uploadLog(`uploadViaTunnel: POST failed with HTTP ${statusCode}: ${body.slice(0, 200)}`);
424
+ reject(new Error(`upload HTTP ${statusCode}: ${body}`));
425
+ return;
426
+ }
427
+ try {
428
+ resolve(JSON.parse(body));
429
+ } catch (error) {
430
+ reject(new Error(`invalid JSON response: ${body.slice(0, 200)}`));
431
+ }
432
+ });
433
+ }
434
+ }
435
+ });
436
+ sock.on('close', () => {
437
+ done(() => {
438
+ const resp = respBuf.toString();
439
+ const statusLine = resp.split('\r\n')[0] || '';
440
+ const statusCode = parseInt(statusLine.split(' ')[1], 10);
441
+ const he = resp.indexOf('\r\n\r\n');
442
+ const body = he > 0 ? resp.slice(he + 4) : '';
443
+ if (!statusCode || statusCode < 200 || statusCode >= 300) {
444
+ uploadLog(`uploadViaTunnel: POST failed with HTTP ${statusCode}: ${body.slice(0, 200)}`);
445
+ reject(new Error(`upload HTTP ${statusCode}: ${body}`));
446
+ return;
447
+ }
448
+ try {
449
+ resolve(JSON.parse(body));
450
+ } catch (error) {
451
+ reject(new Error(`invalid JSON response: ${body.slice(0, 200)}`));
452
+ }
453
+ });
454
+ });
455
+ sock.on('error', (err) => {
456
+ done(() => {
457
+ uploadLog(`uploadViaTunnel: socket error: ${err.message}`);
458
+ reject(err);
459
+ });
460
+ });
461
+ });
462
+ }
463
+
464
+ async function waitForServerReady(localPort) {
465
+ for (let i = 0; i < SERVER_HEALTH_MAX_RETRIES; i++) {
466
+ try {
467
+ uploadLog(`waitForServerReady: attempt ${i + 1}, checking http://localhost:${localPort}/health`);
468
+ const ok = await new Promise((resolve) => {
469
+ let settled = false;
470
+ const done = (val) => {
471
+ if (!settled) {
472
+ settled = true;
473
+ sock.destroy();
474
+ resolve(val);
475
+ }
476
+ };
477
+ const sock = netConnect({ host: '127.0.0.1', port: localPort }, () => {
478
+ sock.write('GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n');
479
+ });
480
+ let resp = '';
481
+ sock.on('data', (c) => {
482
+ resp += c.toString();
483
+ if (resp.includes('200 OK')) done(true);
484
+ });
485
+ sock.on('close', () => done(resp.includes('200 OK')));
486
+ sock.on('error', () => done(false));
487
+ setTimeout(() => done(false), 3000);
488
+ });
489
+ if (ok) {
490
+ uploadLog(`waitForServerReady: server ready on port ${localPort} after ${i} retries`);
491
+ return;
492
+ }
493
+ uploadLog(`waitForServerReady: health check returned non-200 (retry ${i + 1})`);
494
+ } catch (error) {
495
+ uploadLog(`waitForServerReady: health check failed: ${error.message} (retry ${i + 1})`);
496
+ }
497
+ await new Promise((r) => setTimeout(r, SERVER_HEALTH_INTERVAL_MS));
498
+ }
499
+ throw new Error(
500
+ `sandbox file server not ready after ${SERVER_HEALTH_MAX_RETRIES * SERVER_HEALTH_INTERVAL_MS}ms (log: ${UPLOAD_LOG_PATH})`,
501
+ );
502
+ }
503
+
504
+ async function cleanupFileServer(workspaceId, username) {
505
+ const pidFile = '/tmp/sandbox-file-server.pid';
506
+ const scriptPath = '/tmp/sandbox-file-server.py';
507
+ await execWithSession(workspaceId, `kill $(cat ${pidFile}) 2>/dev/null; rm -f ${pidFile} ${scriptPath}`, username);
508
+ }
509
+
510
+ async function uploadViaHttpTunnel(workspaceId, archivePath, archiveRemotePath, username, timeoutMs, options) {
511
+ const sandboxPort = options.sandboxPort || 8888;
512
+ const uploadToken = generateUploadToken();
513
+ const archiveSize = statSync(archivePath).size;
514
+
515
+ uploadLog(
516
+ `uploadViaHttpTunnel: start (archive=${archivePath}, size=${archiveSize}, remotePath=${archiveRemotePath})`,
517
+ );
518
+
519
+ uploadLog(`uploadViaHttpTunnel: deploying file server on sandbox port ${sandboxPort}`);
520
+ await deployFileServer(workspaceId, username, sandboxPort, uploadToken);
521
+
522
+ uploadLog(`uploadViaHttpTunnel: creating dedicated tunnel session`);
523
+ const tunnelSession = await createTunnelSession(workspaceId, username);
524
+
525
+ uploadLog(`uploadViaHttpTunnel: creating tunnel channel (localPort=0, remotePort=${sandboxPort})`);
526
+ const { HwlinkTunnelChannel } = await import(WS_EXEC_INDEX_URL);
527
+ const tunnel = new HwlinkTunnelChannel({
528
+ localPort: 0,
529
+ remotePort: sandboxPort,
530
+ });
531
+ tunnel.attach(tunnelSession.mux);
532
+
533
+ uploadLog(`uploadViaHttpTunnel: waiting for tunnel ready (timeout=${TUNNEL_READY_TIMEOUT_MS}ms)`);
534
+ try {
535
+ await Promise.race([
536
+ tunnel.ready,
537
+ new Promise((_, reject) =>
538
+ setTimeout(
539
+ () => reject(new Error(`tunnel ready timeout after ${TUNNEL_READY_TIMEOUT_MS}ms`)),
540
+ TUNNEL_READY_TIMEOUT_MS,
541
+ ),
542
+ ),
543
+ ]);
544
+ uploadLog(`uploadViaHttpTunnel: tunnel ready, localPort=${tunnel.localPort}`);
545
+ } catch (tunnelReadyError) {
546
+ uploadLog(`uploadViaHttpTunnel: TUNNEL READY FAILED: ${tunnelReadyError.message}`);
547
+ tunnel.close();
548
+ throw new Error(
549
+ `HTTP tunnel failed to establish: ${tunnelReadyError.message}. ` +
550
+ `This means the WebSocket port-forwarding channel to sandbox port ${sandboxPort} could not be opened. ` +
551
+ `Common causes: (1) Python file server not running on sandbox, (2) sandbox port ${sandboxPort} blocked, ` +
552
+ `(3) hwlink multiplexer channel rejected. ` +
553
+ `Diagnostic log: ${UPLOAD_LOG_PATH}`,
554
+ { cause: tunnelReadyError },
555
+ );
556
+ }
557
+
558
+ try {
559
+ uploadLog(`uploadViaHttpTunnel: waiting for server health on localhost:${tunnel.localPort}`);
560
+ await waitForServerReady(tunnel.localPort);
561
+
562
+ uploadLog(`uploadViaHttpTunnel: sending POST with ${archiveSize} bytes`);
563
+ const result = await uploadViaTunnel(
564
+ tunnel.localPort,
565
+ archivePath,
566
+ archiveSize,
567
+ archiveRemotePath,
568
+ uploadToken,
569
+ timeoutMs,
570
+ );
571
+ uploadLog(`uploadViaHttpTunnel: upload complete (bytes=${result.bytes}, md5=${result.md5})`);
572
+ return result;
573
+ } catch (uploadError) {
574
+ uploadLog(`uploadViaHttpTunnel: UPLOAD FAILED: ${uploadError.message}`);
575
+ throw uploadError;
576
+ } finally {
577
+ tunnel.close();
578
+ tunnelSession.close();
579
+ }
580
+ }
581
+
582
+ export async function uploadProjectWithSession(
583
+ workspaceId,
584
+ localDir,
585
+ remoteDir,
586
+ username = 'root',
587
+ timeoutMs = 300000,
588
+ options = {},
589
+ ) {
590
+ if (!workspaceId) {
591
+ throw new Error(
592
+ 'sandbox upload project: workspace_id is required. ' +
593
+ 'Set HW_WORKSPACE_ID env var or ensure huaweicloud_sandbox_connect was called first.',
594
+ );
595
+ }
596
+ if (!existsSync(localDir)) {
597
+ throw new Error(`sandbox upload project: local directory not found: ${localDir}`);
598
+ }
599
+ if (!statSync(localDir).isDirectory()) {
600
+ throw new Error(`sandbox upload project: path is not a directory: ${localDir}`);
601
+ }
602
+
603
+ rotateUploadLog();
604
+
605
+ const projectName = basename(localDir);
606
+ const targetParentDir = remoteDir || '/workspace';
607
+ const archiveRemotePath = `${targetParentDir}/${projectName}.tar.gz`;
608
+
609
+ const archivePath = await createTarGz(localDir, options.exclude);
610
+ const archiveSize = statSync(archivePath).size;
611
+ const expectedMd5 = await computeMd5(archivePath);
612
+
613
+ uploadLog(`uploadProject: ${localDir} -> ${archiveRemotePath} (archive=${archiveSize} bytes, md5=${expectedMd5})`);
614
+
615
+ const SIZE_50MB = 50 * 1024 * 1024;
616
+ if (archiveSize > SIZE_50MB) {
617
+ uploadLog(
618
+ `uploadProject: archive size ${(archiveSize / (1024 * 1024)).toFixed(1)}MB exceeds 50MB. ` +
619
+ `Dependencies or platform binaries may have been included. ` +
620
+ `Ensure exclude list contains "**/node_modules" to match all nesting levels.`,
621
+ );
622
+ }
623
+
624
+ let result;
625
+ let tunnelError;
626
+ for (let attempt = 0; attempt < UPLOAD_MAX_RETRIES; attempt++) {
627
+ try {
628
+ await cleanupFileServer(workspaceId, username).catch(() => {});
629
+ result = await uploadViaHttpTunnel(workspaceId, archivePath, archiveRemotePath, username, timeoutMs, options);
630
+ tunnelError = null;
631
+ break;
632
+ } catch (error) {
633
+ tunnelError = error;
634
+ const errorType = error.code || error.name || 'unknown';
635
+ uploadLog(`uploadProject: attempt ${attempt + 1}/${UPLOAD_MAX_RETRIES} failed [${errorType}]: ${error.message}`);
636
+ await new Promise((r) => setTimeout(r, 5000));
637
+ }
638
+ }
639
+ if (tunnelError) {
640
+ uploadLog(`uploadProject: all ${UPLOAD_MAX_RETRIES} attempts failed: ${tunnelError.message}`);
641
+ uploadLog(`uploadProject: NOT falling back to base64 (removed). Rethrowing with diagnostics.`);
642
+ cleanupLocalArchive(archivePath);
643
+ throw new Error(
644
+ `sandbox upload failed after ${UPLOAD_MAX_RETRIES} attempts: HTTP tunnel could not transfer the project archive. ` +
645
+ `Archive size: ${(archiveSize / 1024).toFixed(1)}KB. ` +
646
+ `Root cause: ${tunnelError.message}. ` +
647
+ `Diagnostic log: ${UPLOAD_LOG_PATH}`,
648
+ { cause: tunnelError },
649
+ );
650
+ }
651
+
652
+ if (options.verify !== false && result.md5 && result.md5 !== expectedMd5) {
653
+ throw new Error(`md5 mismatch: expected ${expectedMd5}, got ${result.md5}`);
654
+ }
655
+
656
+ if (options.extract !== false) {
657
+ await execWithSession(
658
+ workspaceId,
659
+ `mkdir -p "${targetParentDir}" && tar -xzf "${archiveRemotePath}" -C "${targetParentDir}" && rm -f "${archiveRemotePath}"`,
660
+ username,
661
+ timeoutMs,
662
+ );
663
+ try {
664
+ await execWithSession(
665
+ workspaceId,
666
+ [
667
+ `REAL_PATH=$(readlink -f "${targetParentDir}/${projectName}" 2>/dev/null || echo "${targetParentDir}/${projectName}")`,
668
+ `if [ ! -d "$REAL_PATH" ] && [ -d "${targetParentDir}" ]; then`,
669
+ ` REAL_PATH="${targetParentDir}/${projectName}"`,
670
+ `fi`,
671
+ `chmod -R o+rX "$REAL_PATH" 2>/dev/null || true`,
672
+ `find "$REAL_PATH" -type d -exec chmod o+x {} \\; 2>/dev/null || true`,
673
+ `find "$REAL_PATH" -type f -path "*/node_modules/.bin/*" -exec chmod +x {} \\; 2>/dev/null || true`,
674
+ ].join('\n'),
675
+ username,
676
+ 15000,
677
+ );
678
+ } catch {}
679
+ }
680
+
681
+ try {
682
+ await cleanupFileServer(workspaceId, username);
683
+ } catch {}
684
+ cleanupLocalArchive(archivePath);
685
+
686
+ return {
687
+ ok: true,
688
+ localDir,
689
+ remotePath: options.extract !== false ? `${targetParentDir}/${projectName}` : archiveRemotePath,
690
+ bytes: result.bytes || 0,
691
+ md5: result.md5 || expectedMd5,
692
+ md5Verified: result.md5 ? result.md5 === expectedMd5 : true,
693
+ extracted: options.extract !== false,
694
+ };
695
+ }
696
+
697
+ export async function deployNginx(
698
+ workspaceId,
699
+ { nginxType, port, project, outputDir, nodePort, publicPort, configName },
700
+ username = 'root',
701
+ timeoutMs = 60000,
702
+ ) {
703
+ if (!workspaceId) {
704
+ throw new Error('sandbox deploy nginx: workspace_id is required.');
705
+ }
706
+ if (!nginxType || !port || !project || !outputDir) {
707
+ throw new Error('sandbox deploy nginx: nginxType, port, project, and outputDir are required.');
708
+ }
709
+
710
+ const nginxCheck = await execOneShot(
711
+ workspaceId,
712
+ 'command -v nginx >/dev/null 2>&1 && echo "INSTALLED" || echo "MISSING"',
713
+ username,
714
+ 10000,
715
+ );
716
+ if (!String(nginxCheck.stdout || '').includes('INSTALLED')) {
717
+ throw new Error(
718
+ 'sandbox deploy nginx: nginx is not installed. Install it first:\n' +
719
+ ' Detect OS: source /etc/os-release && echo $ID\n' +
720
+ ' apt: sudo apt-get update -qq && sudo apt-get install -y -qq nginx\n' +
721
+ ' yum: sudo yum install -y nginx\n' +
722
+ ' dnf: sudo dnf install -y nginx\n' +
723
+ 'Alternatively, skip nginx and use Python HTTP server (see nginx-templates.md).',
724
+ );
725
+ }
726
+
727
+ const listenPort = publicPort || port;
728
+ const basePort = nginxType === 'proxy' ? listenPort : port;
729
+
730
+ let targetPort = basePort;
731
+ let portWarning;
732
+ const maxPortAttempts = 10;
733
+ for (let offset = 0; offset < maxPortAttempts; offset += 1) {
734
+ targetPort = basePort + offset;
735
+ try {
736
+ const portCheck = await execOneShot(
737
+ workspaceId,
738
+ `ss -tlnp 2>/dev/null | grep -q ":${targetPort} " && echo "IN_USE" || echo "FREE"`,
739
+ username,
740
+ 10000,
741
+ );
742
+ if (!String(portCheck.stdout || '').includes('IN_USE')) break;
743
+ if (offset === 0) {
744
+ portWarning = `Port ${basePort} is in use — auto-assigned port ${targetPort}`;
745
+ }
746
+ } catch {}
747
+ if (offset === maxPortAttempts - 1) {
748
+ throw new Error(
749
+ `sandbox deploy nginx: all ports ${basePort}-${basePort + maxPortAttempts - 1} are in use. Free a port and try again.`,
750
+ );
751
+ }
752
+ }
753
+
754
+ const effectiveNodePort =
755
+ nginxType === 'proxy' ? (nodePort && nodePort !== listenPort ? nodePort : listenPort + 1) : undefined;
756
+
757
+ const projectPath = `/workspace/${project}`;
758
+ const outputPath = outputDir.startsWith('/') ? outputDir : `${projectPath}/${outputDir}`;
759
+
760
+ const resolveScript = `REAL_PROJECT=$(readlink -f "${projectPath}" 2>/dev/null || echo "${projectPath}")
761
+ REAL_OUTPUT="${outputPath}"
762
+ if [ "\${REAL_PROJECT}" != "${projectPath}" ]; then
763
+ REL_OUTPUT=$(echo "${outputDir}" | sed "s|${projectPath}/||")
764
+ REAL_OUTPUT="\${REAL_PROJECT}/\${REL_OUTPUT}"
765
+ fi`;
766
+
767
+ const templates = {
768
+ spa: `server {
769
+ listen ${targetPort};
770
+ root ${outputPath};
771
+ index index.html;
772
+
773
+ location / {
774
+ try_files $uri /index.html;
775
+ }
776
+
777
+ location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
778
+ expires 1h;
779
+ add_header Cache-Control "public, immutable";
780
+ }
781
+ }`,
782
+ proxy: `server {
783
+ listen ${listenPort};
784
+ server_name _;
785
+ large_client_header_buffers 4 32k;
786
+
787
+ location / {
788
+ proxy_pass http://127.0.0.1:${effectiveNodePort};
789
+ proxy_http_version 1.1;
790
+ proxy_set_header Upgrade $http_upgrade;
791
+ proxy_set_header Connection 'upgrade';
792
+ proxy_set_header Host $host;
793
+ proxy_set_header X-Real-IP $remote_addr;
794
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
795
+ proxy_set_header X-Forwarded-Proto $scheme;
796
+ proxy_cache_bypass $http_upgrade;
797
+ proxy_read_timeout 60s;
798
+ proxy_buffer_size 128k;
799
+ proxy_buffers 4 256k;
800
+ proxy_busy_buffers_size 256k;
801
+ }
802
+ }`,
803
+ static: `server {
804
+ listen ${targetPort};
805
+ root ${outputPath};
806
+ index index.html;
807
+
808
+ location / {
809
+ try_files $uri $uri.html $uri/ =404;
810
+ autoindex off;
811
+ }
812
+
813
+ location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
814
+ expires 1h;
815
+ add_header Cache-Control "public, immutable";
816
+ }
817
+ }`,
818
+ };
819
+
820
+ const config = templates[nginxType];
821
+ if (!config) {
822
+ throw new Error(`sandbox deploy nginx: unknown nginxType "${nginxType}". Must be one of: spa, proxy, static`);
823
+ }
824
+
825
+ const cmd = [
826
+ resolveScript,
827
+ `sudo mkdir -p /etc/nginx/conf.d`,
828
+ `sudo tee /etc/nginx/conf.d/${configName || project}.conf > /dev/null << 'NGINX_EOF'`,
829
+ config,
830
+ `NGINX_EOF`,
831
+ `# Resolve symlinks for chmod (chmod does not follow symlinks on Linux)`,
832
+ `chmod -R o+rX "$REAL_PROJECT" 2>/dev/null || true`,
833
+ `find "$REAL_PROJECT" -type d -exec chmod o+x {} \\; 2>/dev/null || true`,
834
+ `find "$REAL_PROJECT" -type f -path "*/node_modules/.bin/*" -exec chmod +x {} \\; 2>/dev/null || true`,
835
+ `if pgrep -x nginx > /dev/null 2>&1; then sudo killall -9 nginx 2>/dev/null; sleep 1; fi
836
+ sudo nginx`,
837
+ ].join('\n');
838
+
839
+ const result = await execOneShot(workspaceId, cmd, username, timeoutMs);
840
+
841
+ let tunnelActive = false;
842
+ try {
843
+ const tunnelCheck = await execOneShot(
844
+ workspaceId,
845
+ 'devbridge list -j 2>/dev/null | grep -q \'"tunnelId"\' && echo "ACTIVE" || echo "INACTIVE"',
846
+ username,
847
+ 10000,
848
+ );
849
+ tunnelActive = String(tunnelCheck.stdout || '').includes('ACTIVE');
850
+ } catch {}
851
+
852
+ return {
853
+ ok: result.exitCode === 0,
854
+ nginxType,
855
+ port: targetPort,
856
+ nodePort: effectiveNodePort || undefined,
857
+ outputPath,
858
+ projectPath,
859
+ exitCode: result.exitCode,
860
+ stdout: result.stdout,
861
+ nextStep: 'expose_via_devbridge',
862
+ warning:
863
+ (!tunnelActive
864
+ ? 'No active DevBridge tunnel — deployment is incomplete. Proceed to Step 7 to expose the app.'
865
+ : portWarning) || undefined,
866
+ };
867
+ }
868
+
869
+ export async function deployCheck(
870
+ workspaceId,
871
+ { port, project, outputDir, frameworkType },
872
+ username = 'root',
873
+ timeoutMs = 30000,
874
+ ) {
875
+ if (!workspaceId) {
876
+ throw new Error('sandbox deploy check: workspace_id is required.');
877
+ }
878
+
879
+ const projectPath = `/workspace/${project}`;
880
+ const outputPath = outputDir.startsWith('/') ? outputDir : `${projectPath}/${outputDir}`;
881
+ const isCrossPlatform = frameworkType === 'cross-platform';
882
+
883
+ const checkScript = [
884
+ `echo "=== DEPLOY CHECK ==="`,
885
+ `PASS=0`,
886
+ `TOTAL=0`,
887
+ ``,
888
+ `TOTAL=$((TOTAL+1))`,
889
+ `if curl -s -o /dev/null -w "%{http_code}" http://localhost:${port} 2>/dev/null | grep -qE "^(2|3)"; then`,
890
+ ` echo "nginx_serving:PASS (port ${port})"`,
891
+ ` PASS=$((PASS+1))`,
892
+ `else`,
893
+ ` echo "nginx_serving:FAIL"`,
894
+ `fi`,
895
+ ``,
896
+ `TOTAL=$((TOTAL+1))`,
897
+ `if [ -d "${outputPath}" ] && ls -A "${outputPath}" 2>/dev/null | grep -q .; then`,
898
+ ` echo "output_dir:PASS (${outputPath})"`,
899
+ ` PASS=$((PASS+1))`,
900
+ `else`,
901
+ ` echo "output_dir:FAIL (${outputPath} empty or missing)"`,
902
+ `fi`,
903
+ ``,
904
+ `TOTAL=$((TOTAL+1))`,
905
+ `FINGERPRINT_FILE="${outputPath}/.deploy_fingerprint"`,
906
+ `FINGERPRINT_EXPECTED=$(cat "$FINGERPRINT_FILE" 2>/dev/null)`,
907
+ `if [ -n "$FINGERPRINT_EXPECTED" ]; then`,
908
+ ` FINGERPRINT_ACTUAL=$(curl -s http://localhost:${port}/.deploy_fingerprint 2>/dev/null)`,
909
+ ` if [ "$FINGERPRINT_EXPECTED" = "$FINGERPRINT_ACTUAL" ]; then`,
910
+ ` echo "content_verified:PASS"`,
911
+ ` PASS=$((PASS+1))`,
912
+ ` else`,
913
+ ` echo "content_verified:FAIL (fingerprint mismatch — nginx may be serving stale content from a previous deployment)"`,
914
+ ` fi`,
915
+ `else`,
916
+ ` echo "content_verified:SKIP (no fingerprint file)"`,
917
+ ` TOTAL=$((TOTAL-1))`,
918
+ `fi`,
919
+ ``,
920
+ `TOTAL=$((TOTAL+1))`,
921
+ `if devbridge list -j 2>/dev/null | grep -q '"tunnelId"'; then`,
922
+ ` echo "devbridge_tunnel:PASS"`,
923
+ ` PASS=$((PASS+1))`,
924
+ `else`,
925
+ ` echo "devbridge_tunnel:FAIL"`,
926
+ `fi`,
927
+ ``,
928
+ `TOTAL=$((TOTAL+1))`,
929
+ `TUNNEL_ID=$(devbridge list -j 2>/dev/null | grep -oP '"tunnelId":\\s*"\\K[^"]+' | head -1)`,
930
+ `TUNNEL_URL="https://\${TUNNEL_ID}-${port}.cn-north-4-bridge.myhuaweicloud.com"`,
931
+ `if [ -n "$TUNNEL_ID" ] && [ -n "$TUNNEL_URL" ]; then`,
932
+ ` HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$TUNNEL_URL" 2>/dev/null || echo "000")`,
933
+ ` if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "304" ]; then`,
934
+ ` echo "tunnel_url_accessible:PASS ($TUNNEL_URL -> $HTTP_CODE)"`,
935
+ ` PASS=$((PASS+1))`,
936
+ ` else`,
937
+ ` echo "tunnel_url_accessible:FAIL ($TUNNEL_URL -> HTTP $HTTP_CODE)"`,
938
+ ` fi`,
939
+ `else`,
940
+ ` echo "tunnel_url_accessible:FAIL (no tunnel URL)"`,
941
+ `fi`,
942
+ ``,
943
+ `${`
944
+ TOTAL=$((TOTAL+1))
945
+ if [ -f "${outputPath}/qr.png" ]; then
946
+ echo "qr_code:PASS"
947
+ PASS=$((PASS+1))
948
+ else
949
+ # Auto-detect cross-platform from project files (more reliable than frameworkType param)
950
+ PROJ_DIR="/workspace/${project}"
951
+ IS_CROSS=0
952
+ if [ -f "$PROJ_DIR/manifest.json" ] || grep -qE '"@tarojs/taro"|"@dcloudio/uni-app"' "$PROJ_DIR/package.json" 2>/dev/null; then
953
+ IS_CROSS=1
954
+ fi
955
+ 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
956
+ IS_CROSS=1
957
+ fi
958
+
959
+ if [ $IS_CROSS -eq 1 ]; then
960
+ if [ -n "$TUNNEL_URL" ]; then
961
+ 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
962
+ chmod o+r "${outputPath}/qr.png" 2>/dev/null || true
963
+ if [ -f "${outputPath}/qr.png" ] && [ -s "${outputPath}/qr.png" ]; then
964
+ echo "qr_code:PASS (auto-generated)"
965
+ PASS=$((PASS+1))
966
+ else
967
+ echo "qr_code:FAIL (QR generation failed)"
968
+ fi
969
+ else
970
+ echo "qr_code:FAIL (no tunnel URL for QR generation)"
971
+ fi
972
+ else
973
+ echo "qr_code:SKIP (not a cross-platform project)"
974
+ TOTAL=$((TOTAL-1))
975
+ fi
976
+ fi
977
+ `}`,
978
+ `echo "SCORE:\${PASS}/\${TOTAL}"`,
979
+ `echo "TUNNEL_URL:\${TUNNEL_URL:-}"`,
980
+ `[ "\${PASS}" = "\${TOTAL}" ] && echo "VERDICT:COMPLETE" || echo "VERDICT:INCOMPLETE"`,
981
+ ]
982
+ .filter(Boolean)
983
+ .join('\n');
984
+
985
+ const result = await execOneShot(workspaceId, checkScript, username, timeoutMs);
986
+ const stdout = String(result.stdout || '');
987
+ const checks = {};
988
+ const lines = stdout.split('\n');
989
+ for (const line of lines) {
990
+ const m = line.match(/^(\w+):(\w+)\b(.*)/);
991
+ if (m) checks[m[1]] = { status: m[2], detail: (m[3] || '').trim() };
992
+ }
993
+ const scoreMatch = stdout.match(/SCORE:(\d+)\/(\d+)/);
994
+ const tunnelMatch = stdout.match(/TUNNEL_URL:(https:\/\/[^\s]+)/);
995
+ const complete = /VERDICT:COMPLETE/.test(stdout);
996
+
997
+ const missing = [];
998
+ if (!complete) {
999
+ for (const [key, val] of Object.entries(checks)) {
1000
+ if (val.status === 'FAIL') missing.push(key);
1001
+ }
1002
+ }
1003
+
1004
+ return {
1005
+ ok: true,
1006
+ complete,
1007
+ checkType: isCrossPlatform ? 'cross-platform' : 'standard',
1008
+ checks,
1009
+ score: scoreMatch ? { pass: parseInt(scoreMatch[1], 10), total: parseInt(scoreMatch[2], 10) } : null,
1010
+ publicUrl: tunnelMatch ? tunnelMatch[1] : undefined,
1011
+ missingSteps: missing.length > 0 ? missing.join(', ') : undefined,
1012
+ nextStep: !complete
1013
+ ? missing.includes('devbridge_tunnel') || missing.includes('tunnel_url_accessible')
1014
+ ? 'expose_via_devbridge'
1015
+ : missing.includes('nginx_serving')
1016
+ ? 'configure_nginx'
1017
+ : missing.includes('qr_code')
1018
+ ? 'generate_qr_code'
1019
+ : 'review_checks'
1020
+ : 'complete',
1021
+ };
1022
+ }
1023
+
96
1024
  export async function closeSession(workspaceId, username) {
97
1025
  const key = `${workspaceId}:${username}`;
98
1026
  const session = sessions.get(key);
99
1027
  if (!session) return false;
100
1028
  sessions.delete(key);
101
- try { session.close(); } catch {}
1029
+ try {
1030
+ session.close();
1031
+ } catch {}
1032
+ trackSandboxDisconnect();
102
1033
  return true;
103
1034
  }
104
1035
 
105
1036
  export async function closeAllSessions() {
106
1037
  for (const [key, session] of sessions) {
107
1038
  sessions.delete(key);
108
- try { session.close(); } catch {}
1039
+ try {
1040
+ session.close();
1041
+ } catch {}
109
1042
  }
110
1043
  }
111
1044
 
112
- export { DEFAULT_WORKSPACE_ID, runNodeExec };
1045
+ export { getCurrentWorkspaceId, setWorkspaceId, runNodeExec };